From 2c381ee741113ab9064bdc5d95dd8dc6c92e3aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 4 Aug 2026 18:09:40 +0200 Subject: [PATCH] feat: additional status --- migrations/Version20260804153855.php | 35 +++++ .../AccommodationBooking/EditController.php | 10 +- src/Controller/Groups/OfferController.php | 17 +++ src/Controller/Groups/Step4Controller.php | 2 +- src/Entity/Groups/AccommodationBooking.php | 33 +++- .../Groups/AccommodationBookingStatus.php | 21 +++ .../Groups/AccommodationBookingCreateType.php | 15 +- .../Admin/Groups/AccommodationBookingType.php | 31 +++- src/Model/AccommodationBookingApiResponse.php | 2 +- src/Service/AccommodationBookingService.php | 44 ++++-- .../accommodation_booking/edit.html.twig | 2 +- .../accommodation_booking/index.html.twig | 6 +- .../modal_create.html.twig | 2 +- .../accommodation_booking/show.html.twig | 4 +- .../EditControllerTest.php | 143 ++++++++++++++++++ .../AccommodationBookingControllerTest.php | 13 +- .../Controller/Groups/OfferControllerTest.php | 69 ++++++++- .../Controller/Groups/Step4ControllerTest.php | 5 +- .../Groups/AccommodationBookingTypeTest.php | 114 ++++++++++++++ .../AccommodationBookingServiceTest.php | 84 ++++++++-- 20 files changed, 588 insertions(+), 64 deletions(-) create mode 100644 migrations/Version20260804153855.php create mode 100644 src/Enum/Groups/AccommodationBookingStatus.php create mode 100644 tests/Controller/Admin/AccommodationBooking/EditControllerTest.php create mode 100644 tests/Form/Admin/Groups/AccommodationBookingTypeTest.php diff --git a/migrations/Version20260804153855.php b/migrations/Version20260804153855.php new file mode 100644 index 0000000..fbb57ba --- /dev/null +++ b/migrations/Version20260804153855.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE accommodation_booking ADD status VARCHAR(20) DEFAULT NULL'); + $this->addSql("UPDATE accommodation_booking SET status = IF(is_inquiry = 1, 'inquiry', 'booking')"); + $this->addSql('ALTER TABLE accommodation_booking CHANGE status status VARCHAR(20) NOT NULL'); + $this->addSql('ALTER TABLE accommodation_booking DROP is_inquiry'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE accommodation_booking ADD is_inquiry TINYINT(1) DEFAULT NULL'); + $this->addSql("UPDATE accommodation_booking SET is_inquiry = IF(status = 'inquiry', 1, 0)"); + $this->addSql('ALTER TABLE accommodation_booking CHANGE is_inquiry is_inquiry TINYINT(1) NOT NULL'); + $this->addSql('ALTER TABLE accommodation_booking DROP status'); + } +} diff --git a/src/Controller/Admin/AccommodationBooking/EditController.php b/src/Controller/Admin/AccommodationBooking/EditController.php index 9475d59..cf5d141 100644 --- a/src/Controller/Admin/AccommodationBooking/EditController.php +++ b/src/Controller/Admin/AccommodationBooking/EditController.php @@ -7,6 +7,7 @@ namespace App\Controller\Admin\AccommodationBooking; use App\Entity\Groups\AccommodationBooking; use App\Entity\Groups\AdditionalService; use App\Entity\Groups\BoardService; +use App\Enum\Groups\AccommodationBookingStatus; use App\Form\Admin\Groups\AccommodationBookingType; use App\Repository\Groups\AdditionalServiceRepository; use App\Repository\Groups\BoardServiceRepository; @@ -75,7 +76,7 @@ class EditController extends AbstractController 'current_board_service' => $currentBoardService, 'current_additional_services' => $currentAdditionalServices, ]); - $wasInquiry = $booking->isInquiry(); + $previousStatus = $booking->getStatus(); $form->handleRequest($request); @@ -110,8 +111,11 @@ class EditController extends AbstractController $this->entityManager->persist($booking); $this->entityManager->flush(); - if ($wasInquiry && !$booking->isInquiry()) { - $this->bookingService->issueAccessLinkForDirectBooking($booking); + // Entering Anfrage or Buchung is what the customer needs to hear about: the offer + // becomes viewable, or the booking is confirmed. Entwurf and Absage stay silent. + $notifiableStatuses = [AccommodationBookingStatus::Inquiry, AccommodationBookingStatus::Booking]; + if ($previousStatus !== $booking->getStatus() && in_array($booking->getStatus(), $notifiableStatuses, true)) { + $this->bookingService->issueAccessLink($booking); $this->bookingService->sendCustomerConfirmationEmail($booking); } diff --git a/src/Controller/Groups/OfferController.php b/src/Controller/Groups/OfferController.php index 9442a4e..0704f37 100644 --- a/src/Controller/Groups/OfferController.php +++ b/src/Controller/Groups/OfferController.php @@ -44,6 +44,10 @@ class OfferController extends AbstractController return $this->render('groups/booking/offer_unavailable.html.twig'); } + if (!$this->isCustomerVisible($booking)) { + return $this->render('groups/booking/offer_unavailable.html.twig'); + } + $this->linkSigner->authorizeSession($request, $booking); return new RedirectResponse($this->generateUrl('app_groups_booking_offer_view', ['uuid' => $uuid])); @@ -58,6 +62,10 @@ class OfferController extends AbstractController return $this->render('groups/booking/offer_unavailable.html.twig'); } + if (!$this->isCustomerVisible($booking)) { + return $this->render('groups/booking/offer_unavailable.html.twig'); + } + $accommodation = $booking->getAccommodation() ?? throw $this->createNotFoundException('Booking has no accommodation.'); $priceBreakdown = $this->breakdownCalculator->compute($booking); @@ -105,6 +113,15 @@ class OfferController extends AbstractController ]); } + /** + * A draft is not ready to be shown, and a discarded booking must not be viewable + * any more — in both cases the link behaves as if it had expired. + */ + private function isCustomerVisible(AccommodationBooking $booking): bool + { + return !$booking->isDraft() && !$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 diff --git a/src/Controller/Groups/Step4Controller.php b/src/Controller/Groups/Step4Controller.php index 2c079d2..258c9d4 100644 --- a/src/Controller/Groups/Step4Controller.php +++ b/src/Controller/Groups/Step4Controller.php @@ -151,7 +151,7 @@ class Step4Controller extends AbstractAccommodationController { $prices = $this->bookingService->loadPrices($dto, $accommodation); $booking = $this->bookingService->finalizeBooking($dto, $accommodation, $prices, $services); - $this->addFlash('groups_booking_result', $booking->isInquiry() ? 'inquiry' : 'booking'); + $this->addFlash('groups_booking_result', $booking->getStatus()->value); $this->sessionManager->clear($request); } } diff --git a/src/Entity/Groups/AccommodationBooking.php b/src/Entity/Groups/AccommodationBooking.php index 47e513b..39a4b2b 100644 --- a/src/Entity/Groups/AccommodationBooking.php +++ b/src/Entity/Groups/AccommodationBooking.php @@ -9,6 +9,7 @@ use App\Entity\BlameableEntityInterface; use App\Entity\TimestampableEntity; use App\Entity\TimestampableEntityInterface; use App\Entity\User; +use App\Enum\Groups\AccommodationBookingStatus; use App\Enum\Groups\AdditionalServiceType; use App\Repository\Groups\AccommodationBookingRepository; use Doctrine\DBAL\Types\Types; @@ -75,8 +76,8 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt #[ORM\Column(type: Types::SMALLINT, nullable: true)] private ?int $pricingVersion = null; - #[ORM\Column] - private bool $isInquiry = false; + #[ORM\Column(length: 20, enumType: AccommodationBookingStatus::class)] + private AccommodationBookingStatus $status = AccommodationBookingStatus::Draft; #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] private ?\DateTimeImmutable $accessLinkIssuedAt = null; @@ -335,18 +336,38 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt return $this->pricingVersion; } - public function isInquiry(): bool + public function getStatus(): AccommodationBookingStatus { - return $this->isInquiry; + return $this->status; } - public function setIsInquiry(bool $isInquiry): self + public function setStatus(AccommodationBookingStatus $status): self { - $this->isInquiry = $isInquiry; + $this->status = $status; return $this; } + public function isDraft(): bool + { + return AccommodationBookingStatus::Draft === $this->status; + } + + public function isInquiry(): bool + { + return AccommodationBookingStatus::Inquiry === $this->status; + } + + public function isBooking(): bool + { + return AccommodationBookingStatus::Booking === $this->status; + } + + public function isDiscarded(): bool + { + return AccommodationBookingStatus::Discarded === $this->status; + } + public function getAccessLinkIssuedAt(): ?\DateTimeImmutable { return $this->accessLinkIssuedAt; diff --git a/src/Enum/Groups/AccommodationBookingStatus.php b/src/Enum/Groups/AccommodationBookingStatus.php new file mode 100644 index 0000000..a54032c --- /dev/null +++ b/src/Enum/Groups/AccommodationBookingStatus.php @@ -0,0 +1,21 @@ + 'Entwurf', + self::Inquiry => 'Anfrage', + self::Booking => 'Buchung', + self::Discarded => 'Absage', + }; + } +} diff --git a/src/Form/Admin/Groups/AccommodationBookingCreateType.php b/src/Form/Admin/Groups/AccommodationBookingCreateType.php index 8192faa..bcf7199 100644 --- a/src/Form/Admin/Groups/AccommodationBookingCreateType.php +++ b/src/Form/Admin/Groups/AccommodationBookingCreateType.php @@ -6,10 +6,11 @@ namespace App\Form\Admin\Groups; use App\Entity\Groups\Accommodation; use App\Entity\Groups\AccommodationBooking; +use App\Enum\Groups\AccommodationBookingStatus; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\DateType; +use Symfony\Component\Form\Extension\Core\Type\EnumType; use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -47,9 +48,15 @@ class AccommodationBookingCreateType extends AbstractType ->add('childrenCount', IntegerType::class, [ 'label' => 'davon Kinder', ]) - ->add('isInquiry', CheckboxType::class, [ - 'label' => 'Anfrage (nicht bindend)', - 'required' => false, + ->add('status', EnumType::class, [ + 'label' => 'Status', + 'class' => AccommodationBookingStatus::class, + 'choices' => [ + AccommodationBookingStatus::Draft, + AccommodationBookingStatus::Inquiry, + AccommodationBookingStatus::Booking, + ], + 'choice_label' => fn (AccommodationBookingStatus $status) => $status->label(), ]) ; } diff --git a/src/Form/Admin/Groups/AccommodationBookingType.php b/src/Form/Admin/Groups/AccommodationBookingType.php index 6fe984e..0d7c36e 100644 --- a/src/Form/Admin/Groups/AccommodationBookingType.php +++ b/src/Form/Admin/Groups/AccommodationBookingType.php @@ -7,16 +7,18 @@ namespace App\Form\Admin\Groups; use App\Entity\Groups\AccommodationBooking; use App\Entity\Groups\AdditionalService; use App\Entity\Groups\BoardService; +use App\Enum\Groups\AccommodationBookingStatus; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\Form\Extension\Core\Type\EmailType; +use Symfony\Component\Form\Extension\Core\Type\EnumType; use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints\Range; @@ -25,9 +27,13 @@ class AccommodationBookingType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { + // The contact fields carry no HTML5 `required` attribute: a draft must be saveable + // while still empty. They are enforced server-side by the `edit` validation group + // as soon as the booking leaves Entwurf — see configureOptions(). $builder ->add('groupName', TextType::class, [ 'label' => 'Gruppenname', + 'required' => false, ]) ->add('salutation', ChoiceType::class, [ 'label' => 'Anrede', @@ -41,12 +47,15 @@ class AccommodationBookingType extends AbstractType ]) ->add('firstName', TextType::class, [ 'label' => 'Vorname', + 'required' => false, ]) ->add('lastName', TextType::class, [ 'label' => 'Nachname', + 'required' => false, ]) ->add('email', EmailType::class, [ 'label' => 'E-Mail', + 'required' => false, ]) ->add('phone', TextType::class, [ 'label' => 'Telefon', @@ -113,9 +122,10 @@ class AccommodationBookingType extends AbstractType } $builder - ->add('isInquiry', CheckboxType::class, [ - 'label' => 'Anfrage (nicht bindend)', - 'required' => false, + ->add('status', EnumType::class, [ + 'label' => 'Status', + 'class' => AccommodationBookingStatus::class, + 'choice_label' => fn (AccommodationBookingStatus $status) => $status->label(), ]) ->add('accommodationDiscount', IntegerType::class, [ 'label' => 'Rabatt Unterkunft (%)', @@ -149,7 +159,18 @@ class AccommodationBookingType extends AbstractType { $resolver->setDefaults([ 'data_class' => AccommodationBooking::class, - 'validation_groups' => ['Default', 'edit'], + // Entwurf is a scratch record the office fills in over time, and an Absage is + // never going anywhere — neither needs complete contact data. The groups are + // resolved after binding, so promoting a draft to Anfrage or Buchung enforces + // the contact data in the very same save. + 'validation_groups' => static function (FormInterface $form): array { + /** @var AccommodationBooking $booking */ + $booking = $form->getData(); + + return $booking->isDraft() || $booking->isDiscarded() + ? ['Default'] + : ['Default', 'edit']; + }, 'max_adolescent_age' => 0, 'board_services' => [], 'additional_services' => [], diff --git a/src/Model/AccommodationBookingApiResponse.php b/src/Model/AccommodationBookingApiResponse.php index 10bee73..765ce0b 100644 --- a/src/Model/AccommodationBookingApiResponse.php +++ b/src/Model/AccommodationBookingApiResponse.php @@ -88,7 +88,7 @@ final readonly class AccommodationBookingApiResponse public function __construct(AccommodationBooking $booking, ?array $priceBreakdown) { $this->uuid = $booking->getUuid(); - $this->status = $booking->isInquiry() ? 'inquiry' : 'booking'; + $this->status = $booking->getStatus()->value; $this->dateFrom = $booking->getDateFrom(); $this->dateTo = $booking->getDateTo(); $this->nights = $booking->getNights(); diff --git a/src/Service/AccommodationBookingService.php b/src/Service/AccommodationBookingService.php index b8eac2b..65d0aa4 100644 --- a/src/Service/AccommodationBookingService.php +++ b/src/Service/AccommodationBookingService.php @@ -10,6 +10,7 @@ use App\Entity\Groups\AccommodationBooking; use App\Entity\Groups\AccommodationPrice; use App\Entity\Groups\AdditionalService; use App\Entity\Groups\BoardService; +use App\Enum\Groups\AccommodationBookingStatus; use App\Form\Model\AccommodationBookingDto; use App\Model\AccommodationBookingQueryParams; use App\Model\CmsHotelData; @@ -264,7 +265,11 @@ class AccommodationBookingService $booking->setPaxCount($dto->paxCount); $booking->setMinorsCount($dto->minorsCount); $booking->setChildrenCount($dto->childrenCount); - $booking->setIsInquiry($dto->forceInquiry || $this->computeInquiryStatus($dto, $prices)->isInquiry); + $booking->setStatus( + $dto->forceInquiry || $this->computeInquiryStatus($dto, $prices)->isInquiry + ? AccommodationBookingStatus::Inquiry + : AccommodationBookingStatus::Booking + ); // Freeze board service as scalar fields (no FK) if (null !== $dto->selectedBoardServiceId) { @@ -412,13 +417,13 @@ class AccommodationBookingService } /** - * Sets accessLinkIssuedAt for a direct (non-inquiry) booking if it doesn't have one yet. + * Sets accessLinkIssuedAt if the booking doesn't have one yet, whatever its status. * No email side effect — the confirmation email is always sent separately via * sendCustomerConfirmationEmail(), regardless of whether a link exists. */ - public function issueAccessLinkForDirectBooking(AccommodationBooking $booking): void + public function issueAccessLink(AccommodationBooking $booking): void { - if ($booking->isInquiry() || null !== $booking->getAccessLinkIssuedAt()) { + if (null !== $booking->getAccessLinkIssuedAt()) { return; } @@ -426,18 +431,39 @@ class AccommodationBookingService $this->entityManager->flush(); } + /** + * Issues the access link only for a confirmed booking. Inquiries from the public flow + * deliberately get no link — the office prepares the offer first and issues the link + * when it switches the booking to Anfrage. + */ + public function issueAccessLinkForDirectBooking(AccommodationBooking $booking): void + { + if (!$booking->isBooking()) { + return; + } + + $this->issueAccessLink($booking); + } + /** * Always sends a confirmation email to the customer, whether or not an access link - * exists yet — the template renders differently depending on accessLink being present. + * exists yet — the template renders differently depending on accessLink being present, + * so the subject has to follow the same distinction. */ public function sendCustomerConfirmationEmail(AccommodationBooking $booking): void { + if (!$booking->isInquiry()) { + $subject = 'Deine Buchung ist bestätigt'; + } elseif (null !== $booking->getAccessLinkIssuedAt()) { + $subject = 'Dein Angebot ist bereit'; + } else { + $subject = 'Deine Anfrage ist bei uns eingegangen'; + } + $this->sendBookingEmail( $booking, $booking->getEmail(), - $booking->isInquiry() - ? 'Deine Anfrage ist bei uns eingegangen' - : 'Deine Buchung ist bestätigt', + $subject, 'email/accommodation_booking_customer.html.twig', 'Failed to send accommodation booking customer confirmation email', ); @@ -464,7 +490,7 @@ class AccommodationBookingService return; } - $booking->setIsInquiry(false); + $booking->setStatus(AccommodationBookingStatus::Booking); $booking->setAcceptedAt(new \DateTimeImmutable()); $this->entityManager->flush(); diff --git a/templates/admin/accommodation_booking/edit.html.twig b/templates/admin/accommodation_booking/edit.html.twig index 172f15f..d1a1fc5 100644 --- a/templates/admin/accommodation_booking/edit.html.twig +++ b/templates/admin/accommodation_booking/edit.html.twig @@ -40,7 +40,7 @@

Status & Rabatt

- {{ form_row(form.isInquiry) }} + {{ form_row(form.status) }}
{% if booking.acceptedAt is not null %}

diff --git a/templates/admin/accommodation_booking/index.html.twig b/templates/admin/accommodation_booking/index.html.twig index b762780..f09f5c5 100644 --- a/templates/admin/accommodation_booking/index.html.twig +++ b/templates/admin/accommodation_booking/index.html.twig @@ -50,11 +50,7 @@ {{ booking.paxCount }} - {% if booking.isInquiry %} - Anfrage - {% else %} - Buchung - {% endif %} + {{ booking.status.label }} {% set discounts = [] %} diff --git a/templates/admin/accommodation_booking/modal_create.html.twig b/templates/admin/accommodation_booking/modal_create.html.twig index 7c79eab..1db37b4 100644 --- a/templates/admin/accommodation_booking/modal_create.html.twig +++ b/templates/admin/accommodation_booking/modal_create.html.twig @@ -16,7 +16,7 @@ {{ form_row(form.minorsCount) }} {{ form_row(form.childrenCount) }}

- {{ form_row(form.isInquiry) }} + {{ form_row(form.status) }}
diff --git a/templates/admin/accommodation_booking/show.html.twig b/templates/admin/accommodation_booking/show.html.twig index 87f3913..ff1d265 100644 --- a/templates/admin/accommodation_booking/show.html.twig +++ b/templates/admin/accommodation_booking/show.html.twig @@ -2,7 +2,7 @@ {% block content %} - {% if booking.isInquiry %}Anfrage{% else %}Buchung{% endif %} + {{ booking.status.label }} {{ booking.groupName }} @@ -54,7 +54,7 @@
{{ booking.childrenCount }}
{% endif %}
Status
-
{% if booking.isInquiry %}Anfrage{% else %}Buchung{% endif %}
+
{{ booking.status.label }}
{% if booking.acceptedAt is not null %}
Angenommen am
{{ booking.acceptedAt | date('d.m.Y, H:i') }}
diff --git a/tests/Controller/Admin/AccommodationBooking/EditControllerTest.php b/tests/Controller/Admin/AccommodationBooking/EditControllerTest.php new file mode 100644 index 0000000..0969fdb --- /dev/null +++ b/tests/Controller/Admin/AccommodationBooking/EditControllerTest.php @@ -0,0 +1,143 @@ +assertNotified(AccommodationBookingStatus::Inquiry); + + $this->submitStatusChange(AccommodationBookingStatus::Draft, AccommodationBookingStatus::Inquiry, $bookingService); + } + + public function testTransitionToBookingIssuesAccessLinkAndSendsCustomerEmail(): void + { + $bookingService = $this->assertNotified(AccommodationBookingStatus::Booking); + + $this->submitStatusChange(AccommodationBookingStatus::Draft, AccommodationBookingStatus::Booking, $bookingService); + } + + public function testTransitionToDiscardedSendsNothing(): void + { + $bookingService = $this->assertNotNotified(); + + $this->submitStatusChange(AccommodationBookingStatus::Inquiry, AccommodationBookingStatus::Discarded, $bookingService); + } + + public function testSavingWithoutStatusChangeSendsNothing(): void + { + $bookingService = $this->assertNotNotified(); + + $this->submitStatusChange(AccommodationBookingStatus::Booking, AccommodationBookingStatus::Booking, $bookingService); + } + + private function assertNotified(AccommodationBookingStatus $expected): AccommodationBookingService + { + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->expects(self::once())->method('refreshPriceSnapshot'); + $bookingService + ->expects(self::once()) + ->method('issueAccessLink') + ->with(self::callback(static fn (AccommodationBooking $booking) => $expected === $booking->getStatus())); + $bookingService->expects(self::once())->method('sendCustomerConfirmationEmail'); + + return $bookingService; + } + + private function assertNotNotified(): AccommodationBookingService + { + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->expects(self::once())->method('refreshPriceSnapshot'); + $bookingService->expects(self::never())->method('issueAccessLink'); + $bookingService->expects(self::never())->method('sendCustomerConfirmationEmail'); + + return $bookingService; + } + + private function submitStatusChange( + AccommodationBookingStatus $from, + AccommodationBookingStatus $to, + AccommodationBookingService $bookingService, + ): void { + $booking = new AccommodationBooking(); + $booking->setStatus($from); + + // The form is what moves the entity to its new status during handleRequest(). + $form = $this->createMock(FormInterface::class); + $form->method('handleRequest')->willReturnCallback(static function () use ($booking, $to, $form) { + $booking->setStatus($to); + + return $form; + }); + $form->method('isSubmitted')->willReturn(true); + $form->method('isValid')->willReturn(true); + $form->method('has')->willReturn(false); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once())->method('flush'); + + $controller = new TestableEditController( + $entityManager, + $this->createMock(LoggerInterface::class), + $this->createMock(BoardServiceRepository::class), + $this->createMock(AdditionalServiceRepository::class), + $bookingService, + $form, + ); + + $response = $controller->index($booking, Request::create('/admin/accommodation-booking/1/edit', 'POST')); + + self::assertSame(Response::HTTP_FOUND, $response->getStatusCode()); + } +} + +final class TestableEditController extends EditController +{ + public function __construct( + EntityManagerInterface $entityManager, + LoggerInterface $logger, + BoardServiceRepository $boardServiceRepo, + AdditionalServiceRepository $additionalServiceRepo, + AccommodationBookingService $bookingService, + private readonly FormInterface $form, + ) { + parent::__construct($entityManager, $logger, $boardServiceRepo, $additionalServiceRepo, $bookingService); + } + + protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface + { + return $this->form; + } + + protected function addFlash(string $type, mixed $message): void + { + } + + /** + * @param array $parameters + */ + protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string + { + return '/'.$route.'?'.http_build_query($parameters); + } +} diff --git a/tests/Controller/Api/AccommodationBookingControllerTest.php b/tests/Controller/Api/AccommodationBookingControllerTest.php index a1eb6c2..6222bf3 100644 --- a/tests/Controller/Api/AccommodationBookingControllerTest.php +++ b/tests/Controller/Api/AccommodationBookingControllerTest.php @@ -7,6 +7,7 @@ namespace App\Tests\Controller\Api; use App\Controller\Api\AccommodationBookingController; use App\Entity\Groups\Accommodation; use App\Entity\Groups\AccommodationBooking; +use App\Enum\Groups\AccommodationBookingStatus; use App\Repository\Groups\AccommodationBookingRepository; use App\Service\AccommodationBookingBreakdownCalculator; use App\Service\AccommodationBookingService; @@ -54,7 +55,7 @@ class AccommodationBookingControllerTest extends TestCase $booking->setAccommodationDiscount(10); $booking->setBoardServiceDiscount(20); $booking->setAdditionalServicesDiscount(30); - $booking->setIsInquiry(false); + $booking->setStatus(AccommodationBookingStatus::Booking); $booking->setAcceptedAt(new \DateTimeImmutable('2026-07-15T10:00:00+00:00')); $bookingRepository = $this->createMock(AccommodationBookingRepository::class); @@ -143,7 +144,7 @@ class AccommodationBookingControllerTest extends TestCase $booking->setFirstName('Tom'); $booking->setLastName('Beispiel'); $booking->setEmail('tom@example.com'); - $booking->setIsInquiry(true); + $booking->setStatus(AccommodationBookingStatus::Inquiry); $bookingRepository = $this->createMock(AccommodationBookingRepository::class); $bookingRepository @@ -194,7 +195,7 @@ class AccommodationBookingControllerTest extends TestCase $booking->setFirstName('Tom'); $booking->setLastName('Beispiel'); $booking->setEmail('tom@example.com'); - $booking->setIsInquiry(true); + $booking->setStatus(AccommodationBookingStatus::Inquiry); $bookingRepository = $this->createMock(AccommodationBookingRepository::class); $bookingRepository @@ -212,7 +213,7 @@ class AccommodationBookingControllerTest extends TestCase ->method('acceptBooking') ->with($booking) ->willReturnCallback(static function (AccommodationBooking $b): void { - $b->setIsInquiry(false); + $b->setStatus(AccommodationBookingStatus::Booking); $b->setAcceptedAt(new \DateTimeImmutable()); }); @@ -223,7 +224,7 @@ class AccommodationBookingControllerTest extends TestCase self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertSame('booking', $payload['status']); - self::assertFalse($booking->isInquiry()); + self::assertSame(AccommodationBookingStatus::Booking, $booking->getStatus()); self::assertNotNull($booking->getAcceptedAt()); self::assertSame($booking->getAcceptedAt()->format(\DATE_ATOM), $payload['acceptedAt']); } @@ -239,7 +240,7 @@ class AccommodationBookingControllerTest extends TestCase $booking->setFirstName('Tom'); $booking->setLastName('Beispiel'); $booking->setEmail('tom@example.com'); - $booking->setIsInquiry(false); + $booking->setStatus(AccommodationBookingStatus::Booking); $booking->setAcceptedAt($acceptedAt); $bookingRepository = $this->createMock(AccommodationBookingRepository::class); diff --git a/tests/Controller/Groups/OfferControllerTest.php b/tests/Controller/Groups/OfferControllerTest.php index 4d7ad0d..7e2a67b 100644 --- a/tests/Controller/Groups/OfferControllerTest.php +++ b/tests/Controller/Groups/OfferControllerTest.php @@ -7,6 +7,7 @@ namespace App\Tests\Controller\Groups; use App\Controller\Groups\OfferController; use App\Entity\Groups\Accommodation; use App\Entity\Groups\AccommodationBooking; +use App\Enum\Groups\AccommodationBookingStatus; use App\Repository\Groups\AccommodationBookingRepository; use App\Service\AccommodationBookingBreakdownCalculator; use App\Service\AccommodationBookingLinkSigner; @@ -24,6 +25,7 @@ class OfferControllerTest extends TestCase public function testAccessAuthorizesSessionAndRedirectsToView(): void { $booking = new AccommodationBooking(); + $booking->setStatus(AccommodationBookingStatus::Inquiry); $bookingRepository = $this->createMock(AccommodationBookingRepository::class); $bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking); @@ -90,10 +92,65 @@ class OfferControllerTest extends TestCase self::assertSame('groups/booking/offer_unavailable.html.twig', $controller->renderedView); } + public function testAccessRendersUnavailableForDraft(): void + { + $booking = new AccommodationBooking(); + $booking->setStatus(AccommodationBookingStatus::Draft); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isValidLinkRequest')->willReturn(true); + $linkSigner->expects(self::never())->method('authorizeSession'); + + $controller = new TestableOfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $this->createMock(AccommodationBookingService::class), + ); + + $response = $controller->access($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid())); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame('groups/booking/offer_unavailable.html.twig', $controller->renderedView); + } + + public function testViewRendersUnavailableForDiscardedBooking(): void + { + $booking = new AccommodationBooking(); + $booking->setStatus(AccommodationBookingStatus::Discarded); + $booking->setAccommodation(new Accommodation()); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->willReturn($booking); + + $linkSigner = $this->createMock(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->createMock(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/booking/offer_unavailable.html.twig', $controller->renderedView); + } + public function testViewRendersOfferWhenSessionAuthorized(): void { $booking = new AccommodationBooking(); - $booking->setIsInquiry(true); + $booking->setStatus(AccommodationBookingStatus::Inquiry); $booking->setAccommodation(new Accommodation()); $bookingRepository = $this->createMock(AccommodationBookingRepository::class); @@ -149,7 +206,7 @@ class OfferControllerTest extends TestCase public function testConfirmGetRendersModalWithFreshForm(): void { $booking = new AccommodationBooking(); - $booking->setIsInquiry(true); + $booking->setStatus(AccommodationBookingStatus::Inquiry); $bookingRepository = $this->createMock(AccommodationBookingRepository::class); $bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking); @@ -180,7 +237,7 @@ class OfferControllerTest extends TestCase public function testConfirmPostValidAcceptsBookingAndRedirects(): void { $booking = new AccommodationBooking(); - $booking->setIsInquiry(true); + $booking->setStatus(AccommodationBookingStatus::Inquiry); $bookingRepository = $this->createMock(AccommodationBookingRepository::class); $bookingRepository->method('findOneBy')->willReturn($booking); @@ -215,7 +272,7 @@ class OfferControllerTest extends TestCase public function testConfirmPostValidViaHtmxReturnsHxRedirect(): void { $booking = new AccommodationBooking(); - $booking->setIsInquiry(true); + $booking->setStatus(AccommodationBookingStatus::Inquiry); $bookingRepository = $this->createMock(AccommodationBookingRepository::class); $bookingRepository->method('findOneBy')->willReturn($booking); @@ -250,7 +307,7 @@ class OfferControllerTest extends TestCase public function testConfirmPostInvalidReRendersModalWithoutAccepting(): void { $booking = new AccommodationBooking(); - $booking->setIsInquiry(true); + $booking->setStatus(AccommodationBookingStatus::Inquiry); $bookingRepository = $this->createMock(AccommodationBookingRepository::class); $bookingRepository->method('findOneBy')->willReturn($booking); @@ -284,7 +341,7 @@ class OfferControllerTest extends TestCase public function testConfirmRedirectsWhenAlreadyAccepted(): void { $booking = new AccommodationBooking(); - $booking->setIsInquiry(false); + $booking->setStatus(AccommodationBookingStatus::Booking); $bookingRepository = $this->createMock(AccommodationBookingRepository::class); $bookingRepository->method('findOneBy')->willReturn($booking); diff --git a/tests/Controller/Groups/Step4ControllerTest.php b/tests/Controller/Groups/Step4ControllerTest.php index a4a89bc..45ad226 100644 --- a/tests/Controller/Groups/Step4ControllerTest.php +++ b/tests/Controller/Groups/Step4ControllerTest.php @@ -7,6 +7,7 @@ namespace App\Tests\Controller\Groups; use App\Controller\Groups\Step4Controller; use App\Entity\Groups\Accommodation; use App\Entity\Groups\AccommodationBooking; +use App\Enum\Groups\AccommodationBookingStatus; use App\Form\Model\AccommodationBookingDto; use App\Service\AccommodationBookingService; use App\Service\AccommodationSessionManager; @@ -100,7 +101,7 @@ class Step4ControllerTest extends TestCase ->setCalendarCode('HOTEL') ->setMaxAdolescentAge(17); - $booking = (new AccommodationBooking())->setIsInquiry(true); + $booking = (new AccommodationBooking())->setStatus(AccommodationBookingStatus::Inquiry); $bookingService = $this->createMock(AccommodationBookingService::class); $bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation); @@ -273,7 +274,7 @@ class Step4ControllerTest extends TestCase ->setCalendarCode('HOTEL') ->setMaxAdolescentAge(17); - $booking = (new AccommodationBooking())->setIsInquiry(false); + $booking = (new AccommodationBooking())->setStatus(AccommodationBookingStatus::Booking); $bookingService = $this->createMock(AccommodationBookingService::class); $bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation); diff --git a/tests/Form/Admin/Groups/AccommodationBookingTypeTest.php b/tests/Form/Admin/Groups/AccommodationBookingTypeTest.php new file mode 100644 index 0000000..6bc1b3b --- /dev/null +++ b/tests/Form/Admin/Groups/AccommodationBookingTypeTest.php @@ -0,0 +1,114 @@ +resolveValidationGroups($status)); + } + + /** + * @dataProvider customerFacingStatuses + */ + public function testCustomerFacingStatusesAddTheEditGroup(AccommodationBookingStatus $status): void + { + self::assertSame(['Default', 'edit'], $this->resolveValidationGroups($status)); + } + + /** + * @return iterable + */ + public static function statusesWithoutMandatoryFields(): iterable + { + yield 'draft' => [AccommodationBookingStatus::Draft]; + yield 'discarded' => [AccommodationBookingStatus::Discarded]; + } + + /** + * @return iterable + */ + public static function customerFacingStatuses(): iterable + { + yield 'inquiry' => [AccommodationBookingStatus::Inquiry]; + yield 'booking' => [AccommodationBookingStatus::Booking]; + } + + /** + * @dataProvider statusesWithoutMandatoryFields + */ + public function testEmptyContactDataPassesValidation(AccommodationBookingStatus $status): void + { + $booking = new AccommodationBooking(); + $booking->setStatus($status); + + $violations = $this->validate($booking, $this->resolveValidationGroups($status)); + + self::assertCount(0, $violations); + } + + public function testPromotedBookingRequiresContactData(): void + { + $booking = new AccommodationBooking(); + $booking->setStatus(AccommodationBookingStatus::Inquiry); + + $violations = $this->validate($booking, ['Default', 'edit']); + + $properties = array_map(static fn ($violation) => $violation->getPropertyPath(), iterator_to_array($violations)); + sort($properties); + + self::assertSame(['email', 'firstName', 'groupName', 'lastName'], $properties); + } + + /** + * @return string[] + */ + private function resolveValidationGroups(AccommodationBookingStatus $status): array + { + $resolver = new OptionsResolver(); + (new AccommodationBookingType())->configureOptions($resolver); + + $booking = new AccommodationBooking(); + $booking->setStatus($status); + + $form = $this->createMock(FormInterface::class); + $form->method('getData')->willReturn($booking); + + return ($resolver->resolve()['validation_groups'])($form); + } + + /** + * @param string[] $groups + */ + private function validate(AccommodationBooking $booking, array $groups): \Symfony\Component\Validator\ConstraintViolationListInterface + { + // The app configures email_validation_mode: html5 globally (config/packages/validator.yaml); + // the standalone builder would otherwise fall back to the deprecated "loose" mode. + $validatorFactory = new ConstraintValidatorFactory([ + EmailValidator::class => new EmailValidator(Email::VALIDATION_MODE_HTML5), + ]); + + return Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->setConstraintValidatorFactory($validatorFactory) + ->getValidator() + ->validate($booking, null, $groups); + } +} diff --git a/tests/Service/AccommodationBookingServiceTest.php b/tests/Service/AccommodationBookingServiceTest.php index 019cf81..6678bf3 100644 --- a/tests/Service/AccommodationBookingServiceTest.php +++ b/tests/Service/AccommodationBookingServiceTest.php @@ -8,6 +8,7 @@ use App\Email\Mailer; use App\Entity\Groups\Accommodation; use App\Entity\Groups\AccommodationBooking; use App\Entity\Groups\AccommodationPrice; +use App\Enum\Groups\AccommodationBookingStatus; use App\Model\AccommodationBookingQueryParams; use App\Repository\Groups\AccommodationPriceRepository; use App\Repository\Groups\AccommodationRepository; @@ -65,7 +66,7 @@ class AccommodationBookingServiceTest extends TestCase $service = $this->createServiceWithAccommodation(entityManager: $entityManager); $booking = new AccommodationBooking(); - $booking->setIsInquiry(false); + $booking->setStatus(AccommodationBookingStatus::Booking); $service->issueAccessLinkForDirectBooking($booking); @@ -80,7 +81,7 @@ class AccommodationBookingServiceTest extends TestCase $service = $this->createServiceWithAccommodation(entityManager: $entityManager); $booking = new AccommodationBooking(); - $booking->setIsInquiry(true); + $booking->setStatus(AccommodationBookingStatus::Inquiry); $service->issueAccessLinkForDirectBooking($booking); @@ -95,7 +96,7 @@ class AccommodationBookingServiceTest extends TestCase $service = $this->createServiceWithAccommodation(entityManager: $entityManager); $booking = new AccommodationBooking(); - $booking->setIsInquiry(false); + $booking->setStatus(AccommodationBookingStatus::Booking); $issuedAt = new \DateTimeImmutable('2026-01-01'); $booking->setAccessLinkIssuedAt($issuedAt); @@ -104,11 +105,70 @@ class AccommodationBookingServiceTest extends TestCase self::assertSame($issuedAt, $booking->getAccessLinkIssuedAt()); } + public function testIssueAccessLinkSetsTimestampRegardlessOfStatus(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once())->method('flush'); + + $service = $this->createServiceWithAccommodation(entityManager: $entityManager); + + $booking = new AccommodationBooking(); + $booking->setStatus(AccommodationBookingStatus::Inquiry); + + $service->issueAccessLink($booking); + + self::assertNotNull($booking->getAccessLinkIssuedAt()); + } + + public function testIssueAccessLinkNoOpsWhenAlreadySet(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::never())->method('flush'); + + $service = $this->createServiceWithAccommodation(entityManager: $entityManager); + + $booking = new AccommodationBooking(); + $booking->setStatus(AccommodationBookingStatus::Inquiry); + $issuedAt = new \DateTimeImmutable('2026-01-01'); + $booking->setAccessLinkIssuedAt($issuedAt); + + $service->issueAccessLink($booking); + + self::assertSame($issuedAt, $booking->getAccessLinkIssuedAt()); + } + + public function testSendCustomerConfirmationEmailUsesOfferSubjectForInquiryWithLink(): void + { + $booking = new AccommodationBooking(); + $booking->setEmail('customer@example.com'); + $booking->setStatus(AccommodationBookingStatus::Inquiry); + $booking->setAccessLinkIssuedAt(new \DateTimeImmutable()); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('sign')->willReturn('https://example.com/offer/signed-link'); + + $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); + $breakdownCalculator->method('compute')->willReturn(null); + + $mailer = $this->createMock(Mailer::class); + $mailer + ->expects(self::once()) + ->method('createAndSendEmail') + ->with( + self::anything(), + self::callback(static fn (array $options) => 'Dein Angebot ist bereit' === $options['subject']), + ); + + $service = $this->createServiceWithAccommodation(mailer: $mailer, linkSigner: $linkSigner, breakdownCalculator: $breakdownCalculator); + + $service->sendCustomerConfirmationEmail($booking); + } + public function testSendCustomerConfirmationEmailAlwaysSendsWithLinkWhenIssued(): void { $booking = new AccommodationBooking(); $booking->setEmail('customer@example.com'); - $booking->setIsInquiry(false); + $booking->setStatus(AccommodationBookingStatus::Booking); $booking->setAccessLinkIssuedAt(new \DateTimeImmutable()); $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); @@ -137,7 +197,7 @@ class AccommodationBookingServiceTest extends TestCase { $booking = new AccommodationBooking(); $booking->setEmail('customer@example.com'); - $booking->setIsInquiry(true); + $booking->setStatus(AccommodationBookingStatus::Inquiry); $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); $breakdownCalculator->method('compute')->willReturn(null); @@ -160,7 +220,7 @@ class AccommodationBookingServiceTest extends TestCase { $booking = new AccommodationBooking(); $booking->setEmail('customer@example.com'); - $booking->setIsInquiry(true); + $booking->setStatus(AccommodationBookingStatus::Inquiry); $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); $breakdownCalculator->method('compute')->willReturn(null); @@ -180,7 +240,7 @@ class AccommodationBookingServiceTest extends TestCase { $booking = new AccommodationBooking(); $booking->setEmail('customer@example.com'); - $booking->setIsInquiry(true); + $booking->setStatus(AccommodationBookingStatus::Inquiry); $previousIssuedAt = new \DateTimeImmutable('2026-01-01'); $booking->setAccessLinkIssuedAt($previousIssuedAt); @@ -210,7 +270,7 @@ class AccommodationBookingServiceTest extends TestCase $entityManager->expects(self::once())->method('flush'); $booking = new AccommodationBooking(); - $booking->setIsInquiry(true); + $booking->setStatus(AccommodationBookingStatus::Inquiry); $booking->setEmail('customer@example.com'); $mailer = $this->createMock(Mailer::class); @@ -228,7 +288,7 @@ class AccommodationBookingServiceTest extends TestCase $service->acceptBooking($booking); - self::assertFalse($booking->isInquiry()); + self::assertSame(AccommodationBookingStatus::Booking, $booking->getStatus()); self::assertNotNull($booking->getAcceptedAt()); } @@ -243,7 +303,7 @@ class AccommodationBookingServiceTest extends TestCase $service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer); $booking = new AccommodationBooking(); - $booking->setIsInquiry(false); + $booking->setStatus(AccommodationBookingStatus::Booking); $service->acceptBooking($booking); @@ -253,7 +313,7 @@ class AccommodationBookingServiceTest extends TestCase public function testSendOfferAcceptedNotificationEmailLogsAndSwallowsMailerFailures(): void { $booking = new AccommodationBooking(); - $booking->setIsInquiry(false); + $booking->setStatus(AccommodationBookingStatus::Booking); $mailer = $this->createMock(Mailer::class); $mailer->method('createAndSendEmail')->willThrowException(new \RuntimeException('SMTP down')); @@ -270,7 +330,7 @@ class AccommodationBookingServiceTest extends TestCase { $booking = new AccommodationBooking(); $booking->setEmail('customer@example.com'); - $booking->setIsInquiry(false); + $booking->setStatus(AccommodationBookingStatus::Booking); $mailer = $this->createMock(Mailer::class); $mailer->method('createAndSendEmail')->willThrowException(new \RuntimeException('SMTP down'));