feat: offer contact validation flow

This commit is contained in:
2026-09-16 10:40:01 +02:00
parent 5d7ffd7d58
commit 12fe4d8e15
15 changed files with 1219 additions and 554 deletions
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups\Offer;
use App\Entity\Groups\AccommodationBooking;
use App\Htmx\HxTrait;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as SymfonyAbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
abstract class AbstractController extends SymfonyAbstractController
{
use HxTrait;
/**
* A draft and an inquiry the office has not worked through yet are not ready to be
* shown, and a discarded booking must not be viewable any more — in all three cases
* the link behaves as if it had expired.
*/
protected function isCustomerVisible(AccommodationBooking $booking): bool
{
return $booking->isCustomerAccessible() && !$booking->isRequested() && !$booking->isDiscarded();
}
/**
* Sends the browser to a full reload of the (non-modal) offer page — this is
* triggered from an htmx-loaded modal, so a plain render/redirect here would
* get appended as an inert HTML fragment instead of actually navigating.
*/
protected function redirectToOfferPage(Request $request, string $uuid): Response
{
$url = $this->generateUrl('app_groups_offer_view', ['uuid' => $uuid]);
return $this->htmxRedirect($request, $url);
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups\Offer;
use App\Form\Model\OfferAcceptDto;
use App\Form\OfferAcceptConfirmationType;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationTermsUrlProvider;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ConfirmController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingRepository $bookingRepository,
private readonly AccommodationBookingLinkSigner $linkSigner,
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationTermsUrlProvider $termsUrlProvider,
) {
}
#[Route(path: '/groups/booking/offer/{uuid}/confirm', name: 'app_groups_offer_confirm', methods: ['GET', 'POST'])]
public function index(string $uuid, Request $request): Response
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
return $this->redirectToOfferPage($request, $uuid);
}
// Offen is the one status that means an offer is out and awaiting acceptance, so it
// is the whole guard — mirroring acceptBooking(), which no-ops on anything else.
if (!$booking->isOpen()) {
return $this->redirectToOfferPage($request, $uuid);
}
// Checked for POST as well, so the modal cannot be submitted around the contact page.
if (!$this->bookingService->hasCompleteContactData($booking)) {
return $this->htmxRedirect($request, $this->generateUrl('app_groups_offer_contact', ['uuid' => $uuid]));
}
$dto = new OfferAcceptDto(remarks: $booking->getRemarks());
$confirmationForm = $this->createForm(OfferAcceptConfirmationType::class, $dto, [
'terms_url' => $this->termsUrlProvider->forAccommodation($booking->getAccommodation()),
]);
$confirmationForm->handleRequest($request);
if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) {
// The textarea is prefilled, so the submitted value is the complete remark —
// an emptied field arrives as null and must clear the stored one, not keep it.
$this->bookingService->acceptBooking($booking, $dto->remarks ?? '');
$this->addFlash('success', 'Deine Buchung ist bei uns eingegangen. Sobald sie geprüft ist, erhältst du eine Bestätigung per E-Mail.');
return $this->redirectToOfferPage($request, $uuid);
}
return $this->render('groups/offer/modal_accept_confirmation.html.twig', [
'booking' => $booking,
'confirmationForm' => $confirmationForm,
]);
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups\Offer;
use App\Form\AccommodationContactType;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ContactController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingRepository $bookingRepository,
private readonly AccommodationBookingLinkSigner $linkSigner,
private readonly AccommodationBookingService $bookingService,
) {
}
/**
* Full page rather than modal: the form is as large as step 3 of a self-service booking,
* whose fields and constraints it shares. Once saved, the customer is back on the offer
* and accepts it from there as usual.
*/
#[Route(path: '/groups/booking/offer/{uuid}/contact', name: 'app_groups_offer_contact', methods: ['GET', 'POST'])]
public function index(string $uuid, Request $request): Response
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
return $this->redirectToOfferPage($request, $uuid);
}
if (!$booking->isOpen()) {
return $this->redirectToOfferPage($request, $uuid);
}
$dto = $this->bookingService->contactDataFromBooking($booking);
$form = $this->createForm(AccommodationContactType::class, $dto, [
'email_readonly' => true,
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->bookingService->applyContactData($booking, $dto);
$this->addFlash('success', 'Deine Kontaktdaten wurden gespeichert. Du kannst die Buchung jetzt abschließen.');
return $this->redirectToOfferPage($request, $uuid);
}
$accommodation = $booking->getAccommodation() ?? throw $this->createNotFoundException('Booking has no accommodation.');
$ctx = $this->bookingService->createOfferContext($booking, $accommodation);
return $this->render('groups/offer/contact.html.twig', [
'booking' => $booking,
'priceBreakdown' => $ctx->priceBreakdown,
'ctx' => $ctx,
'form' => $form,
]);
}
}
+1 -102
View File
@@ -4,17 +4,8 @@ declare(strict_types=1);
namespace App\Controller\Groups\Offer;
use App\Entity\Groups\AccommodationBooking;
use App\Form\Model\OfferAcceptDto;
use App\Form\OfferAcceptConfirmationType;
use App\Htmx\HxTrait;
use App\Model\AccommodationBookingContext;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationTermsUrlProvider;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -22,14 +13,9 @@ use Symfony\Component\Routing\Attribute\Route;
class IndexController extends AbstractController
{
use HxTrait;
public function __construct(
private readonly AccommodationBookingRepository $bookingRepository,
private readonly AccommodationBookingLinkSigner $linkSigner,
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationTermsUrlProvider $termsUrlProvider,
) {
}
@@ -39,7 +25,7 @@ class IndexController extends AbstractController
* (session-gated) offer page — nothing downstream needs the signature again.
*/
#[Route(path: '/groups/booking/offer/{uuid}', name: 'app_groups_offer', methods: ['GET'])]
public function access(string $uuid, Request $request): Response
public function index(string $uuid, Request $request): Response
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
@@ -55,91 +41,4 @@ class IndexController extends AbstractController
return new RedirectResponse($this->generateUrl('app_groups_offer_view', ['uuid' => $uuid]));
}
#[Route(path: '/groups/booking/offer/{uuid}/view', name: 'app_groups_offer_view', methods: ['GET'])]
public function view(string $uuid, Request $request): Response
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
return $this->render('groups/offer/unavailable.html.twig');
}
if (!$this->isCustomerVisible($booking)) {
return $this->render('groups/offer/unavailable.html.twig');
}
$accommodation = $booking->getAccommodation() ?? throw $this->createNotFoundException('Booking has no accommodation.');
$priceBreakdown = $this->breakdownCalculator->compute($booking);
$ctx = new AccommodationBookingContext(
accommodation: $accommodation,
hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation),
priceBreakdown: $priceBreakdown,
);
return $this->render('groups/offer/view.html.twig', [
'booking' => $booking,
'priceBreakdown' => $priceBreakdown,
'ctx' => $ctx,
]);
}
#[Route(path: '/groups/booking/offer/{uuid}/confirm', name: 'app_groups_offer_confirm', methods: ['GET', 'POST'])]
public function confirm(string $uuid, Request $request): Response
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
return $this->redirectToOfferPage($request, $uuid);
}
// Offen is the one status that means an offer is out and awaiting acceptance, so it
// is the whole guard — mirroring acceptBooking(), which no-ops on anything else.
if (!$booking->isOpen()) {
return $this->redirectToOfferPage($request, $uuid);
}
$dto = new OfferAcceptDto(remarks: $booking->getRemarks());
$confirmationForm = $this->createForm(OfferAcceptConfirmationType::class, $dto, [
'terms_url' => $this->termsUrlProvider->forAccommodation($booking->getAccommodation()),
]);
$confirmationForm->handleRequest($request);
if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) {
// The textarea is prefilled, so the submitted value is the complete remark —
// an emptied field arrives as null and must clear the stored one, not keep it.
$this->bookingService->acceptBooking($booking, $dto->remarks ?? '');
$this->addFlash('success', 'Deine Buchung ist bei uns eingegangen. Sobald sie geprüft ist, erhältst du eine Bestätigung per E-Mail.');
return $this->redirectToOfferPage($request, $uuid);
}
return $this->render('groups/offer/modal_accept_confirmation.html.twig', [
'booking' => $booking,
'confirmationForm' => $confirmationForm,
]);
}
/**
* A draft and an inquiry the office has not worked through yet are not ready to be
* shown, and a discarded booking must not be viewable any more — in all three cases
* the link behaves as if it had expired.
*/
private function isCustomerVisible(AccommodationBooking $booking): bool
{
return $booking->isCustomerAccessible() && !$booking->isRequested() && !$booking->isDiscarded();
}
/**
* Sends the browser to a full reload of the (non-modal) offer page — this is
* triggered from an htmx-loaded modal, so a plain render/redirect here would
* get appended as an inert HTML fragment instead of actually navigating.
*/
private function redirectToOfferPage(Request $request, string $uuid): Response
{
$url = $this->generateUrl('app_groups_offer_view', ['uuid' => $uuid]);
return $this->htmxRedirect($request, $url);
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups\Offer;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ViewController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingRepository $bookingRepository,
private readonly AccommodationBookingLinkSigner $linkSigner,
private readonly AccommodationBookingService $bookingService,
) {
}
#[Route(path: '/groups/booking/offer/{uuid}/view', name: 'app_groups_offer_view', methods: ['GET'])]
public function index(string $uuid, Request $request): Response
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
return $this->render('groups/offer/unavailable.html.twig');
}
if (!$this->isCustomerVisible($booking)) {
return $this->render('groups/offer/unavailable.html.twig');
}
$accommodation = $booking->getAccommodation() ?? throw $this->createNotFoundException('Booking has no accommodation.');
$ctx = $this->bookingService->createOfferContext($booking, $accommodation);
return $this->render('groups/offer/view.html.twig', [
'booking' => $booking,
'priceBreakdown' => $ctx->priceBreakdown,
'ctx' => $ctx,
]);
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Form\Model\AccommodationBookingDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* The contact data of a group booking — shared by step 3 of the self-service flow and by
* the page that completes an office-made offer before it can be accepted, so both ask
* for the same fields under the same constraints.
*
* @extends AbstractType<AccommodationBookingDto>
*/
class AccommodationContactType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('groupName', TextType::class, [
'label' => 'Name der Gruppe',
])
->add('salutation', ChoiceType::class, [
'label' => 'Anrede',
'choices' => [
'Herr' => 'Herr',
'Frau' => 'Frau',
'divers' => 'divers',
],
'expanded' => false,
'multiple' => false,
'placeholder' => 'Bitte wählen',
])
->add('firstName', TextType::class, [
'label' => 'Vorname',
])
->add('lastName', TextType::class, [
'label' => 'Nachname',
])
->add('email', EmailType::class, [
'label' => 'E-Mail-Adresse',
// An offer's access link and every notification already go to this address.
'disabled' => $options['email_readonly'],
])
->add('phone', TextType::class, [
'label' => 'Telefon',
])
->add('street', TextType::class, [
'label' => 'Straße und Hausnummer',
])
->add('zip', TextType::class, [
'label' => 'Postleitzahl',
])
->add('city', TextType::class, [
'label' => 'Ort',
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => AccommodationBookingDto::class,
'validation_groups' => ['contact'],
'email_readonly' => false,
]);
$resolver->setAllowedTypes('email_readonly', 'bool');
}
}
+5 -38
View File
@@ -6,10 +6,7 @@ namespace App\Form;
use App\Form\Model\AccommodationBookingDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -21,41 +18,6 @@ class AccommodationStep3Type extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('groupName', TextType::class, [
'label' => 'Name der Gruppe',
])
->add('salutation', ChoiceType::class, [
'label' => 'Anrede',
'choices' => [
'Herr' => 'Herr',
'Frau' => 'Frau',
'divers' => 'divers',
],
'expanded' => false,
'multiple' => false,
'placeholder' => 'Bitte wählen',
])
->add('firstName', TextType::class, [
'label' => 'Vorname',
])
->add('lastName', TextType::class, [
'label' => 'Nachname',
])
->add('email', EmailType::class, [
'label' => 'E-Mail-Adresse',
])
->add('phone', TextType::class, [
'label' => 'Telefon',
])
->add('street', TextType::class, [
'label' => 'Straße und Hausnummer',
])
->add('zip', TextType::class, [
'label' => 'Postleitzahl',
])
->add('city', TextType::class, [
'label' => 'Ort',
])
->add('remarks', TextareaType::class, [
'label' => false,
'required' => false,
@@ -71,4 +33,9 @@ class AccommodationStep3Type extends AbstractType
'validation_groups' => ['step_3'],
]);
}
public function getParent(): string
{
return AccommodationContactType::class;
}
}
+13 -11
View File
@@ -43,33 +43,35 @@ class AccommodationBookingDto
/** @var array<string, mixed> */
public array $priceBreakdown = [];
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
// `contact` is the subset an office-made offer is checked against before the customer
// may accept it — the same constraints the customer meets in step 3 of a self-service booking.
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
public ?string $groupName = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
#[Assert\Choice(choices: ['Herr', 'Frau', 'divers'], groups: ['step_3'])]
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
#[Assert\Choice(choices: ['Herr', 'Frau', 'divers'], groups: ['step_3', 'contact'])]
public ?string $salutation = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
public ?string $firstName = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
public ?string $lastName = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
#[Assert\Email(message: 'invalid', groups: ['step_3'])]
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
#[Assert\Email(message: 'invalid', groups: ['step_3', 'contact'])]
public ?string $email = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
public ?string $phone = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
public ?string $street = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
public ?string $zip = null;
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
public ?string $city = null;
public ?string $remarks = null;
+62 -10
View File
@@ -14,6 +14,7 @@ use App\Entity\Groups\BoardService;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Form\Model\AccommodationBookingDto;
use App\Model\AccommodationBookingContext;
use App\Model\AccommodationBookingQueryParams;
use App\Model\CmsHotelData;
use App\Model\InquiryStatus;
@@ -23,6 +24,7 @@ use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class AccommodationBookingService
{
@@ -41,6 +43,7 @@ class AccommodationBookingService
private readonly AccommodationBookingLinkSigner $linkSigner,
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
private readonly AccommodationBookingPdfGenerator $pdfGenerator,
private readonly ValidatorInterface $validator,
private readonly string $accommodationEmail,
) {
}
@@ -115,6 +118,19 @@ class AccommodationBookingService
return $this->cmsDataProvider->getHotelDetails($accommodation->getEffectiveCmsCode());
}
/**
* What the customer-facing offer pages show around a stored booking: the hotel's CMS
* content and the price breakdown computed from the booking's frozen snapshot.
*/
public function createOfferContext(AccommodationBooking $booking, Accommodation $accommodation): AccommodationBookingContext
{
return new AccommodationBookingContext(
accommodation: $accommodation,
hotelCmsData: $this->loadHotelCmsData($accommodation),
priceBreakdown: $this->breakdownCalculator->compute($booking),
);
}
/**
* @return AccommodationPrice[]
*/
@@ -262,7 +278,6 @@ class AccommodationBookingService
): AccommodationBooking {
$booking = new AccommodationBooking();
$booking->setAccommodation($accommodation);
$booking->setGroupName($dto->groupName);
$booking->setDateFrom($dto->dateFrom);
$booking->setDateTo($dto->dateTo);
$booking->setPaxCount($dto->paxCount);
@@ -305,15 +320,7 @@ class AccommodationBookingService
);
}
// Personal data
$booking->setSalutation($dto->salutation);
$booking->setFirstName($dto->firstName);
$booking->setLastName($dto->lastName);
$booking->setEmail($dto->email);
$booking->setPhone($dto->phone);
$booking->setStreet($dto->street);
$booking->setZip($dto->zip);
$booking->setCity($dto->city);
$this->copyContactData($dto, $booking);
$booking->setRemarks($dto->remarks);
$this->refreshPriceSnapshot($booking);
@@ -627,6 +634,51 @@ class AccommodationBookingService
$this->sendOfferAcceptedNotificationEmail($booking);
}
/**
* Offers are put together by the office, which only has to supply name and email, so
* the rest of the contact data is often missing. A customer may accept an offer once it
* meets the constraints a self-service booking meets in step 3.
*/
public function hasCompleteContactData(AccommodationBooking $booking): bool
{
return 0 === $this->validator->validate($this->contactDataFromBooking($booking), null, ['contact'])->count();
}
public function contactDataFromBooking(AccommodationBooking $booking): AccommodationBookingDto
{
$dto = new AccommodationBookingDto();
$dto->groupName = $booking->getGroupName();
$dto->salutation = $booking->getSalutation();
$dto->firstName = $booking->getFirstName();
$dto->lastName = $booking->getLastName();
$dto->email = $booking->getEmail();
$dto->phone = $booking->getPhone();
$dto->street = $booking->getStreet();
$dto->zip = $booking->getZip();
$dto->city = $booking->getCity();
return $dto;
}
public function applyContactData(AccommodationBooking $booking, AccommodationBookingDto $dto): void
{
$this->copyContactData($dto, $booking);
$this->entityManager->flush();
}
private function copyContactData(AccommodationBookingDto $dto, AccommodationBooking $booking): void
{
$booking->setGroupName($dto->groupName);
$booking->setSalutation($dto->salutation);
$booking->setFirstName($dto->firstName);
$booking->setLastName($dto->lastName);
$booking->setEmail($dto->email);
$booking->setPhone($dto->phone);
$booking->setStreet($dto->street);
$booking->setZip($dto->zip);
$booking->setCity($dto->city);
}
/**
* Explicit office action after the requested services and capacities have been validated:
* this is the moment the booking becomes binding for the customer, and the only place the
+90
View File
@@ -0,0 +1,90 @@
{% extends 'layout_booking.html.twig' %}
{% block title %}Kontaktdaten vervollständigen{% endblock %}
{% block background %}bg-outer bg-outer--summer{% endblock %}
{% block content %}
{% form_theme form 'booking/_form_theme.html.twig' %}
{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
{{ form_start(form, {
'attr': {
'class': 'flex-1 flex flex-col min-h-0',
'novalidate': 'novalidate',
}
}) }}
<div class="flex-1 flex flex-col lg:grid lg:grid-cols-5 min-h-0 relative">
{# Sidebar summary #}
<div class="order-1 lg:order-2 lg:flex-1 lg:min-h-0 lg:col-span-2"
{{ stimulus_controller('toggle', { 'open': false }, { 'closed': 'hidden', 'open': 'absolute inset-0 z-20', 'iconOpen': 'rotate-180' }) }}>
{% include 'groups/offer/_summary.html.twig' with {
booking: booking,
ctx: ctx,
currency: currency,
} %}
</div>
{# Contact data form #}
<div class="flex-1 order-2 lg:order-1 lg:col-span-3 bg-white flex flex-col min-h-0">
{% include '_partials/_flashes.html.twig' %}
<div class="flex-1 overflow-y-auto scroll-stable px-4 lg:px-8 py-8">
<hgroup class="pb-4">
<h1 class="text-2xl lg:text-4xl font-bold mb-2">
Kontaktdaten vervollständigen
</h1>
<p>
Bevor du das Angebot für {{ ctx.accommodation.name }} buchen kannst, benötigen wir
noch deine vollständigen Kontaktdaten.
</p>
</hgroup>
{% from '_partials/_validation_errors.html.twig' import validation_alert %}
{{ validation_alert(form, 'Bitte fülle alle Pflichtfelder aus.') }}
<div class="grid lg:grid-cols-2 lg:gap-x-8">
{{ form_row(form.groupName) }}
{{ form_row(form.salutation) }}
</div>
<div class="grid lg:grid-cols-2 lg:gap-x-8">
{{ form_row(form.firstName) }}
{{ form_row(form.lastName) }}
</div>
<div class="grid lg:grid-cols-2 lg:gap-x-8">
{{ form_row(form.email) }}
{{ form_row(form.phone) }}
</div>
<h3 class="pt-4 pb-2">Adresse</h3>
{{ form_row(form.street) }}
<div class="grid lg:grid-cols-2 lg:gap-x-8">
{{ form_row(form.zip) }}
{{ form_row(form.city) }}
</div>
</div>
</div>
</div>
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
<div class="flex items-center justify-between">
<a href="{{ path('app_groups_offer_view', { uuid: booking.uuid }) }}" class="button button--secondary">
Zurück zum Angebot
</a>
<button type="submit" class="button button--primary">
Speichern
</button>
</div>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Groups\Offer;
use App\Controller\Groups\Offer\ConfirmController;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Form\Model\OfferAcceptDto;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationTermsUrlProvider;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class ConfirmControllerTest extends TestCase
{
public function testGetRendersModalWithFreshForm(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setRemarks("Bitte Zimmer im EG\nDanke!");
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking);
$confirmationForm = $this->createStub(FormInterface::class);
$confirmationForm->method('handleRequest')->willReturnSelf();
$confirmationForm->method('isSubmitted')->willReturn(false);
$bookingService = $this->serviceWithCompleteContactData();
$bookingService->expects(self::never())->method('acceptBooking');
$controller = new TestableOfferConfirmController(
$bookingRepository,
$this->authorizedLinkSigner(),
$bookingService,
$confirmationForm,
);
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/offer/modal_accept_confirmation.html.twig', $controller->renderedView);
self::assertSame($booking, $controller->renderedParameters['booking']);
self::assertSame($confirmationForm, $controller->renderedParameters['confirmationForm']);
self::assertInstanceOf(OfferAcceptDto::class, $controller->formData);
self::assertSame("Bitte Zimmer im EG\nDanke!", $controller->formData->remarks, 'the stored remark is prefilled into the modal');
self::assertFalse($controller->formData->termsAccepted);
}
public function testPostValidAcceptsBookingAndRedirects(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setRemarks('Bitte Zimmer im EG');
$bookingService = $this->serviceWithCompleteContactData();
$bookingService->expects(self::once())->method('acceptBooking')->with($booking, 'Bitte Zimmer im EG');
$controller = new TestableOfferConfirmController(
$this->repositoryReturning($booking),
$this->authorizedLinkSigner(),
$bookingService,
$this->submittedForm(valid: true),
);
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST'));
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/app_groups_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl());
self::assertSame([['type' => 'success', 'message' => 'Deine Buchung ist bei uns eingegangen. Sobald sie geprüft ist, erhältst du eine Bestätigung per E-Mail.']], $controller->flashes);
}
public function testPostValidViaHtmxReturnsHxRedirect(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$bookingService = $this->serviceWithCompleteContactData();
// No remark on the booking and none submitted: the empty string clears rather than keeps.
$bookingService->expects(self::once())->method('acceptBooking')->with($booking, '');
$controller = new TestableOfferConfirmController(
$this->repositoryReturning($booking),
$this->authorizedLinkSigner(),
$bookingService,
$this->submittedForm(valid: true),
);
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST');
$request->headers->set('HX-Request', 'true');
$response = $controller->index($booking->getUuid(), $request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertTrue($response->headers->has('HX-Redirect'));
}
public function testPostInvalidReRendersModalWithoutAccepting(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$bookingService = $this->serviceWithCompleteContactData();
$bookingService->expects(self::never())->method('acceptBooking');
$controller = new TestableOfferConfirmController(
$this->repositoryReturning($booking),
$this->authorizedLinkSigner(),
$bookingService,
$this->submittedForm(valid: false),
);
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertFalse($response->headers->has('HX-Redirect'));
self::assertSame('groups/offer/modal_accept_confirmation.html.twig', $controller->renderedView);
}
#[DataProvider('methods')]
public function testSendsToContactPageWhenContactDataIsIncomplete(string $method): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('hasCompleteContactData')->with($booking)->willReturn(false);
$bookingService->expects(self::never())->method('acceptBooking');
$controller = new TestableOfferConfirmController(
$this->repositoryReturning($booking),
$this->authorizedLinkSigner(),
$bookingService,
);
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', $method);
$request->headers->set('HX-Request', 'true');
$response = $controller->index($booking->getUuid(), $request);
self::assertSame('/app_groups_offer_contact?uuid='.$booking->getUuid(), $response->headers->get('HX-Redirect'));
self::assertNull($controller->renderedView);
}
/**
* @return iterable<string, array{string}>
*/
public static function methods(): iterable
{
yield 'opening the modal' => ['GET'];
yield 'submitting it anyway' => ['POST'];
}
public function testRedirectsWhenAlreadyAccepted(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Received);
$controller = new TestableOfferConfirmController(
$this->repositoryReturning($booking),
$this->authorizedLinkSigner(),
$this->createStub(AccommodationBookingService::class),
);
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm'));
self::assertInstanceOf(RedirectResponse::class, $response);
}
public function testRedirectsWhenSessionNotAuthorized(): void
{
$booking = new AccommodationBooking();
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(false);
$controller = new TestableOfferConfirmController(
$this->repositoryReturning($booking),
$linkSigner,
$this->createStub(AccommodationBookingService::class),
);
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm'));
self::assertInstanceOf(RedirectResponse::class, $response);
}
public function testRedirectsForUnknownUuid(): void
{
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn(null);
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->expects(self::never())->method('isSessionAuthorized');
$controller = new TestableOfferConfirmController(
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingService::class),
);
$response = $controller->index('unknown-uuid', Request::create('/groups/booking/offer/unknown-uuid/confirm'));
self::assertInstanceOf(RedirectResponse::class, $response);
}
private function repositoryReturning(AccommodationBooking $booking): AccommodationBookingRepository
{
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
return $bookingRepository;
}
private function authorizedLinkSigner(): AccommodationBookingLinkSigner
{
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
return $linkSigner;
}
private function serviceWithCompleteContactData(): AccommodationBookingService&MockObject
{
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('hasCompleteContactData')->willReturn(true);
return $bookingService;
}
private function submittedForm(bool $valid): FormInterface
{
$form = $this->createStub(FormInterface::class);
$form->method('handleRequest')->willReturnSelf();
$form->method('isSubmitted')->willReturn(true);
$form->method('isValid')->willReturn($valid);
return $form;
}
}
final class TestableOfferConfirmController extends ConfirmController
{
public ?string $renderedView = null;
/** @var array<string, mixed> */
public array $renderedParameters = [];
public mixed $formData = null;
/** @var array<int, array{type: string, message: mixed}> */
public array $flashes = [];
public function __construct(
AccommodationBookingRepository $bookingRepository,
AccommodationBookingLinkSigner $linkSigner,
AccommodationBookingService $bookingService,
private readonly ?FormInterface $form = null,
) {
parent::__construct(
$bookingRepository,
$linkSigner,
$bookingService,
new AccommodationTermsUrlProvider(['AT' => '', 'CH' => '', 'IT' => ''], 'https://example.test/agb/'),
);
}
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
{
$this->formData = $data;
return $this->form ?? throw new \LogicException('No form mock configured for this test.');
}
protected function addFlash(string $type, mixed $message): void
{
$this->flashes[] = ['type' => $type, 'message' => $message];
}
/**
* @param array<string, mixed> $parameters
*/
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
{
return '/'.$route.'?'.http_build_query($parameters);
}
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{
$this->renderedView = $view;
$this->renderedParameters = $parameters;
return $response ?? new Response('<html></html>');
}
}
@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Groups\Offer;
use App\Controller\Groups\Offer\ContactController;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Form\Model\AccommodationBookingDto;
use App\Model\AccommodationBookingContext;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class ContactControllerTest extends TestCase
{
public function testGetRendersPagePrefilledFromBooking(): void
{
$booking = $this->openBooking();
$dto = new AccommodationBookingDto();
$ctx = new AccommodationBookingContext(accommodation: $booking->getAccommodation(), priceBreakdown: ['total' => 1000]);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('contactDataFromBooking')->with($booking)->willReturn($dto);
$bookingService->method('createOfferContext')->with($booking, $booking->getAccommodation())->willReturn($ctx);
$bookingService->expects(self::never())->method('applyContactData');
$form = $this->createStub(FormInterface::class);
$form->method('handleRequest')->willReturnSelf();
$form->method('isSubmitted')->willReturn(false);
$controller = new TestableOfferContactController(
$this->repositoryReturning($booking),
$this->linkSigner(authorized: true),
$bookingService,
$form,
);
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/contact'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/offer/contact.html.twig', $controller->renderedView);
self::assertSame($form, $controller->renderedParameters['form']);
self::assertSame($ctx, $controller->renderedParameters['ctx']);
self::assertSame(['total' => 1000], $controller->renderedParameters['priceBreakdown']);
self::assertSame($dto, $controller->formData);
self::assertSame(['email_readonly' => true], $controller->formOptions);
}
public function testPostValidSavesAndReturnsToOffer(): void
{
$booking = $this->openBooking();
$dto = new AccommodationBookingDto();
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('contactDataFromBooking')->willReturn($dto);
$bookingService->expects(self::once())->method('applyContactData')->with($booking, $dto);
$bookingService->expects(self::never())->method('acceptBooking');
$controller = new TestableOfferContactController(
$this->repositoryReturning($booking),
$this->linkSigner(authorized: true),
$bookingService,
$this->submittedForm(valid: true),
);
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/contact', 'POST'));
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/app_groups_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl());
self::assertSame([['type' => 'success', 'message' => 'Deine Kontaktdaten wurden gespeichert. Du kannst die Buchung jetzt abschließen.']], $controller->flashes);
}
public function testPostInvalidReRendersWithoutSaving(): void
{
$booking = $this->openBooking();
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('contactDataFromBooking')->willReturn(new AccommodationBookingDto());
$bookingService->method('createOfferContext')->willReturn(new AccommodationBookingContext(accommodation: $booking->getAccommodation()));
$bookingService->expects(self::never())->method('applyContactData');
$controller = new TestableOfferContactController(
$this->repositoryReturning($booking),
$this->linkSigner(authorized: true),
$bookingService,
$this->submittedForm(valid: false),
);
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/contact', 'POST'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/offer/contact.html.twig', $controller->renderedView);
}
#[DataProvider('guards')]
public function testRedirectsToOfferWhenNotEditable(AccommodationBookingStatus $status, bool $authorized): void
{
$booking = new AccommodationBooking();
$booking->setStatus($status);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('applyContactData');
$controller = new TestableOfferContactController(
$this->repositoryReturning($booking),
$this->linkSigner($authorized),
$bookingService,
);
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/contact', 'POST'));
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/app_groups_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl());
}
/**
* @return iterable<string, array{AccommodationBookingStatus, bool}>
*/
public static function guards(): iterable
{
yield 'unauthorized session' => [AccommodationBookingStatus::Open, false];
yield 'already accepted' => [AccommodationBookingStatus::Received, true];
yield 'discarded' => [AccommodationBookingStatus::Discarded, true];
}
private function openBooking(): AccommodationBooking
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setAccommodation(new Accommodation());
return $booking;
}
private function repositoryReturning(AccommodationBooking $booking): AccommodationBookingRepository
{
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
return $bookingRepository;
}
private function linkSigner(bool $authorized): AccommodationBookingLinkSigner
{
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn($authorized);
return $linkSigner;
}
private function submittedForm(bool $valid): FormInterface
{
$form = $this->createStub(FormInterface::class);
$form->method('handleRequest')->willReturnSelf();
$form->method('isSubmitted')->willReturn(true);
$form->method('isValid')->willReturn($valid);
return $form;
}
}
final class TestableOfferContactController extends ContactController
{
public ?string $renderedView = null;
/** @var array<string, mixed> */
public array $renderedParameters = [];
public mixed $formData = null;
/** @var array<string, mixed> */
public array $formOptions = [];
/** @var array<int, array{type: string, message: mixed}> */
public array $flashes = [];
public function __construct(
AccommodationBookingRepository $bookingRepository,
AccommodationBookingLinkSigner $linkSigner,
AccommodationBookingService $bookingService,
private readonly ?FormInterface $form = null,
) {
parent::__construct($bookingRepository, $linkSigner, $bookingService);
}
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
{
$this->formData = $data;
$this->formOptions = $options;
return $this->form ?? throw new \LogicException('No form mock configured for this test.');
}
protected function addFlash(string $type, mixed $message): void
{
$this->flashes[] = ['type' => $type, 'message' => $message];
}
/**
* @param array<string, mixed> $parameters
*/
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
{
return '/'.$route.'?'.http_build_query($parameters);
}
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{
$this->renderedView = $view;
$this->renderedParameters = $parameters;
return $response ?? new Response('<html></html>');
}
}
@@ -5,27 +5,19 @@ declare(strict_types=1);
namespace App\Tests\Controller\Groups\Offer;
use App\Controller\Groups\Offer\IndexController;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Form\Model\OfferAcceptDto;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationTermsUrlProvider;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
class IndexControllerTest extends TestCase
{
public function testAccessAuthorizesSessionAndRedirectsToView(): void
public function testAuthorizesSessionAndRedirectsToView(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
@@ -37,20 +29,15 @@ class IndexControllerTest extends TestCase
$linkSigner->method('isValidLinkRequest')->willReturn(true);
$linkSigner->expects(self::once())->method('authorizeSession')->with(self::anything(), $booking);
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$this->createStub(AccommodationBookingService::class),
);
$controller = new TestableOfferIndexController($bookingRepository, $linkSigner);
$response = $controller->access($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid()));
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid()));
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/app_groups_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl());
}
public function testAccessRendersUnavailableForInvalidLink(): void
public function testRendersUnavailableForInvalidLink(): void
{
$booking = new AccommodationBooking();
@@ -61,20 +48,15 @@ class IndexControllerTest extends TestCase
$linkSigner->method('isValidLinkRequest')->willReturn(false);
$linkSigner->expects(self::never())->method('authorizeSession');
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$this->createStub(AccommodationBookingService::class),
);
$controller = new TestableOfferIndexController($bookingRepository, $linkSigner);
$response = $controller->access($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid()));
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid()));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/offer/unavailable.html.twig', $controller->renderedView);
}
public function testAccessRendersUnavailableForUnknownUuid(): void
public function testRendersUnavailableForUnknownUuid(): void
{
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn(null);
@@ -82,21 +64,16 @@ class IndexControllerTest extends TestCase
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->expects(self::never())->method('isValidLinkRequest');
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$this->createStub(AccommodationBookingService::class),
);
$controller = new TestableOfferIndexController($bookingRepository, $linkSigner);
$response = $controller->access('unknown-uuid', Request::create('/groups/booking/offer/unknown-uuid'));
$response = $controller->index('unknown-uuid', Request::create('/groups/booking/offer/unknown-uuid'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/offer/unavailable.html.twig', $controller->renderedView);
}
#[DataProvider('statusesTheCustomerMustNotSee')]
public function testAccessRendersUnavailableForABookingThatIsNotOfferedYet(AccommodationBookingStatus $status): void
public function testRendersUnavailableForABookingThatIsNotOfferedYet(AccommodationBookingStatus $status): void
{
$booking = new AccommodationBooking();
$booking->setStatus($status);
@@ -108,14 +85,9 @@ class IndexControllerTest extends TestCase
$linkSigner->method('isValidLinkRequest')->willReturn(true);
$linkSigner->expects(self::never())->method('authorizeSession');
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$this->createStub(AccommodationBookingService::class),
);
$controller = new TestableOfferIndexController($bookingRepository, $linkSigner);
$response = $controller->access($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid()));
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid()));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/offer/unavailable.html.twig', $controller->renderedView);
@@ -132,353 +104,12 @@ class IndexControllerTest extends TestCase
yield 'requested' => [AccommodationBookingStatus::Requested];
yield 'discarded' => [AccommodationBookingStatus::Discarded];
}
/**
* The status is re-read on every view, so an authorized session is no free pass: a
* booking pushed back into Entwurf stops showing the offer from that moment on.
*/
#[DataProvider('statusesTheCustomerMustNotSee')]
public function testViewRendersUnavailableForABookingTheCustomerMustNotSee(AccommodationBookingStatus $status): void
{
$booking = new AccommodationBooking();
$booking->setStatus($status);
$booking->setAccommodation(new Accommodation());
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator->expects(self::never())->method('compute');
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$breakdownCalculator,
$this->createStub(AccommodationBookingService::class),
);
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/view');
$request->setSession(new Session(new MockArraySessionStorage()));
$response = $controller->view($booking->getUuid(), $request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/offer/unavailable.html.twig', $controller->renderedView);
}
public function testViewRendersOfferWhenSessionAuthorized(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setAccommodation(new Accommodation());
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking);
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator->method('compute')->with($booking)->willReturn(['total' => 1000]);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('acceptBooking');
$bookingService->method('loadHotelCmsData')->willReturn(null);
$controller = new TestableOfferController($bookingRepository, $linkSigner, $breakdownCalculator, $bookingService);
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/view');
$request->setSession(new Session(new MockArraySessionStorage()));
$response = $controller->view($booking->getUuid(), $request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('<html>offer</html>', $response->getContent());
self::assertSame('groups/offer/view.html.twig', $controller->renderedView);
self::assertSame($booking, $controller->renderedParameters['booking']);
self::assertSame(['total' => 1000], $controller->renderedParameters['priceBreakdown']);
self::assertSame($booking->getAccommodation(), $controller->renderedParameters['ctx']->accommodation);
}
public function testViewRendersUnavailableWhenSessionNotAuthorized(): void
{
$booking = new AccommodationBooking();
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(false);
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$this->createStub(AccommodationBookingService::class),
);
$response = $controller->view($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/view'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/offer/unavailable.html.twig', $controller->renderedView);
}
public function testConfirmGetRendersModalWithFreshForm(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setRemarks("Bitte Zimmer im EG\nDanke!");
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking);
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$confirmationForm = $this->createStub(FormInterface::class);
$confirmationForm->method('handleRequest')->willReturnSelf();
$confirmationForm->method('isSubmitted')->willReturn(false);
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$this->createStub(AccommodationBookingService::class),
$confirmationForm,
);
$response = $controller->confirm($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/offer/modal_accept_confirmation.html.twig', $controller->renderedView);
self::assertSame($booking, $controller->renderedParameters['booking']);
self::assertSame($confirmationForm, $controller->renderedParameters['confirmationForm']);
self::assertInstanceOf(OfferAcceptDto::class, $controller->formData);
self::assertSame("Bitte Zimmer im EG\nDanke!", $controller->formData->remarks, 'the stored remark is prefilled into the modal');
self::assertFalse($controller->formData->termsAccepted);
}
public function testConfirmPostValidAcceptsBookingAndRedirects(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setRemarks('Bitte Zimmer im EG');
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::once())->method('acceptBooking')->with($booking, 'Bitte Zimmer im EG');
$confirmationForm = $this->createStub(FormInterface::class);
$confirmationForm->method('handleRequest')->willReturnSelf();
$confirmationForm->method('isSubmitted')->willReturn(true);
$confirmationForm->method('isValid')->willReturn(true);
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$bookingService,
$confirmationForm,
);
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST');
$response = $controller->confirm($booking->getUuid(), $request);
self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/app_groups_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl());
self::assertSame([['type' => 'success', 'message' => 'Deine Buchung ist bei uns eingegangen. Sobald sie geprüft ist, erhältst du eine Bestätigung per E-Mail.']], $controller->flashes);
}
public function testConfirmPostValidViaHtmxReturnsHxRedirect(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$bookingService = $this->createMock(AccommodationBookingService::class);
// No remark on the booking and none submitted: the empty string clears rather than keeps.
$bookingService->expects(self::once())->method('acceptBooking')->with($booking, '');
$confirmationForm = $this->createStub(FormInterface::class);
$confirmationForm->method('handleRequest')->willReturnSelf();
$confirmationForm->method('isSubmitted')->willReturn(true);
$confirmationForm->method('isValid')->willReturn(true);
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$bookingService,
$confirmationForm,
);
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST');
$request->headers->set('HX-Request', 'true');
$response = $controller->confirm($booking->getUuid(), $request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertTrue($response->headers->has('HX-Redirect'));
}
public function testConfirmPostInvalidReRendersModalWithoutAccepting(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('acceptBooking');
$confirmationForm = $this->createStub(FormInterface::class);
$confirmationForm->method('handleRequest')->willReturnSelf();
$confirmationForm->method('isSubmitted')->willReturn(true);
$confirmationForm->method('isValid')->willReturn(false);
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$bookingService,
$confirmationForm,
);
$response = $controller->confirm($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertFalse($response->headers->has('HX-Redirect'));
self::assertSame('groups/offer/modal_accept_confirmation.html.twig', $controller->renderedView);
}
public function testConfirmRedirectsWhenAlreadyAccepted(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Received);
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$this->createStub(AccommodationBookingService::class),
);
$response = $controller->confirm($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm'));
self::assertInstanceOf(RedirectResponse::class, $response);
}
public function testConfirmRedirectsWhenSessionNotAuthorized(): void
{
$booking = new AccommodationBooking();
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(false);
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$this->createStub(AccommodationBookingService::class),
);
$response = $controller->confirm($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm'));
self::assertInstanceOf(RedirectResponse::class, $response);
}
public function testConfirmRedirectsForUnknownUuid(): void
{
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn(null);
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->expects(self::never())->method('isSessionAuthorized');
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$this->createStub(AccommodationBookingService::class),
);
$response = $controller->confirm('unknown-uuid', Request::create('/groups/booking/offer/unknown-uuid/confirm'));
self::assertInstanceOf(RedirectResponse::class, $response);
}
}
final class TestableOfferController extends IndexController
final class TestableOfferIndexController extends IndexController
{
public ?string $renderedView = null;
/** @var array<string, mixed> */
public array $renderedParameters = [];
public mixed $formData = null;
public function __construct(
AccommodationBookingRepository $bookingRepository,
AccommodationBookingLinkSigner $linkSigner,
AccommodationBookingBreakdownCalculator $breakdownCalculator,
AccommodationBookingService $bookingService,
private readonly ?FormInterface $confirmationForm = null,
) {
parent::__construct(
$bookingRepository,
$linkSigner,
$breakdownCalculator,
$bookingService,
new AccommodationTermsUrlProvider(['AT' => '', 'CH' => '', 'IT' => ''], 'https://example.test/agb/'),
);
}
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
{
$this->formData = $data;
return $this->confirmationForm ?? throw new \LogicException('No confirmation form mock configured for this test.');
}
protected function getParameter(string $name): array|bool|string|int|float|\UnitEnum|null
{
return 'https://example.test/agb/';
}
/**
* @var array<int, array{type: string, message: mixed}>
*/
public array $flashes = [];
protected function addFlash(string $type, mixed $message): void
{
$this->flashes[] = ['type' => $type, 'message' => $message];
}
/**
* @param array<string, mixed> $parameters
*/
@@ -490,17 +121,7 @@ final class TestableOfferController extends IndexController
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{
$this->renderedView = $view;
$this->renderedParameters = $parameters;
if ('groups/offer/view.html.twig' === $view) {
$content = null !== $parameters['booking']->getAcceptedAt() ? '<html>confirmed</html>' : '<html>offer</html>';
} else {
$content = '<html>unavailable</html>';
}
$response ??= new Response();
$response->setContent($content);
return $response;
return $response ?? new Response('<html>unavailable</html>');
}
}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Groups\Offer;
use App\Controller\Groups\Offer\ViewController;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Model\AccommodationBookingContext;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
class ViewControllerTest extends TestCase
{
/**
* The status is re-read on every view, so an authorized session is no free pass: a
* booking pushed back into Entwurf stops showing the offer from that moment on.
*/
#[DataProvider('statusesTheCustomerMustNotSee')]
public function testRendersUnavailableForABookingTheCustomerMustNotSee(AccommodationBookingStatus $status): void
{
$booking = new AccommodationBooking();
$booking->setStatus($status);
$booking->setAccommodation(new Accommodation());
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('createOfferContext');
$controller = new TestableOfferViewController($bookingRepository, $linkSigner, $bookingService);
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/view');
$request->setSession(new Session(new MockArraySessionStorage()));
$response = $controller->index($booking->getUuid(), $request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/offer/unavailable.html.twig', $controller->renderedView);
}
/**
* @return iterable<string, array{AccommodationBookingStatus}>
*/
public static function statusesTheCustomerMustNotSee(): iterable
{
yield 'draft' => [AccommodationBookingStatus::Draft];
yield 'requested' => [AccommodationBookingStatus::Requested];
yield 'discarded' => [AccommodationBookingStatus::Discarded];
}
public function testRendersOfferWhenSessionAuthorized(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setAccommodation(new Accommodation());
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking);
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(true);
$ctx = new AccommodationBookingContext(accommodation: $booking->getAccommodation(), priceBreakdown: ['total' => 1000]);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->method('createOfferContext')->with($booking, $booking->getAccommodation())->willReturn($ctx);
$bookingService->expects(self::never())->method('acceptBooking');
$controller = new TestableOfferViewController($bookingRepository, $linkSigner, $bookingService);
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/view');
$request->setSession(new Session(new MockArraySessionStorage()));
$response = $controller->index($booking->getUuid(), $request);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/offer/view.html.twig', $controller->renderedView);
self::assertSame($booking, $controller->renderedParameters['booking']);
self::assertSame(['total' => 1000], $controller->renderedParameters['priceBreakdown']);
self::assertSame($ctx, $controller->renderedParameters['ctx']);
}
public function testRendersUnavailableWhenSessionNotAuthorized(): void
{
$booking = new AccommodationBooking();
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
$linkSigner->method('isSessionAuthorized')->willReturn(false);
$controller = new TestableOfferViewController(
$bookingRepository,
$linkSigner,
$this->createStub(AccommodationBookingService::class),
);
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/view'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('groups/offer/unavailable.html.twig', $controller->renderedView);
}
}
final class TestableOfferViewController extends ViewController
{
public ?string $renderedView = null;
/** @var array<string, mixed> */
public array $renderedParameters = [];
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{
$this->renderedView = $view;
$this->renderedParameters = $parameters;
return $response ?? new Response('<html></html>');
}
}
@@ -28,6 +28,7 @@ use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Validator\Validation;
class AccommodationBookingServiceTest extends TestCase
{
@@ -1010,6 +1011,90 @@ class AccommodationBookingServiceTest extends TestCase
return $booking;
}
public function testHasCompleteContactDataAcceptsAFullyFilledBooking(): void
{
$service = $this->createServiceWithAccommodation();
self::assertTrue($service->hasCompleteContactData($this->createBookingWithContactData()));
}
/**
* An offer the office saved with only the fields its own form requires.
*/
public function testHasCompleteContactDataRejectsWhatTheOfficeMayLeaveOut(): void
{
$service = $this->createServiceWithAccommodation();
$booking = $this->createBookingWithContactData();
$booking->setPhone(null);
self::assertFalse($service->hasCompleteContactData($booking));
}
public function testHasCompleteContactDataAppliesTheStep3Constraints(): void
{
$service = $this->createServiceWithAccommodation();
$booking = $this->createBookingWithContactData();
$booking->setSalutation('Dr.');
self::assertFalse($service->hasCompleteContactData($booking));
}
public function testApplyContactDataRoundTripsAndFlushes(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('flush');
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$dto = $service->contactDataFromBooking($this->createBookingWithContactData());
$dto->phone = '0171 999999';
$booking = new AccommodationBooking();
$booking->setRemarks('bleibt');
$service->applyContactData($booking, $dto);
self::assertSame('Skiclub Nord', $booking->getGroupName());
self::assertSame('Frau', $booking->getSalutation());
self::assertSame('Erika', $booking->getFirstName());
self::assertSame('Mustermann', $booking->getLastName());
self::assertSame('[email protected]', $booking->getEmail());
self::assertSame('0171 999999', $booking->getPhone());
self::assertSame('Hauptstraße 1', $booking->getStreet());
self::assertSame('12345', $booking->getZip());
self::assertSame('Musterstadt', $booking->getCity());
self::assertSame('bleibt', $booking->getRemarks(), 'remarks are not contact data');
}
public function testCreateOfferContextCombinesCmsDataAndStoredBreakdown(): void
{
$booking = new AccommodationBooking();
$accommodation = (new Accommodation())->setCalendarCode('HOTEL');
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator->method('compute')->with($booking)->willReturn(['total' => 1000]);
$service = $this->createServiceWithAccommodation(breakdownCalculator: $breakdownCalculator);
$ctx = $service->createOfferContext($booking, $accommodation);
self::assertSame($accommodation, $ctx->accommodation);
self::assertSame(['total' => 1000], $ctx->priceBreakdown);
}
private function createBookingWithContactData(): AccommodationBooking
{
return (new AccommodationBooking())
->setGroupName('Skiclub Nord')
->setSalutation('Frau')
->setFirstName('Erika')
->setLastName('Mustermann')
->setEmail('[email protected]')
->setPhone('0171 123456')
->setStreet('Hauptstraße 1')
->setZip('12345')
->setCity('Musterstadt');
}
/**
* @param AccommodationPrice[] $prices
*/
@@ -1051,6 +1136,7 @@ class AccommodationBookingServiceTest extends TestCase
$linkSigner ?? $this->createStub(AccommodationBookingLinkSigner::class),
$breakdownCalculator ?? $this->createStub(AccommodationBookingBreakdownCalculator::class),
$pdfGenerator ?? $this->createStub(AccommodationBookingPdfGenerator::class),
Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator(),
'[email protected]',
);
}