diff --git a/src/Controller/Groups/Offer/AbstractController.php b/src/Controller/Groups/Offer/AbstractController.php new file mode 100644 index 0000000..bbcf837 --- /dev/null +++ b/src/Controller/Groups/Offer/AbstractController.php @@ -0,0 +1,38 @@ +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); + } +} diff --git a/src/Controller/Groups/Offer/ConfirmController.php b/src/Controller/Groups/Offer/ConfirmController.php new file mode 100644 index 0000000..7c496ab --- /dev/null +++ b/src/Controller/Groups/Offer/ConfirmController.php @@ -0,0 +1,67 @@ +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, + ]); + } +} diff --git a/src/Controller/Groups/Offer/ContactController.php b/src/Controller/Groups/Offer/ContactController.php new file mode 100644 index 0000000..c1d9555 --- /dev/null +++ b/src/Controller/Groups/Offer/ContactController.php @@ -0,0 +1,65 @@ +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, + ]); + } +} diff --git a/src/Controller/Groups/Offer/IndexController.php b/src/Controller/Groups/Offer/IndexController.php index 7c5ef90..c23b4ef 100644 --- a/src/Controller/Groups/Offer/IndexController.php +++ b/src/Controller/Groups/Offer/IndexController.php @@ -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); - } } diff --git a/src/Controller/Groups/Offer/ViewController.php b/src/Controller/Groups/Offer/ViewController.php new file mode 100644 index 0000000..31edb88 --- /dev/null +++ b/src/Controller/Groups/Offer/ViewController.php @@ -0,0 +1,45 @@ +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, + ]); + } +} diff --git a/src/Form/AccommodationContactType.php b/src/Form/AccommodationContactType.php new file mode 100644 index 0000000..d9d4a0c --- /dev/null +++ b/src/Form/AccommodationContactType.php @@ -0,0 +1,76 @@ + + */ +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'); + } +} diff --git a/src/Form/AccommodationStep3Type.php b/src/Form/AccommodationStep3Type.php index 762b417..bb76100 100644 --- a/src/Form/AccommodationStep3Type.php +++ b/src/Form/AccommodationStep3Type.php @@ -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; + } } diff --git a/src/Form/Model/AccommodationBookingDto.php b/src/Form/Model/AccommodationBookingDto.php index 9738940..6caf404 100644 --- a/src/Form/Model/AccommodationBookingDto.php +++ b/src/Form/Model/AccommodationBookingDto.php @@ -43,33 +43,35 @@ class AccommodationBookingDto /** @var array */ 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; diff --git a/src/Service/AccommodationBookingService.php b/src/Service/AccommodationBookingService.php index 69544e0..6e10a9a 100644 --- a/src/Service/AccommodationBookingService.php +++ b/src/Service/AccommodationBookingService.php @@ -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 diff --git a/templates/groups/offer/contact.html.twig b/templates/groups/offer/contact.html.twig new file mode 100644 index 0000000..0e46049 --- /dev/null +++ b/templates/groups/offer/contact.html.twig @@ -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', + } + }) }} + +
+ + {# Sidebar summary #} +
+ {% include 'groups/offer/_summary.html.twig' with { + booking: booking, + ctx: ctx, + currency: currency, + } %} +
+ + {# Contact data form #} +
+ {% include '_partials/_flashes.html.twig' %} + +
+
+

+ Kontaktdaten vervollständigen +

+

+ Bevor du das Angebot für {{ ctx.accommodation.name }} buchen kannst, benötigen wir + noch deine vollständigen Kontaktdaten. +

+
+ + {% from '_partials/_validation_errors.html.twig' import validation_alert %} + {{ validation_alert(form, 'Bitte fülle alle Pflichtfelder aus.') }} + +
+ {{ form_row(form.groupName) }} + {{ form_row(form.salutation) }} +
+ +
+ {{ form_row(form.firstName) }} + {{ form_row(form.lastName) }} +
+ +
+ {{ form_row(form.email) }} + {{ form_row(form.phone) }} +
+ +

Adresse

+ + {{ form_row(form.street) }} + +
+ {{ form_row(form.zip) }} + {{ form_row(form.city) }} +
+
+
+ +
+ +
+
+ + Zurück zum Angebot + + +
+
+ + {{ form_rest(form) }} + {{ form_end(form) }} +{% endblock %} diff --git a/tests/Controller/Groups/Offer/ConfirmControllerTest.php b/tests/Controller/Groups/Offer/ConfirmControllerTest.php new file mode 100644 index 0000000..809da33 --- /dev/null +++ b/tests/Controller/Groups/Offer/ConfirmControllerTest.php @@ -0,0 +1,303 @@ +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 + */ + 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 */ + public array $renderedParameters = []; + + public mixed $formData = null; + + /** @var array */ + 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 $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(''); + } +} diff --git a/tests/Controller/Groups/Offer/ContactControllerTest.php b/tests/Controller/Groups/Offer/ContactControllerTest.php new file mode 100644 index 0000000..5367e08 --- /dev/null +++ b/tests/Controller/Groups/Offer/ContactControllerTest.php @@ -0,0 +1,223 @@ +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 + */ + 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 */ + public array $renderedParameters = []; + + public mixed $formData = null; + + /** @var array */ + public array $formOptions = []; + + /** @var array */ + 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 $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(''); + } +} diff --git a/tests/Controller/Groups/Offer/IndexControllerTest.php b/tests/Controller/Groups/Offer/IndexControllerTest.php index 9b31e92..12693a6 100644 --- a/tests/Controller/Groups/Offer/IndexControllerTest.php +++ b/tests/Controller/Groups/Offer/IndexControllerTest.php @@ -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('offer', $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 */ - 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 - */ - public array $flashes = []; - - protected function addFlash(string $type, mixed $message): void - { - $this->flashes[] = ['type' => $type, 'message' => $message]; - } - /** * @param array $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() ? 'confirmed' : 'offer'; - } else { - $content = 'unavailable'; - } - - $response ??= new Response(); - $response->setContent($content); - - return $response; + return $response ?? new Response('unavailable'); } } diff --git a/tests/Controller/Groups/Offer/ViewControllerTest.php b/tests/Controller/Groups/Offer/ViewControllerTest.php new file mode 100644 index 0000000..fb07c21 --- /dev/null +++ b/tests/Controller/Groups/Offer/ViewControllerTest.php @@ -0,0 +1,131 @@ +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 + */ + 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 */ + public array $renderedParameters = []; + + protected function render(string $view, array $parameters = [], ?Response $response = null): Response + { + $this->renderedView = $view; + $this->renderedParameters = $parameters; + + return $response ?? new Response(''); + } +} diff --git a/tests/Service/AccommodationBookingServiceTest.php b/tests/Service/AccommodationBookingServiceTest.php index 22fa3c9..1392f44 100644 --- a/tests/Service/AccommodationBookingServiceTest.php +++ b/tests/Service/AccommodationBookingServiceTest.php @@ -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('erika@example.com', $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('erika@example.com') + ->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(), 'office@example.com', ); }