setStatus(AccommodationBookingStatus::Open); $bookingRepository = $this->createMock(AccommodationBookingRepository::class); $bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking); $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); $linkSigner->method('isValidLinkRequest')->willReturn(true); $linkSigner->expects(self::once())->method('authorizeSession')->with(self::anything(), $booking); $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::assertInstanceOf(RedirectResponse::class, $response); self::assertSame('/app_groups_booking_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl()); } public function testAccessRendersUnavailableForInvalidLink(): void { $booking = new AccommodationBooking(); $bookingRepository = $this->createMock(AccommodationBookingRepository::class); $bookingRepository->method('findOneBy')->willReturn($booking); $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); $linkSigner->method('isValidLinkRequest')->willReturn(false); $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 testAccessRendersUnavailableForUnknownUuid(): void { $bookingRepository = $this->createMock(AccommodationBookingRepository::class); $bookingRepository->method('findOneBy')->willReturn(null); $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); $linkSigner->expects(self::never())->method('isValidLinkRequest'); $controller = new TestableOfferController( $bookingRepository, $linkSigner, $this->createMock(AccommodationBookingBreakdownCalculator::class), $this->createMock(AccommodationBookingService::class), ); $response = $controller->access('unknown-uuid', Request::create('/groups/booking/offer/unknown-uuid')); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertSame('groups/booking/offer_unavailable.html.twig', $controller->renderedView); } /** * @dataProvider statusesTheCustomerMustNotSee */ public function testAccessRendersUnavailableForABookingThatIsNotOfferedYet(AccommodationBookingStatus $status): void { $booking = new AccommodationBooking(); $booking->setStatus($status); $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); } /** * @return iterable */ public static function statusesTheCustomerMustNotSee(): iterable { // A scratch record and an inquiry the office has not worked through yet are both // unfinished; a discarded one is over. All three behave like an expired link. yield 'draft' => [AccommodationBookingStatus::Draft]; yield 'requested' => [AccommodationBookingStatus::Requested]; yield 'discarded' => [AccommodationBookingStatus::Discarded]; } 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->setStatus(AccommodationBookingStatus::Open); $booking->setAccommodation(new Accommodation()); $bookingRepository = $this->createMock(AccommodationBookingRepository::class); $bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking); $linkSigner = $this->createMock(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/booking/offer.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->createMock(AccommodationBookingRepository::class); $bookingRepository->method('findOneBy')->willReturn($booking); $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); $linkSigner->method('isSessionAuthorized')->willReturn(false); $controller = new TestableOfferController( $bookingRepository, $linkSigner, $this->createMock(AccommodationBookingBreakdownCalculator::class), $this->createMock(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/booking/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->createMock(AccommodationBookingLinkSigner::class); $linkSigner->method('isSessionAuthorized')->willReturn(true); $confirmationForm = $this->createMock(FormInterface::class); $confirmationForm->method('handleRequest')->willReturnSelf(); $confirmationForm->method('isSubmitted')->willReturn(false); $controller = new TestableOfferController( $bookingRepository, $linkSigner, $this->createMock(AccommodationBookingBreakdownCalculator::class), $this->createMock(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/booking/_offer_accept_confirmation_modal.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->createMock(AccommodationBookingRepository::class); $bookingRepository->method('findOneBy')->willReturn($booking); $linkSigner = $this->createMock(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->createMock(FormInterface::class); $confirmationForm->method('handleRequest')->willReturnSelf(); $confirmationForm->method('isSubmitted')->willReturn(true); $confirmationForm->method('isValid')->willReturn(true); $controller = new TestableOfferController( $bookingRepository, $linkSigner, $this->createMock(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_booking_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->createMock(AccommodationBookingRepository::class); $bookingRepository->method('findOneBy')->willReturn($booking); $linkSigner = $this->createMock(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->createMock(FormInterface::class); $confirmationForm->method('handleRequest')->willReturnSelf(); $confirmationForm->method('isSubmitted')->willReturn(true); $confirmationForm->method('isValid')->willReturn(true); $controller = new TestableOfferController( $bookingRepository, $linkSigner, $this->createMock(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->createMock(AccommodationBookingRepository::class); $bookingRepository->method('findOneBy')->willReturn($booking); $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); $linkSigner->method('isSessionAuthorized')->willReturn(true); $bookingService = $this->createMock(AccommodationBookingService::class); $bookingService->expects(self::never())->method('acceptBooking'); $confirmationForm = $this->createMock(FormInterface::class); $confirmationForm->method('handleRequest')->willReturnSelf(); $confirmationForm->method('isSubmitted')->willReturn(true); $confirmationForm->method('isValid')->willReturn(false); $controller = new TestableOfferController( $bookingRepository, $linkSigner, $this->createMock(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/booking/_offer_accept_confirmation_modal.html.twig', $controller->renderedView); } public function testConfirmRedirectsWhenAlreadyAccepted(): void { $booking = new AccommodationBooking(); $booking->setStatus(AccommodationBookingStatus::Received); $bookingRepository = $this->createMock(AccommodationBookingRepository::class); $bookingRepository->method('findOneBy')->willReturn($booking); $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); $linkSigner->method('isSessionAuthorized')->willReturn(true); $controller = new TestableOfferController( $bookingRepository, $linkSigner, $this->createMock(AccommodationBookingBreakdownCalculator::class), $this->createMock(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->createMock(AccommodationBookingRepository::class); $bookingRepository->method('findOneBy')->willReturn($booking); $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); $linkSigner->method('isSessionAuthorized')->willReturn(false); $controller = new TestableOfferController( $bookingRepository, $linkSigner, $this->createMock(AccommodationBookingBreakdownCalculator::class), $this->createMock(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->createMock(AccommodationBookingRepository::class); $bookingRepository->method('findOneBy')->willReturn(null); $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); $linkSigner->expects(self::never())->method('isSessionAuthorized'); $controller = new TestableOfferController( $bookingRepository, $linkSigner, $this->createMock(AccommodationBookingBreakdownCalculator::class), $this->createMock(AccommodationBookingService::class), ); $response = $controller->confirm('unknown-uuid', Request::create('/groups/booking/offer/unknown-uuid/confirm')); self::assertInstanceOf(RedirectResponse::class, $response); } } final class TestableOfferController extends OfferController { 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 */ 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; if ('groups/booking/offer.html.twig' === $view) { $content = null !== $parameters['booking']->getAcceptedAt() ? 'confirmed' : 'offer'; } else { $content = 'unavailable'; } $response ??= new Response(); $response->setContent($content); return $response; } }