From cdce7d57e02a5b6c240d9a64cc2c683d569e4be2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 17 Jun 2026 13:07:58 +0200 Subject: [PATCH] fix: refresh live room availability during create flow --- src/BusProNet/XmlLoader/TravelLoader.php | 36 +++ src/BusProNet/XmlParser/ApiResponseParser.php | 10 +- .../XmlParser/AvailabilitiesParser.php | 29 ++- src/Controller/Api/TravelController.php | 7 +- .../AbstractBookingCreateController.php | 53 ++++ .../Booking/Create/Step1Controller.php | 13 + .../Booking/Create/Step2Controller.php | 9 +- .../Create/Step2ParticipantController.php | 48 ++-- .../Booking/Create/Step3Controller.php | 4 + .../Booking/Create/Step4Controller.php | 27 ++ src/Form/Model/BookingDto.php | 66 ++++- .../BookingAvailabilityRefreshResult.php | 16 ++ src/Service/BookingConfigurator.php | 9 +- src/Service/BookingSessionManager.php | 83 +++++- src/Service/TravelDataProvider.php | 157 +++++++++++- tests/BusProNet/Model/TravelTest.php | 33 +++ .../BusProNet/XmlLoader/TravelLoaderTest.php | 72 ++++++ .../XmlParser/AvailabilitiesParserTest.php | 67 +++++ tests/Controller/Api/TravelControllerTest.php | 85 ++++++ .../Booking/Create/Step1ControllerTest.php | 242 ++++++++++++++++++ .../Create/Step2ParticipantControllerTest.php | 199 ++++++++++++++ .../Booking/Create/Step4ControllerTest.php | 239 +++++++++++++++++ tests/Form/Model/BookingDtoTest.php | 161 ++++++++++++ .../Service/BookingConfiguratorStatusTest.php | 28 ++ tests/Service/BookingSessionManagerTest.php | 116 ++++++++- tests/Service/TravelDataProviderTest.php | 221 ++++++++++++++++ 26 files changed, 1976 insertions(+), 54 deletions(-) create mode 100644 src/Service/BookingAvailabilityRefreshResult.php create mode 100644 tests/BusProNet/XmlLoader/TravelLoaderTest.php create mode 100644 tests/BusProNet/XmlParser/AvailabilitiesParserTest.php create mode 100644 tests/Controller/Api/TravelControllerTest.php create mode 100644 tests/Controller/Booking/Create/Step1ControllerTest.php create mode 100644 tests/Controller/Booking/Create/Step2ParticipantControllerTest.php create mode 100644 tests/Controller/Booking/Create/Step4ControllerTest.php diff --git a/src/BusProNet/XmlLoader/TravelLoader.php b/src/BusProNet/XmlLoader/TravelLoader.php index dd5cd449..096dfb02 100644 --- a/src/BusProNet/XmlLoader/TravelLoader.php +++ b/src/BusProNet/XmlLoader/TravelLoader.php @@ -303,6 +303,42 @@ class TravelLoader extends AbstractLoader } } + /** + * Apply live room availability data to the travel rooms. + * + * Matching room IDs are patched with the live payload values. + */ + public function patchRoomAvailabilities(Travel $travel, BaseData $roomAvailabilities): void + { + $liveRooms = $roomAvailabilities->getItems(); + + foreach ($travel->rooms as $room) { + $liveRoom = $liveRooms[$room->id] ?? null; + + if (!$liveRoom instanceof \App\BusProNet\Model\Room) { + continue; + } + + $room->available = $liveRoom->available; + + if (null !== $liveRoom->status) { + $room->status = $liveRoom->status; + } + + if (null !== $liveRoom->price) { + $room->price = $liveRoom->price; + } + + if (null !== $liveRoom->label) { + $room->label = $liveRoom->label; + } + + if (null !== $liveRoom->category) { + $room->category = $liveRoom->category; + } + } + } + /** * Enhance booking data with travel information and URLs. * diff --git a/src/BusProNet/XmlParser/ApiResponseParser.php b/src/BusProNet/XmlParser/ApiResponseParser.php index ce245acd..e4f418c2 100644 --- a/src/BusProNet/XmlParser/ApiResponseParser.php +++ b/src/BusProNet/XmlParser/ApiResponseParser.php @@ -4,10 +4,16 @@ namespace App\BusProNet\XmlParser; use App\BusProNet\ApiClient; use App\BusProNet\Exception\ResponseParserException; +use Psr\Log\LoggerInterface; use Symfony\Component\DomCrawler\Crawler; class ApiResponseParser extends AbstractParser { + public function __construct( + private readonly LoggerInterface $logger, + ) { + } + /** * @throws ResponseParserException */ @@ -62,11 +68,11 @@ class ApiResponseParser extends AbstractParser case ApiClient::TYPE_MUTABLE_DATA: return (new MutableDataParser())->parse($resultNode); case ApiClient::TYPE_AVAILABILITY: - return (new AvailabilitiesParser())->parseServices($resultNode); + return (new AvailabilitiesParser($this->logger))->parseServices($resultNode); case ApiClient::TYPE_AVAILABILITY_EXTENDED: return (new ExtendedAvailabilitiesParser())->parseServices($resultNode); case ApiClient::TYPE_AVAILABILITY_HOTEL: - return (new AvailabilitiesParser())->parseRooms($resultNode); + return (new AvailabilitiesParser($this->logger))->parseRooms($resultNode); case ApiClient::TYPE_BOOKING_UPDATE: return (new BookingUpdateParser())->parse($resultNode); case ApiClient::TYPE_BOOKING: diff --git a/src/BusProNet/XmlParser/AvailabilitiesParser.php b/src/BusProNet/XmlParser/AvailabilitiesParser.php index 55b4102d..cb2dc10d 100644 --- a/src/BusProNet/XmlParser/AvailabilitiesParser.php +++ b/src/BusProNet/XmlParser/AvailabilitiesParser.php @@ -2,14 +2,22 @@ namespace App\BusProNet\XmlParser; +use App\BusProNet\Constants; use App\BusProNet\Model\Availability; use App\BusProNet\Model\BaseData; use App\BusProNet\Model\Room; use App\BusProNet\Model\ServiceAvailabilityResponse; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Symfony\Component\DomCrawler\Crawler; class AvailabilitiesParser extends AbstractParser { + public function __construct( + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + public function parseServices(Crawler $result): ServiceAvailabilityResponse { $availabilities = []; @@ -57,7 +65,7 @@ class AvailabilitiesParser extends AbstractParser $room->category = (string) $node->attr('kategorie'); $room->board = (string) $node->attr('verpflegung'); $room->available = (int) $node->attr('frei'); - $room->status = (string) $node->attr('status'); + $room->status = $this->normalizeRoomStatus((string) $node->attr('status')); $room->price = $this->stringToFloat($node->attr('preis')); $availabilities[$room->id] = $room; @@ -66,4 +74,23 @@ class AvailabilitiesParser extends AbstractParser return new BaseData($availabilities); } + + private function normalizeRoomStatus(string $status): string + { + return match (mb_strtolower(trim($status))) { + 'buchbar' => Constants::STATUS_AVAILABLE, + 'anfrage' => Constants::STATUS_ON_REQUEST, + 'stopp', 'buchungsstop' => Constants::STATUS_BLOCKED, + default => $this->logUnrecognizedStatus($status), + }; + } + + private function logUnrecognizedStatus(string $status): string + { + $this->logger->warning('Unrecognized room availability status from BusProNet XML', [ + 'status' => $status, + ]); + + return $status; + } } diff --git a/src/Controller/Api/TravelController.php b/src/Controller/Api/TravelController.php index 8b5a1128..dce69a95 100644 --- a/src/Controller/Api/TravelController.php +++ b/src/Controller/Api/TravelController.php @@ -4,6 +4,7 @@ namespace App\Controller\Api; use App\BusProNet\ApiClient; use App\BusProNet\Exception\ApiClientException; +use App\BusProNet\Model\Notification; use App\BusProNet\Model\Travel; use App\BusProNet\Utility\DateCodeUtility; use App\Service\TravelDataProvider; @@ -91,7 +92,11 @@ class TravelController extends AbstractController try { $result = $this->apiClient->getHotelAvailability($dateId, $hotelId, $dateTo); - return $this->json($result); + if ($result instanceof Notification) { + return $this->json(['error' => $result->message, 'type' => 'api_error']); + } + + return $this->json($result->getItems(), Response::HTTP_OK, [], ['groups' => ['api:list', 'api:single']]); } catch (ApiClientException $e) { return $this->json(['error' => $e->getMessage(), 'type' => 'api_error']); } diff --git a/src/Controller/Booking/Create/AbstractBookingCreateController.php b/src/Controller/Booking/Create/AbstractBookingCreateController.php index 6af43b43..38824dd8 100644 --- a/src/Controller/Booking/Create/AbstractBookingCreateController.php +++ b/src/Controller/Booking/Create/AbstractBookingCreateController.php @@ -8,6 +8,8 @@ use App\Exception\BookingSessionNotFoundException; use App\Exception\HotelNotInTravelException; use App\Exception\TravelNotFoundException; use App\Form\Model\BookingDto; +use App\Htmx\HxTrait; +use App\Service\BookingAvailabilityRefreshResult; use App\Service\BookingSessionManager; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -20,6 +22,8 @@ use Symfony\Component\HttpFoundation\Response; */ abstract class AbstractBookingCreateController extends AbstractController { + use HxTrait; + protected function loadBookingCreateDto(BookingSessionManager $bookingSessionService, Request $request): BookingDto { return $bookingSessionService->getOrCreateBookingCreateDto($request); @@ -91,4 +95,53 @@ abstract class AbstractBookingCreateController extends AbstractController return $this->redirectToRoute($route); } + + protected function redirectIfRoomSelectionsStale( + Request $request, + BookingDto $bookingDto, + BookingSessionManager $bookingSessionService, + bool $forceRefresh = false, + ?\Closure $onRefreshFailure = null, + ): ?Response { + $result = $bookingSessionService->refreshCreateAvailability($request, $bookingDto, $forceRefresh); + + if (true === $forceRefresh && false === $result->availabilityRefreshed) { + if (null !== $onRefreshFailure) { + return $onRefreshFailure(); + } + + $this->addFlash('error', 'Die Verfügbarkeit konnte gerade nicht aktualisiert werden. Bitte versuche es erneut.'); + + return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_error')); + } + + if ($redirect = $this->redirectIfNoRoomsAvailable($request, $result)) { + return $redirect; + } + + if (false === $result->roomSelectionQuantityReset) { + if (true === $forceRefresh && true === $result->roomSelectionSnapshotChanged) { + $this->addFlash('warning', 'Die Zimmerpreise haben sich aktualisiert. Bitte überprüfe deine Buchung erneut.'); + + return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_step_4')); + } + + return null; + } + + $this->addFlash('error', 'Mindestens eine Zimmerauswahl ist nicht mehr verfügbar. Bitte wähle die Zimmer erneut.'); + + return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_step_1')); + } + + protected function redirectIfNoRoomsAvailable(Request $request, BookingAvailabilityRefreshResult $result): ?Response + { + if (true === $result->hasAvailableRooms) { + return null; + } + + $this->addFlash('error', 'Leider sind für diese Reise aktuell keine Zimmer verfügbar.'); + + return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_error')); + } } diff --git a/src/Controller/Booking/Create/Step1Controller.php b/src/Controller/Booking/Create/Step1Controller.php index 939cd227..d11c8629 100644 --- a/src/Controller/Booking/Create/Step1Controller.php +++ b/src/Controller/Booking/Create/Step1Controller.php @@ -55,6 +55,19 @@ class Step1Controller extends AbstractBookingCreateController return $redirect; } + $availabilityRefresh = $this->bookingSessionService->refreshCreateAvailability($request, $bookingCreateDto); + if ($availabilityRefresh->roomSelectionSnapshotChanged) { + $oldRoomSelectionSnapshot = $this->bookingSessionService->saveBaselineSnapshot($request, $bookingCreateDto); + } + + if ($availabilityRefresh->roomSelectionQuantityReset) { + $this->addFlash('warning', 'Mindestens eine Zimmerauswahl ist nicht mehr verfügbar und wurde zurückgesetzt.'); + } + + if ($redirect = $this->redirectIfNoRoomsAvailable($request, $availabilityRefresh)) { + return $redirect; + } + $form = $this->createForm(BookingCreateStep1Type::class, $bookingCreateDto, [ 'validation_groups' => ['booking_create_step_1'], ]); diff --git a/src/Controller/Booking/Create/Step2Controller.php b/src/Controller/Booking/Create/Step2Controller.php index e8bd42dd..488a4326 100644 --- a/src/Controller/Booking/Create/Step2Controller.php +++ b/src/Controller/Booking/Create/Step2Controller.php @@ -18,7 +18,6 @@ use App\Service\BookingCreateContextFactory; use App\Service\BookingSessionManager; use App\Service\ParticipantDataPrefiller; use App\Service\RoomAssigner; -use App\Service\TravelDataProvider; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -35,7 +34,6 @@ class Step2Controller extends AbstractBookingCreateController private readonly BookingConfigurator $bookingService, private readonly BookingSessionManager $bookingSessionService, private readonly BookingCreateContextFactory $createContextFactory, - private readonly TravelDataProvider $travelDataService, private readonly RoomAssigner $roomAssignmentService, private readonly ParticipantDataPrefiller $prepopulationService, ) { @@ -58,8 +56,11 @@ class Step2Controller extends AbstractBookingCreateController return $redirect; } - // Enrich with fresh availability data - $this->travelDataService->enrichWithFreshAvailabilities($bookingCreateDto->travel); + if ($redirect = $this->redirectIfRoomSelectionsStale($request, $bookingCreateDto, $this->bookingSessionService)) { + return $redirect; + } + + $this->bookingService->updateBookingStatusFromRoomSelection($bookingCreateDto); // Ensure correct number of participants first, then prepopulate the applicant if needed. $this->ensureCorrectNumberOfParticipants($bookingCreateDto); diff --git a/src/Controller/Booking/Create/Step2ParticipantController.php b/src/Controller/Booking/Create/Step2ParticipantController.php index 9a9e14c4..e4dda1a4 100644 --- a/src/Controller/Booking/Create/Step2ParticipantController.php +++ b/src/Controller/Booking/Create/Step2ParticipantController.php @@ -13,8 +13,6 @@ use App\Service\BookingSessionManager; use App\Service\ParticipantDataPrefiller; use App\Service\ParticipantFormSupport; use App\Service\RoomPricingCalculator; -use App\Service\TravelDataProvider; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -23,7 +21,7 @@ use Symfony\Component\Routing\Attribute\Route; /** * Handles participant editing and refresh actions within booking step 2. */ -class Step2ParticipantController extends AbstractController +class Step2ParticipantController extends AbstractBookingCreateController { use HxTrait; @@ -31,7 +29,6 @@ class Step2ParticipantController extends AbstractController private readonly BookingConfigurator $bookingService, private readonly BookingSessionManager $bookingSessionService, private readonly BookingCreateContextFactory $createContextFactory, - private readonly TravelDataProvider $travelDataService, private readonly ParticipantDataPrefiller $prepopulationService, private readonly ParticipantFormSupport $participantFormSupportService, ) { @@ -51,48 +48,53 @@ class Step2ParticipantController extends AbstractController if ($result instanceof Response) { return $result; } - $bookingDto = $result; + $bookingCreateDto = $result; - $this->participantFormSupportService->ensureParticipantExists($bookingDto, $index); + $this->participantFormSupportService->ensureParticipantExists($bookingCreateDto, $index); - $this->travelDataService->enrichWithFreshAvailabilities($bookingDto->travel); + if ($redirect = $this->redirectIfRoomSelectionsStale($request, $bookingCreateDto, $this->bookingSessionService)) { + return $redirect; + } - $form = $this->createParticipantForm($bookingDto, $index); + $this->bookingService->updateBookingStatusFromRoomSelection($bookingCreateDto); + $this->bookingSessionService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); + + $form = $this->createParticipantForm($bookingCreateDto, $index); $form->handleRequest($request); $isSubmitted = $form->isSubmitted(); $isDummyDataFill = $this ->prepopulationService - ->isDummyDataFillRequested($bookingDto->participants[$index], $bookingDto->getMode()) + ->isDummyDataFillRequested($bookingCreateDto->participants[$index], $bookingCreateDto->getMode()) ; if (true === $isSubmitted && true === $isDummyDataFill) { - $this->prepopulationService->fillDummyParticipant($bookingDto->participants[$index], $index); + $this->prepopulationService->fillDummyParticipant($bookingCreateDto->participants[$index], $index); - $this->bookingService->preselectDefaultServices($bookingDto); - $this->bookingService->applyCreateBookingStatusRules($bookingDto); - $this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); + $this->bookingService->preselectDefaultServices($bookingCreateDto); + $this->bookingService->applyCreateBookingStatusRules($bookingCreateDto); + $this->bookingSessionService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); - $form = $this->createParticipantForm($bookingDto, $index); + $form = $this->createParticipantForm($bookingCreateDto, $index); - return $this->renderParticipantForm($form, $index, $bookingDto); + return $this->renderParticipantForm($form, $index, $bookingCreateDto); } if (true === $isSubmitted) { - $this->bookingService->preselectDefaultServices($bookingDto); - $this->bookingService->applyCreateBookingStatusRules($bookingDto); + $this->bookingService->preselectDefaultServices($bookingCreateDto); + $this->bookingService->applyCreateBookingStatusRules($bookingCreateDto); } - $notifications = $this->participantFormSupportService->collectAndClearNotifications($bookingDto); + $notifications = $this->participantFormSupportService->collectAndClearNotifications($bookingCreateDto); if (true === $isSubmitted && true === $form->isValid()) { - $this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); + $this->bookingSessionService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); $this->addNotificationsAsFlashMessages($notifications); return $this->redirectToRoute('app_booking_create_step_2'); } - return $this->renderParticipantForm($form, $index, $bookingDto); + return $this->renderParticipantForm($form, $index, $bookingCreateDto); } /** @@ -114,7 +116,11 @@ class Step2ParticipantController extends AbstractController $this->participantFormSupportService->ensureParticipantExists($bookingDto, $index); - $this->travelDataService->enrichWithFreshAvailabilities($bookingDto->travel); + if ($redirect = $this->redirectIfRoomSelectionsStale($request, $bookingDto, $this->bookingSessionService)) { + return $redirect; + } + + $this->bookingService->updateBookingStatusFromRoomSelection($bookingDto); return $this->handleParticipantRefresh( $request, diff --git a/src/Controller/Booking/Create/Step3Controller.php b/src/Controller/Booking/Create/Step3Controller.php index 1542fc31..2f4b4f02 100644 --- a/src/Controller/Booking/Create/Step3Controller.php +++ b/src/Controller/Booking/Create/Step3Controller.php @@ -61,6 +61,10 @@ class Step3Controller extends AbstractBookingCreateController return $redirect; } + if ($redirect = $this->redirectIfRoomSelectionsStale($request, $bookingCreateDto, $this->bookingSessionService)) { + return $redirect; + } + $form = $this->createForm(BookingCreateStep3Type::class, $bookingCreateDto); $form->handleRequest($request); diff --git a/src/Controller/Booking/Create/Step4Controller.php b/src/Controller/Booking/Create/Step4Controller.php index a3e0dec3..5348a7f5 100644 --- a/src/Controller/Booking/Create/Step4Controller.php +++ b/src/Controller/Booking/Create/Step4Controller.php @@ -62,6 +62,10 @@ class Step4Controller extends AbstractBookingCreateController return $redirect; } + if ($redirect = $this->redirectIfRoomSelectionsStale($request, $bookingCreateDto, $this->bookingSessionService)) { + return $redirect; + } + $newsletterTargetEmail = $this->resolveNewsletterTargetEmail($bookingCreateDto); $newsletterOptInVisible = null !== $newsletterTargetEmail; @@ -72,6 +76,20 @@ class Step4Controller extends AbstractBookingCreateController $form->handleRequest($request); if (true === $form->isSubmitted() && true === $form->isValid()) { + if ($redirect = $this->redirectIfRoomSelectionsStale( + $request, + $bookingCreateDto, + $this->bookingSessionService, + forceRefresh: true, + onRefreshFailure: function () use ($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail): Response { + $this->addFlash('error', 'Die Verfügbarkeit konnte gerade nicht aktualisiert werden. Bitte versuche es erneut.'); + + return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail); + }, + )) { + return $redirect; + } + try { $this->bookingService->applyCreateBookingStatusRules($bookingCreateDto); @@ -238,5 +256,14 @@ class Step4Controller extends AbstractBookingCreateController // Clear travel data cache (both local and remote variants) $this->cache->delete(sprintf('travel_unified_%d_%d_local', $dateId, $hotelId)); $this->cache->delete(sprintf('travel_unified_%d_%d_remote', $dateId, $hotelId)); + + if (null !== $bookingDto->travel->dateTo) { + $this->cache->delete(sprintf( + 'hotel_availability_%d_%d_%s', + $dateId, + $hotelId, + $bookingDto->travel->dateTo->format('Ymd') + )); + } } } diff --git a/src/Form/Model/BookingDto.php b/src/Form/Model/BookingDto.php index d3ba4cb1..60b0cdee 100644 --- a/src/Form/Model/BookingDto.php +++ b/src/Form/Model/BookingDto.php @@ -6,6 +6,7 @@ namespace App\Form\Model; use App\BusProNet\Constants; use App\BusProNet\Model\Booking; +use App\BusProNet\Model\Room; use App\BusProNet\Model\Travel; use App\BusProNet\XmlLoader\AgencyLoader; use App\Validator\Constraints as AppAssert; @@ -288,15 +289,74 @@ class BookingDto } } + /** + * Synchronizes room selections with live travel data. + * + * Returns true only when selected quantities had to be cleared, so callers know + * whether dependent participant assignments must be reset. + */ + public function syncRoomSelectionsWithAvailableRooms(): bool + { + $availableRooms = $this->travel->getAvailableRooms(); + $resetOccurred = false; + + foreach ($this->roomSelections as $index => $roomSelection) { + $room = $availableRooms[$roomSelection->id] ?? null; + if (null === $room) { + if (0 < (int) $roomSelection->quantity) { + $resetOccurred = true; + } + + unset($this->roomSelections[$index]); + continue; + } + + $roomSelection->label = $room->label; + $roomSelection->price = $room->price; + $roomSelection->status = $room->status; + $roomSelection->maxQuantity = $room->available ?? 0; + $roomSelection->capacity = $room->minPax ?? 0; + + if (0 < (int) $roomSelection->quantity && $roomSelection->quantity > ($room->available ?? 0)) { + $roomSelection->quantity = 0; + $resetOccurred = true; + } + } + + foreach ($availableRooms as $roomId => $room) { + if (isset($this->roomSelections[$roomId])) { + continue; + } + + $this->roomSelections[$roomId] = $this->createRoomSelectionFromRoom($room); + } + + return $resetOccurred; + } + + private function createRoomSelectionFromRoom(Room $room): RoomSelectionDto + { + $roomSelection = new RoomSelectionDto(); + $roomSelection->id = (int) $room->id; + $roomSelection->label = $room->label; + $roomSelection->price = $room->price; + $roomSelection->status = $room->status; + $roomSelection->quantity = 0; + $roomSelection->maxQuantity = $room->available ?? 0; + $roomSelection->capacity = $room->minPax ?? 0; + + return $roomSelection; + } + /** * Creates a snapshot of the current room selection state. * - * @return array Array of [roomId, quantity] pairs + * @return array Array of [roomId, quantity, price] tuples */ public function createRoomSelectionSnapshot(): array { return array_map( - fn ($roomSelection) => [(int) $roomSelection->id, (int) $roomSelection->quantity], + fn ($roomSelection) => [(int) $roomSelection->id, (int) $roomSelection->quantity, (float) ($roomSelection->price ?? 0.0)], $this->roomSelections ); } @@ -304,7 +364,7 @@ class BookingDto /** * Checks if room selection has changed compared to a previous snapshot. * - * @param array $oldSnapshot The baseline room selection snapshot + * @param array $oldSnapshot The baseline room selection snapshot * * @return bool True if room selections have changed, false otherwise */ diff --git a/src/Service/BookingAvailabilityRefreshResult.php b/src/Service/BookingAvailabilityRefreshResult.php new file mode 100644 index 00000000..80ec595b --- /dev/null +++ b/src/Service/BookingAvailabilityRefreshResult.php @@ -0,0 +1,16 @@ +travelDataService->getAvailabilityData($dateId, cached: true, forceRefresh: true); - if (null !== $availabilities) { - $this->travelDataService->patchAvailabilities($travelData, $availabilities); + // Refresh both the service availability and room inventory from the live API + $refreshed = $this->travelDataService->enrichWithFreshAvailabilities($travelData, true, true, refreshRooms: true); + if (true === $refreshed) { + $this->bookingSessionService->markCreateAvailabilityRefreshed($request); } // Get bookable rooms (Frei or Anfrage with available > 0) diff --git a/src/Service/BookingSessionManager.php b/src/Service/BookingSessionManager.php index d93d1ddc..1d137b74 100644 --- a/src/Service/BookingSessionManager.php +++ b/src/Service/BookingSessionManager.php @@ -21,9 +21,11 @@ class BookingSessionManager { public const BOOKING_CREATE_KEY = 'booking_create'; public const BOOKING_CREATE_BASELINE_KEY = 'booking_create_baseline_snapshot'; + public const BOOKING_CREATE_AVAILABILITY_REFRESHED_AT_KEY = 'booking_create_availability_refreshed_at'; public const BOOKING_EDIT_KEY = 'booking_edit'; public const RETURN_URL_KEY = 'booking_return_url'; public const DEFAULT_RETURN_URL = 'https://www.ep-reisen.de'; + private const CREATE_AVAILABILITY_REFRESH_INTERVAL = 600; public function __construct( private readonly TravelDataProvider $travelDataService, @@ -51,22 +53,32 @@ class BookingSessionManager /** * Gets or creates the baseline room selection snapshot for change detection. * - * @return array + * @return array */ public function getOrCreateBaselineSnapshot(Request $request, BookingDto $bookingCreateDto): array { $baselineKey = self::BOOKING_CREATE_BASELINE_KEY; if (!$request->getSession()->has($baselineKey) || $request->query->has('reset_baseline')) { - $baseline = $bookingCreateDto->createRoomSelectionSnapshot(); - $request->getSession()->set($baselineKey, $baseline); - - return $baseline; + return $this->saveBaselineSnapshot($request, $bookingCreateDto); } return $request->getSession()->get($baselineKey); } + /** + * Stores the current room selection snapshot in the session. + * + * @return array + */ + public function saveBaselineSnapshot(Request $request, BookingDto $bookingCreateDto): array + { + $baseline = $bookingCreateDto->createRoomSelectionSnapshot(); + $request->getSession()->set(self::BOOKING_CREATE_BASELINE_KEY, $baseline); + + return $baseline; + } + /** * Clears the baseline snapshot from the session. */ @@ -97,7 +109,12 @@ class BookingSessionManager } $bookingDto = $session->get($sessionKey); - $this->hydrate($bookingDto); + $shouldCheck = BookingDto::MODE_CREATE === $mode && $this->shouldRefreshCreateAvailability($request); + $hydrated = $this->hydrate($bookingDto, $shouldCheck); + + if (BookingDto::MODE_CREATE === $mode && true === $shouldCheck && true === $hydrated) { + $this->markCreateAvailabilityRefreshed($request); + } return $bookingDto; } @@ -111,6 +128,39 @@ class BookingSessionManager $request->getSession()->remove($sessionKey); } + public function refreshCreateAvailability( + Request $request, + BookingDto $bookingDto, + bool $forceRefresh = false, + ): BookingAvailabilityRefreshResult { + $beforeSnapshot = $bookingDto->createRoomSelectionSnapshot(); + $availabilityRefreshed = $this->travelDataService->enrichWithFreshAvailabilities( + $bookingDto->travel, + true, + $forceRefresh, + refreshRooms: true, + ); + $roomSelectionQuantityReset = $bookingDto->syncRoomSelectionsWithAvailableRooms(); + $afterSnapshot = $bookingDto->createRoomSelectionSnapshot(); + + if (true === $roomSelectionQuantityReset) { + $bookingDto->resetParticipantAssignments(); + } + + $this->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); + + if (true === $forceRefresh && true === $availabilityRefreshed) { + $this->markCreateAvailabilityRefreshed($request); + } + + return new BookingAvailabilityRefreshResult( + availabilityRefreshed: $availabilityRefreshed, + roomSelectionQuantityReset: $roomSelectionQuantityReset, + roomSelectionSnapshotChanged: $beforeSnapshot !== $afterSnapshot, + hasAvailableRooms: [] !== $bookingDto->travel->getAvailableRooms(), + ); + } + /** * Clears all booking-related session data except the return URL. */ @@ -119,6 +169,7 @@ class BookingSessionManager $session = $request->getSession(); $session->remove(self::BOOKING_CREATE_KEY); $session->remove(self::BOOKING_CREATE_BASELINE_KEY); + $session->remove(self::BOOKING_CREATE_AVAILABILITY_REFRESHED_AT_KEY); } /** @@ -154,7 +205,20 @@ class BookingSessionManager /** * Restores the full Travel object after session deserialization. */ - private function hydrate(BookingDto $bookingDto): void + public function markCreateAvailabilityRefreshed(Request $request): void + { + $request->getSession()->set(self::BOOKING_CREATE_AVAILABILITY_REFRESHED_AT_KEY, time()); + } + + private function shouldRefreshCreateAvailability(Request $request): bool + { + $lastRefreshedAt = (int) $request->getSession()->get(self::BOOKING_CREATE_AVAILABILITY_REFRESHED_AT_KEY, 0); + + return 0 === $lastRefreshedAt + || (time() - $lastRefreshedAt) >= self::CREATE_AVAILABILITY_REFRESH_INTERVAL; + } + + private function hydrate(BookingDto $bookingDto, bool $forceRefresh = false): bool { $travel = $this->travelDataService->getTravelData( $bookingDto->travel->id, @@ -162,13 +226,16 @@ class BookingSessionManager ); if (null === $travel) { - return; + return false; } + $availabilityRefreshed = $this->travelDataService->enrichWithFreshAvailabilities($travel, true, $forceRefresh, refreshRooms: true); $bookingDto->travel = $travel; if (null !== $bookingDto->booking) { $bookingDto->booking->travelData = $travel; } + + return $availabilityRefreshed; } } diff --git a/src/Service/TravelDataProvider.php b/src/Service/TravelDataProvider.php index 342c40e6..d8c40436 100644 --- a/src/Service/TravelDataProvider.php +++ b/src/Service/TravelDataProvider.php @@ -8,6 +8,7 @@ use App\BusProNet\ApiClient; use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Model\BaseData; use App\BusProNet\Model\Notification; +use App\BusProNet\Model\Room; use App\BusProNet\Model\ServiceAvailabilityResponse; use App\BusProNet\Model\Travel; use App\BusProNet\XmlLoader\TravelLoader; @@ -329,12 +330,12 @@ class TravelDataProvider * Gets availability data for a travel date. * * @param int $dateId The travel date ID for API call - * @param bool $cached Whether to use cached data (default: false) + * @param bool $cached Whether to use cached data (default: true, TTL: 10 minutes) * @param bool $forceRefresh Whether to invalidate cache before fetching (requires cached: true) * * @return ServiceAvailabilityResponse|null The availability data or null if not available or error occurred */ - public function getAvailabilityData(int $dateId, bool $cached = false, bool $forceRefresh = false): ?ServiceAvailabilityResponse + public function getAvailabilityData(int $dateId, bool $cached = true, bool $forceRefresh = false): ?ServiceAvailabilityResponse { if ($cached) { return $this->fetchCached( @@ -366,16 +367,119 @@ class TravelDataProvider /** * Enriches travel data with fresh availability information from the API. * - * @param Travel $travel The travel object to enrich - * @param bool $cached Whether to use cached availability data (default: true) + * Service availability is refreshed first, then room availability is + * patched from the live hotel availability endpoint for the full travel + * snapshot. Missing room IDs are logged as a contract issue rather than + * interpreted as sold out. + * + * @param Travel $travel The travel object to enrich + * @param bool $cached Whether to use cached service availability data (default: true) + * @param bool $refreshRooms Whether to also refresh room-level hotel availability (default: false) */ - public function enrichWithFreshAvailabilities(Travel $travel, bool $cached = true): void - { - $availabilities = $this->getAvailabilityData($travel->id, $cached); + public function enrichWithFreshAvailabilities( + Travel $travel, + bool $cached = true, + bool $forceRefresh = false, + bool $refreshRooms = false, + ): bool { + $fullyRefreshed = true; + + $availabilities = $this->getAvailabilityData($travel->id, $cached, $forceRefresh); if (null !== $availabilities) { $this->patchAvailabilities($travel, $availabilities); + } else { + $fullyRefreshed = false; } + + if (true === $refreshRooms && null !== $travel->hotelId && null !== $travel->dateTo) { + $roomAvailabilities = $this->getHotelAvailabilityData( + $travel->id, + $travel->hotelId, + $travel->dateTo, + $cached, + $forceRefresh + ); + + if (null !== $roomAvailabilities) { + $this->patchRoomAvailabilities($travel, $roomAvailabilities); + } else { + $fullyRefreshed = false; + } + } + + return $fullyRefreshed; + } + + /** + * Apply live room availability data to the travel. + * + * @param Travel $travel The travel object to update + * @param BaseData $roomAvailabilities The room availability data to apply + */ + public function patchRoomAvailabilities(Travel $travel, BaseData $roomAvailabilities): void + { + $liveRooms = $roomAvailabilities->getItems(); + $travelRoomIds = array_map( + static fn (Room $room): int => (int) $room->id, + array_values($travel->rooms) + ); + $liveRoomIds = array_values(array_map( + static fn (int|string $roomId): int => (int) $roomId, + array_keys($liveRooms) + )); + $missingRoomIds = array_values(array_diff($travelRoomIds, $liveRoomIds)); + + if ([] !== $missingRoomIds) { + $this->logger->warning('Hotel availability response is missing room IDs from the travel snapshot', [ + 'travelId' => $travel->id, + 'hotelId' => $travel->hotelId, + 'missingRoomIds' => $missingRoomIds, + ]); + } + + $this->travelLoader->patchRoomAvailabilities($travel, $roomAvailabilities); + + $this->logger->debug('Successfully patched room availability data', [ + 'travelId' => $travel->id, + ]); + } + + /** + * Gets room availability data for a travel date and hotel. + * + * @param int $dateId The travel date ID for API call + * @param int $hotelId The hotel ID for API call + * @param \DateTimeInterface $dateTo The travel end date + * @param bool $cached Whether to use cached data (default: true, TTL: 10 minutes) + * @param bool $forceRefresh Whether to invalidate cache before fetching + * + * @return BaseData|null The room availability data or null if unavailable + */ + public function getHotelAvailabilityData( + int $dateId, + int $hotelId, + \DateTimeInterface $dateTo, + bool $cached = true, + bool $forceRefresh = false, + ): ?BaseData { + $cacheKey = sprintf( + 'hotel_availability_%d_%d_%s', + $dateId, + $hotelId, + $dateTo->format('Ymd') + ); + + if ($cached) { + return $this->fetchCached( + $cacheKey, + self::AVAILABILITY_CACHE_TTL, + fn () => $this->fetchHotelAvailabilityData($dateId, $hotelId, $dateTo), + $forceRefresh, + ); + } + + return $this->fetchHotelAvailabilityData($dateId, $hotelId, $dateTo); } // ------------------------------------------------------------------------- @@ -579,6 +683,45 @@ class TravelDataProvider } } + private function fetchHotelAvailabilityData( + int $dateId, + int $hotelId, + \DateTimeInterface $dateTo, + ): ?BaseData { + try { + $availabilities = $this->apiClient->getHotelAvailability($dateId, $hotelId, $dateTo); + + if ($availabilities instanceof Notification) { + $this->logger->warning('API returned notification for hotel availability data', [ + 'dateId' => $dateId, + 'hotelId' => $hotelId, + 'dateTo' => $dateTo->format('Y-m-d'), + 'message' => $availabilities->message, + 'isError' => $availabilities->isError(), + ]); + + return null; + } + + $this->logger->debug('Successfully fetched hotel availability data', [ + 'dateId' => $dateId, + 'hotelId' => $hotelId, + 'dateTo' => $dateTo->format('Y-m-d'), + ]); + + return $availabilities; + } catch (ApiClientException $e) { + $this->logger->error('Failed to fetch hotel availability data from API', [ + 'dateId' => $dateId, + 'hotelId' => $hotelId, + 'dateTo' => $dateTo->format('Y-m-d'), + 'error' => $e->getMessage(), + ]); + + return null; + } + } + /** * Fetch a value from cache, computing it via $fetcher on a miss. * diff --git a/tests/BusProNet/Model/TravelTest.php b/tests/BusProNet/Model/TravelTest.php index eda33c09..b65db7d4 100644 --- a/tests/BusProNet/Model/TravelTest.php +++ b/tests/BusProNet/Model/TravelTest.php @@ -82,6 +82,39 @@ class TravelTest extends TestCase $this->assertCount(2, $result); } + public function testGetAvailableRoomsExcludesSoldOutRooms(): void + { + $travel = new Travel(); + + $availableRoom = new \App\BusProNet\Model\Room(); + $availableRoom->id = 1; + $availableRoom->label = 'Doppelzimmer'; + $availableRoom->available = 2; + $availableRoom->status = 'Frei'; + + $soldOutRoom = new \App\BusProNet\Model\Room(); + $soldOutRoom->id = 2; + $soldOutRoom->label = 'Einzelzimmer'; + $soldOutRoom->available = 0; + $soldOutRoom->status = 'Frei'; + + $requestRoom = new \App\BusProNet\Model\Room(); + $requestRoom->id = 3; + $requestRoom->label = 'Familienzimmer'; + $requestRoom->available = 1; + $requestRoom->status = 'Anfrage'; + + $travel->rooms = [$availableRoom, $soldOutRoom, $requestRoom]; + + $result = $travel->getAvailableRooms(); + + $this->assertSame([ + 1 => $availableRoom, + 3 => $requestRoom, + ], $result); + $this->assertArrayNotHasKey(2, $result); + } + /** * Test that services are sorted by subType. */ diff --git a/tests/BusProNet/XmlLoader/TravelLoaderTest.php b/tests/BusProNet/XmlLoader/TravelLoaderTest.php new file mode 100644 index 00000000..adfc37b3 --- /dev/null +++ b/tests/BusProNet/XmlLoader/TravelLoaderTest.php @@ -0,0 +1,72 @@ +createLoader(); + + $travel = new Travel(); + + $patchedRoom = new Room(); + $patchedRoom->id = 1; + $patchedRoom->available = 2; + $patchedRoom->status = 'Frei'; + $patchedRoom->price = 100.0; + + $otherRoom = new Room(); + $otherRoom->id = 2; + $otherRoom->available = 1; + $otherRoom->status = 'Frei'; + $otherRoom->price = 150.0; + + $travel->rooms = [$patchedRoom, $otherRoom]; + + $liveRoom = new Room(); + $liveRoom->id = 1; + $liveRoom->available = 0; + $liveRoom->status = 'Anfrage'; + $liveRoom->price = 110.0; + $liveRoom->label = 'Live room'; + $liveRoom->category = 'Live category'; + + $loader->patchRoomAvailabilities($travel, new BaseData([ + 1 => $liveRoom, + ])); + + $this->assertSame(0, $travel->rooms[0]->available); + $this->assertSame('Anfrage', $travel->rooms[0]->status); + $this->assertSame(110.0, $travel->rooms[0]->price); + $this->assertSame('Live room', $travel->rooms[0]->label); + $this->assertSame('Live category', $travel->rooms[0]->category); + + $this->assertSame(1, $travel->rooms[1]->available); + $this->assertSame('Frei', $travel->rooms[1]->status); + $this->assertSame(150.0, $travel->rooms[1]->price); + } + + private function createLoader(): TravelLoader + { + return new TravelLoader( + $this->createMock(HotelLoader::class), + $this->createMock(TravelParser::class), + 'https://example.com', + new ArrayAdapter(), + $this->createMock(FilesystemOperator::class), + ); + } +} diff --git a/tests/BusProNet/XmlParser/AvailabilitiesParserTest.php b/tests/BusProNet/XmlParser/AvailabilitiesParserTest.php new file mode 100644 index 00000000..f62886c8 --- /dev/null +++ b/tests/BusProNet/XmlParser/AvailabilitiesParserTest.php @@ -0,0 +1,67 @@ +parser = new AvailabilitiesParser(); + } + + public function testParseRoomsNormalizesHotelAvailabilityStatuses(): void + { + $xml = <<<'XML' + + + + + + + + XML; + + $rooms = $this->parser->parseRooms(new Crawler($xml))->getItems(); + + $this->assertContainsOnlyInstancesOf(Room::class, $rooms); + $this->assertSame(Constants::STATUS_AVAILABLE, $rooms[1]->status); + $this->assertSame(Constants::STATUS_ON_REQUEST, $rooms[2]->status); + $this->assertSame(Constants::STATUS_BLOCKED, $rooms[3]->status); + } + + public function testParseRoomsLogsUnrecognizedStatus(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('warning') + ->with( + 'Unrecognized room availability status from BusProNet XML', + ['status' => 'foo'] + ); + + $parser = new AvailabilitiesParser($logger); + + $xml = <<<'XML' + + + + + + XML; + + $rooms = $parser->parseRooms(new Crawler($xml))->getItems(); + + $this->assertSame('foo', $rooms[1]->status); + } +} diff --git a/tests/Controller/Api/TravelControllerTest.php b/tests/Controller/Api/TravelControllerTest.php new file mode 100644 index 00000000..8ea0e30d --- /dev/null +++ b/tests/Controller/Api/TravelControllerTest.php @@ -0,0 +1,85 @@ +id = 1; + $room->label = 'Doppelzimmer'; + $room->status = 'Frei'; + $room->available = 2; + $room->price = 199.0; + // booking-only fields that must NOT appear in the API response + $room->dateFrom = new \DateTimeImmutable('2030-01-01'); + $room->totalCount = 42; + $room->board = 'Halbpension'; + + $apiClient = $this->createMock(ApiClient::class); + $apiClient->expects($this->once()) + ->method('getHotelAvailability') + ->willReturn(new BaseData([1 => $room])); + + $controller = new TravelController($apiClient, $this->createMock(TravelDataProvider::class)); + + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + $serializer = new Serializer( + [new DateTimeNormalizer(), new ObjectNormalizer($classMetadataFactory)], + [new JsonEncoder()], + ); + $container = new Container(); + $container->set('serializer', $serializer); + $controller->setContainer($container); + + $response = $controller->hotelAvailability(12345, 67890, new \DateTimeImmutable('2030-01-06')); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + $roomPayload = $payload['1'] ?? null; + $this->assertNotNull($roomPayload); + $this->assertSame('Frei', $roomPayload['status']); + $this->assertArrayNotHasKey('dateFrom', $roomPayload); + $this->assertArrayNotHasKey('totalCount', $roomPayload); + $this->assertArrayNotHasKey('board', $roomPayload); + } + + public function testHotelAvailabilityReturnsErrorPayloadForApiNotification(): void + { + $apiClient = $this->createMock(ApiClient::class); + $apiClient->expects($this->once()) + ->method('getHotelAvailability') + ->willReturn(new Notification(999, 'Hotel not found')); + + $controller = new TravelController($apiClient, $this->createMock(TravelDataProvider::class)); + + $container = new Container(); + $container->set('serializer', new Serializer( + [new ObjectNormalizer(new ClassMetadataFactory(new AttributeLoader()))], + [new JsonEncoder()], + )); + $controller->setContainer($container); + + $response = $controller->hotelAvailability(12345, 67890, new \DateTimeImmutable('2030-01-06')); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + $this->assertSame(['error' => 'Hotel not found', 'type' => 'api_error'], $payload); + } +} diff --git a/tests/Controller/Booking/Create/Step1ControllerTest.php b/tests/Controller/Booking/Create/Step1ControllerTest.php new file mode 100644 index 00000000..9ce91e95 --- /dev/null +++ b/tests/Controller/Booking/Create/Step1ControllerTest.php @@ -0,0 +1,242 @@ +createBookingDtoWithUnavailableSelection(); + + $bookingSessionService = $this->createMock(BookingSessionManager::class); + $bookingSessionService->expects($this->once()) + ->method('getOrCreateBookingCreateDto') + ->with($request) + ->willReturn($bookingDto); + $bookingSessionService->expects($this->once()) + ->method('getOrCreateBaselineSnapshot') + ->with($request, $bookingDto) + ->willReturn([[2, 1]]); + $bookingSessionService->expects($this->once()) + ->method('refreshCreateAvailability') + ->with($request, $bookingDto) + ->willReturn(new BookingAvailabilityRefreshResult( + availabilityRefreshed: true, + roomSelectionQuantityReset: true, + roomSelectionSnapshotChanged: true, + hasAvailableRooms: true, + )); + $bookingSessionService->expects($this->once()) + ->method('saveBaselineSnapshot') + ->with($request, $bookingDto); + + $bookingService = $this->createMock(BookingConfigurator::class); + + $form = $this->createMock(FormInterface::class); + $form->expects($this->once()) + ->method('handleRequest') + ->with($request) + ->willReturnSelf(); + $form->expects($this->once()) + ->method('isSubmitted') + ->willReturn(false); + + $createContextFactory = $this->createMock(BookingCreateContextFactory::class); + $createContextFactory->expects($this->once()) + ->method('create') + ->with($bookingDto, $this->anything()) + ->willReturn(new BookingCreateContext( + bookingDto: $bookingDto, + summaryData: $this->createMock(BookingSummaryDto::class), + groupedRooms: new RoomGroupsDto([], []), + )); + + $controller = new TestableStep1Controller( + $bookingService, + $bookingSessionService, + $createContextFactory, + $form, + ); + + $response = $controller->index($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(['warning', 'Mindestens eine Zimmerauswahl ist nicht mehr verfügbar und wurde zurückgesetzt.'], $controller->flashes[0]); + } + + public function testIndexUsesPrunedBaselineBeforeSubmittingUnchangedSelections(): void + { + $request = Request::create('/bookings/create/rooms', 'POST'); + $bookingDto = $this->createBookingDtoWithUnavailableSelection(); + + $bookingDto = $this->getMockBuilder(BookingDto::class) + ->setConstructorArgs([$bookingDto->travel, $bookingDto->hotelId]) + ->onlyMethods(['hasRoomSelectionChanged', 'resetParticipantAssignments']) + ->getMock(); + + $bookingDto->roomSelections = $this->createBookingDtoWithUnavailableSelection()->roomSelections; + $bookingDto->currentStep = 1; + + $bookingSessionService = $this->createMock(BookingSessionManager::class); + $bookingSessionService->expects($this->once()) + ->method('getOrCreateBookingCreateDto') + ->with($request) + ->willReturn($bookingDto); + $bookingSessionService->expects($this->once()) + ->method('getOrCreateBaselineSnapshot') + ->with($request, $bookingDto) + ->willReturn([[1, 0], [2, 1]]); + $bookingSessionService->expects($this->once()) + ->method('refreshCreateAvailability') + ->with($request, $bookingDto) + ->willReturn(new BookingAvailabilityRefreshResult( + availabilityRefreshed: true, + roomSelectionQuantityReset: true, + roomSelectionSnapshotChanged: true, + hasAvailableRooms: true, + )); + $bookingSessionService->expects($this->once()) + ->method('saveBookingDto') + ->with($request, $bookingDto, BookingDto::MODE_CREATE); + $bookingSessionService->expects($this->once()) + ->method('saveBaselineSnapshot') + ->with($request, $bookingDto) + ->willReturn([[1, 0]]); + $bookingSessionService->expects($this->once()) + ->method('clearBaselineSnapshot') + ->with($request); + + $bookingService = $this->createMock(BookingConfigurator::class); + $bookingService->expects($this->once()) + ->method('updateBookingStatusFromRoomSelection') + ->with($bookingDto); + + $bookingDto->expects($this->never()) + ->method('resetParticipantAssignments'); + $bookingDto->expects($this->once()) + ->method('hasRoomSelectionChanged') + ->with([[1, 0]]) + ->willReturn(false); + + $form = $this->createMock(FormInterface::class); + $form->expects($this->once()) + ->method('handleRequest') + ->with($request) + ->willReturnSelf(); + $form->expects($this->once()) + ->method('isSubmitted') + ->willReturn(true); + $form->expects($this->once()) + ->method('isValid') + ->willReturn(true); + + $createContextFactory = $this->createMock(BookingCreateContextFactory::class); + $createContextFactory->expects($this->never()) + ->method('create'); + + $controller = new TestableStep1Controller( + $bookingService, + $bookingSessionService, + $createContextFactory, + $form, + ); + + $response = $controller->index($request); + + $this->assertInstanceOf(Response::class, $response); + } + + private function createBookingDtoWithUnavailableSelection(): BookingDto + { + $travel = new Travel(); + $travel->id = 1234; + + $availableRoom = new Room(); + $availableRoom->id = 1; + $availableRoom->label = 'Doppelzimmer'; + $availableRoom->available = 2; + $availableRoom->status = 'Frei'; + $travel->rooms = [$availableRoom]; + + $bookingDto = new BookingDto($travel, 77); + + $selection = new RoomSelectionDto(); + $selection->id = 2; + $selection->quantity = 1; + $bookingDto->roomSelections = [$selection]; + + return $bookingDto; + } +} + +final class TestableStep1Controller extends Step1Controller +{ + /** + * @var array + */ + public array $flashes = []; + + /** + * @param FormInterface $form + */ + public function __construct( + BookingConfigurator $bookingService, + BookingSessionManager $bookingSessionService, + BookingCreateContextFactory $createContextFactory, + private readonly FormInterface $form, + ) { + parent::__construct($bookingService, $bookingSessionService, $createContextFactory); + } + + /** + * @return FormInterface + */ + protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface + { + return $this->form; + } + + protected function addFlash(string $type, mixed $message): void + { + $this->flashes[] = [$type, $message]; + } + + /** + * @param array $parameters + */ + protected function render(string $view, array $parameters = [], ?Response $response = null): Response + { + return new Response('rendered:'.$view); + } + + protected function redirectToRoute(string $route, array $parameters = [], int $status = 302): RedirectResponse + { + return new RedirectResponse('redirect:'.$route, $status); + } + + protected function generateUrl(string $route, array $parameters = [], int $referenceType = 0): string + { + return $route; + } +} diff --git a/tests/Controller/Booking/Create/Step2ParticipantControllerTest.php b/tests/Controller/Booking/Create/Step2ParticipantControllerTest.php new file mode 100644 index 00000000..66bf1da0 --- /dev/null +++ b/tests/Controller/Booking/Create/Step2ParticipantControllerTest.php @@ -0,0 +1,199 @@ +createBookingDto(); + + $bookingSessionService = $this->createMock(BookingSessionManager::class); + $bookingSessionService->expects($this->once()) + ->method('getBookingDto') + ->with($request, BookingDto::MODE_CREATE) + ->willReturn($bookingDto); + $bookingSessionService->expects($this->once()) + ->method('refreshCreateAvailability') + ->with($request, $bookingDto, false) + ->willReturn(new BookingAvailabilityRefreshResult( + availabilityRefreshed: true, + roomSelectionQuantityReset: false, + roomSelectionSnapshotChanged: false, + hasAvailableRooms: true, + )); + $bookingSessionService->expects($this->once()) + ->method('saveBookingDto') + ->with($request, $bookingDto, BookingDto::MODE_CREATE); + + $bookingService = $this->createMock(BookingConfigurator::class); + $bookingService->expects($this->once()) + ->method('updateBookingStatusFromRoomSelection') + ->with($bookingDto); + + $participantFormSupportService = $this->createMock(ParticipantFormSupport::class); + $participantFormSupportService->method('ensureParticipantExists')->willReturn(new ParticipantDto()); + $participantFormSupportService->method('createParticipantEditDto')->willReturn($this->createMock(\App\Form\Model\ParticipantEditDto::class)); + $participantFormSupportService->method('getParticipantFormOptions')->willReturn([]); + $participantFormSupportService->method('collectAndClearNotifications')->willReturn([]); + + $prepopulationService = $this->createMock(ParticipantDataPrefiller::class); + $prepopulationService->method('isDummyDataFillRequested')->willReturn(false); + + $form = $this->createMock(FormInterface::class); + $form->expects($this->once())->method('handleRequest')->with($request)->willReturnSelf(); + $form->expects($this->once())->method('isSubmitted')->willReturn(false); + + $bookingDto->participants[0] = new ParticipantDto(); + + $createContextFactory = $this->createMock(BookingCreateContextFactory::class); + $createContextFactory->method('create')->willReturn(new \App\Form\Model\BookingCreateContext( + bookingDto: $bookingDto, + summaryData: $this->createMock(\App\Form\Model\BookingSummaryDto::class), + groupedRooms: new \App\Form\Model\RoomGroupsDto([], []), + )); + + $controller = new TestableStep2ParticipantController( + $bookingService, + $bookingSessionService, + $createContextFactory, + $prepopulationService, + $participantFormSupportService, + ); + $controller->form = $form; + + $response = $controller->editParticipant(0, $request); + + $this->assertSame('rendered', $response->getContent()); + } + + public function testRefreshParticipantFormReturnsHxRedirectForHtmxRequestWhenSelectionStale(): void + { + $request = Request::create('/bookings/create/participants/0/refresh', 'POST'); + $request->headers->set('HX-Request', 'true'); + + $response = $this->invokeRefreshWithStaleSelection($request); + + $this->assertInstanceOf(HxRedirectResponse::class, $response); + $this->assertSame('app_booking_create_step_1', $response->headers->get('HX-Redirect')); + } + + public function testRefreshParticipantFormReturnsPlainRedirectForNonHtmxRequestWhenSelectionStale(): void + { + $request = Request::create('/bookings/create/participants/0/refresh', 'POST'); + + $response = $this->invokeRefreshWithStaleSelection($request); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertNotInstanceOf(HxRedirectResponse::class, $response); + $this->assertSame('app_booking_create_step_1', $response->getTargetUrl()); + } + + private function invokeRefreshWithStaleSelection(Request $request): \Symfony\Component\HttpFoundation\Response + { + $bookingDto = $this->createBookingDto(); + + $bookingSessionService = $this->createMock(BookingSessionManager::class); + $bookingSessionService->expects($this->once()) + ->method('getBookingDto') + ->with($request, BookingDto::MODE_CREATE) + ->willReturn($bookingDto); + $bookingSessionService->expects($this->once()) + ->method('refreshCreateAvailability') + ->with($request, $bookingDto, false) + ->willReturn(new BookingAvailabilityRefreshResult( + availabilityRefreshed: true, + roomSelectionQuantityReset: true, + roomSelectionSnapshotChanged: true, + hasAvailableRooms: true, + )); + + $bookingService = $this->createMock(BookingConfigurator::class); + $bookingService->expects($this->never())->method('updateBookingStatusFromRoomSelection'); + + $controller = new TestableStep2ParticipantController( + $bookingService, + $bookingSessionService, + $this->createMock(BookingCreateContextFactory::class), + $this->createMock(ParticipantDataPrefiller::class), + $this->createMock(ParticipantFormSupport::class), + ); + + return $controller->refreshParticipantForm(0, $request); + } + + private function createBookingDto(): BookingDto + { + $travel = new Travel(); + $travel->id = 1234; + $travel->hotelId = 77; + + $room = new Room(); + $room->id = 1; + $room->label = 'Doppelzimmer'; + $room->available = 2; + $room->status = 'Frei'; + $travel->rooms = [$room]; + + $bookingDto = new BookingDto($travel, 77); + $bookingDto->currentStep = 2; + + $selection = new RoomSelectionDto(); + $selection->id = 1; + $selection->quantity = 1; + $bookingDto->roomSelections = [$selection]; + + return $bookingDto; + } +} + +final class TestableStep2ParticipantController extends Step2ParticipantController +{ + /** + * @var array + */ + public array $flashes = []; + + public ?FormInterface $form = null; + + protected function addFlash(string $type, mixed $message): void + { + $this->flashes[] = [$type, $message]; + } + + protected function generateUrl(string $route, array $parameters = [], int $referenceType = 0): string + { + return $route; + } + + protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface + { + return $this->form; + } + + protected function render(string $view, array $parameters = [], ?\Symfony\Component\HttpFoundation\Response $response = null): \Symfony\Component\HttpFoundation\Response + { + return new \Symfony\Component\HttpFoundation\Response('rendered'); + } +} diff --git a/tests/Controller/Booking/Create/Step4ControllerTest.php b/tests/Controller/Booking/Create/Step4ControllerTest.php new file mode 100644 index 00000000..93580278 --- /dev/null +++ b/tests/Controller/Booking/Create/Step4ControllerTest.php @@ -0,0 +1,239 @@ +createBookingDto(); + + $bookingSessionService = $this->createMock(BookingSessionManager::class); + $bookingSessionService->expects($this->once()) + ->method('getOrCreateBookingCreateDto') + ->with($request) + ->willReturn($bookingDto); + $bookingSessionService->expects($this->exactly(2)) + ->method('refreshCreateAvailability') + ->willReturnOnConsecutiveCalls( + new BookingAvailabilityRefreshResult( + availabilityRefreshed: true, + roomSelectionQuantityReset: false, + roomSelectionSnapshotChanged: false, + hasAvailableRooms: true, + ), + new BookingAvailabilityRefreshResult( + availabilityRefreshed: false, + roomSelectionQuantityReset: false, + roomSelectionSnapshotChanged: false, + hasAvailableRooms: true, + ), + ); + + $bookingService = $this->createMock(BookingConfigurator::class); + $bookingService->expects($this->never())->method('applyCreateBookingStatusRules'); + + $apiClient = $this->createMock(ApiClient::class); + $apiClient->expects($this->never())->method('createBooking'); + + $form = $this->createMock(FormInterface::class); + $form->expects($this->once())->method('handleRequest')->with($request)->willReturnSelf(); + $form->expects($this->once())->method('isSubmitted')->willReturn(true); + $form->expects($this->once())->method('isValid')->willReturn(true); + + $createContextFactory = $this->createMock(BookingCreateContextFactory::class); + $createContextFactory->expects($this->once()) + ->method('createWithParticipantPrices') + ->with($bookingDto) + ->willReturn(new BookingCreateContext( + bookingDto: $bookingDto, + summaryData: $this->createMock(BookingSummaryDto::class), + groupedRooms: new RoomGroupsDto([], []), + )); + + $controller = new TestableStep4Controller( + $bookingService, + $bookingSessionService, + $createContextFactory, + $apiClient, + $this->createMock(CacheInterface::class), + $this->createMock(NewsletterManager::class), + new NullLogger(), + $form, + ); + + $response = $controller->index($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertNotInstanceOf(RedirectResponse::class, $response); + $this->assertSame('rendered:booking/create/step_4.html.twig', $response->getContent()); + $this->assertSame(['error', 'Die Verfügbarkeit konnte gerade nicht aktualisiert werden. Bitte versuche es erneut.'], $controller->flashes[0]); + } + + public function testIndexRedirectsWithWarningWhenPriceChangesDuringForcedRefresh(): void + { + $request = Request::create('/bookings/create/confirmation', 'POST'); + $bookingDto = $this->createBookingDto(); + + $bookingSessionService = $this->createMock(BookingSessionManager::class); + $bookingSessionService->expects($this->once()) + ->method('getOrCreateBookingCreateDto') + ->with($request) + ->willReturn($bookingDto); + $bookingSessionService->expects($this->exactly(2)) + ->method('refreshCreateAvailability') + ->willReturnOnConsecutiveCalls( + new BookingAvailabilityRefreshResult( + availabilityRefreshed: true, + roomSelectionQuantityReset: false, + roomSelectionSnapshotChanged: false, + hasAvailableRooms: true, + ), + new BookingAvailabilityRefreshResult( + availabilityRefreshed: true, + roomSelectionQuantityReset: false, + roomSelectionSnapshotChanged: true, + hasAvailableRooms: true, + ), + ); + + $bookingService = $this->createMock(BookingConfigurator::class); + $bookingService->expects($this->never())->method('applyCreateBookingStatusRules'); + + $apiClient = $this->createMock(ApiClient::class); + $apiClient->expects($this->never())->method('createBooking'); + + $form = $this->createMock(FormInterface::class); + $form->expects($this->once())->method('handleRequest')->with($request)->willReturnSelf(); + $form->expects($this->once())->method('isSubmitted')->willReturn(true); + $form->expects($this->once())->method('isValid')->willReturn(true); + + $createContextFactory = $this->createMock(BookingCreateContextFactory::class); + $createContextFactory->expects($this->never())->method('createWithParticipantPrices'); + + $controller = new TestableStep4Controller( + $bookingService, + $bookingSessionService, + $createContextFactory, + $apiClient, + $this->createMock(CacheInterface::class), + $this->createMock(NewsletterManager::class), + new NullLogger(), + $form, + ); + + $response = $controller->index($request); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame('app_booking_create_step_4', $response->getTargetUrl()); + $this->assertSame(['warning', 'Die Zimmerpreise haben sich aktualisiert. Bitte überprüfe deine Buchung erneut.'], $controller->flashes[0]); + } + + private function createBookingDto(): BookingDto + { + $travel = new Travel(); + $travel->id = 1234; + $travel->hotelId = 77; + + $room = new Room(); + $room->id = 1; + $room->label = 'Doppelzimmer'; + $room->available = 2; + $room->status = 'Frei'; + $travel->rooms = [$room]; + + $bookingDto = new BookingDto($travel, 77); + $bookingDto->currentStep = 4; + + $selection = new RoomSelectionDto(); + $selection->id = 1; + $selection->quantity = 1; + $bookingDto->roomSelections = [$selection]; + + return $bookingDto; + } +} + +final class TestableStep4Controller extends Step4Controller +{ + /** + * @var array + */ + public array $flashes = []; + + public function __construct( + BookingConfigurator $bookingService, + BookingSessionManager $bookingSessionService, + BookingCreateContextFactory $createContextFactory, + ApiClient $apiClient, + CacheInterface $cache, + NewsletterManager $doubleOptInService, + \Psr\Log\LoggerInterface $logger, + private readonly FormInterface $form, + ) { + parent::__construct( + $bookingService, + $bookingSessionService, + $createContextFactory, + $apiClient, + $cache, + $doubleOptInService, + $logger, + ); + } + + protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface + { + return $this->form; + } + + protected function addFlash(string $type, mixed $message): void + { + $this->flashes[] = [$type, $message]; + } + + protected function render(string $view, array $parameters = [], ?Response $response = null): Response + { + return new Response('rendered:'.$view); + } + + protected function redirectToRoute(string $route, array $parameters = [], int $status = 302): RedirectResponse + { + return new RedirectResponse($route, $status); + } + + protected function generateUrl(string $route, array $parameters = [], int $referenceType = 0): string + { + return $route; + } + + protected function getUser(): ?\Symfony\Component\Security\Core\User\UserInterface + { + return null; + } +} diff --git a/tests/Form/Model/BookingDtoTest.php b/tests/Form/Model/BookingDtoTest.php index 9eeb495a..be862f8d 100644 --- a/tests/Form/Model/BookingDtoTest.php +++ b/tests/Form/Model/BookingDtoTest.php @@ -4,10 +4,12 @@ declare(strict_types=1); namespace App\Tests\Form\Model; +use App\BusProNet\Model\Room; use App\BusProNet\Model\Travel; use App\BusProNet\Model\Address; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; +use App\Form\Model\RoomSelectionDto; use PHPUnit\Framework\TestCase; /** @@ -89,6 +91,165 @@ class BookingDtoTest extends TestCase $this->assertSame('43', $restored->participants[0]->shoeSize); } + public function testSyncRoomSelectionsWithAvailableRoomsClearsMissingOrOverbookedRooms(): void + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); + $travel->dateTo = new \DateTimeImmutable('2025-06-08'); + + $availableRoom = new Room(); + $availableRoom->id = 1; + $availableRoom->label = 'Doppelzimmer'; + $availableRoom->price = 100.0; + $availableRoom->available = 2; + $availableRoom->minPax = 2; + $availableRoom->status = 'Frei'; + + $limitedRoom = new Room(); + $limitedRoom->id = 2; + $limitedRoom->label = 'Einzelzimmer'; + $limitedRoom->price = 150.0; + $limitedRoom->available = 1; + $limitedRoom->minPax = 1; + $limitedRoom->status = 'Frei'; + + $travel->rooms = [$availableRoom, $limitedRoom]; + + $dto = new BookingDto($travel, 1); + + $selection1 = new RoomSelectionDto(); + $selection1->id = 1; + $selection1->quantity = 2; + + $selection2 = new RoomSelectionDto(); + $selection2->id = 2; + $selection2->quantity = 2; + + $selection3 = new RoomSelectionDto(); + $selection3->id = 3; + $selection3->quantity = 1; + + $dto->roomSelections = [ + 1 => $selection1, + 2 => $selection2, + 3 => $selection3, + ]; + + $changed = $dto->syncRoomSelectionsWithAvailableRooms(); + + $this->assertTrue($changed); + $this->assertCount(2, $dto->roomSelections); + $this->assertArrayHasKey(1, $dto->roomSelections); + $this->assertArrayHasKey(2, $dto->roomSelections); + $this->assertArrayNotHasKey(3, $dto->roomSelections); + $this->assertSame(1, $dto->roomSelections[1]->id); + $this->assertSame(2, $dto->roomSelections[1]->quantity); + $this->assertSame('Doppelzimmer', $dto->roomSelections[1]->label); + $this->assertSame(100.0, $dto->roomSelections[1]->price); + $this->assertSame(2, $dto->roomSelections[1]->maxQuantity); + $this->assertSame(2, $dto->roomSelections[1]->capacity); + $this->assertSame(2, $dto->roomSelections[2]->id); + $this->assertSame(0, $dto->roomSelections[2]->quantity); + } + + public function testSyncRoomSelectionsWithAvailableRoomsSynchronizesZeroQuantityRoomMetadata(): void + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); + $travel->dateTo = new \DateTimeImmutable('2025-06-08'); + + $room = new Room(); + $room->id = 1; + $room->label = 'Aktualisiertes Zimmer'; + $room->price = 125.0; + $room->available = 1; + $room->minPax = 2; + $room->status = 'Anfrage'; + + $travel->rooms = [$room]; + + $dto = new BookingDto($travel, 1); + + $selection = new RoomSelectionDto(); + $selection->id = 1; + $selection->label = 'Veraltetes Zimmer'; + $selection->price = 100.0; + $selection->status = 'Frei'; + $selection->maxQuantity = 5; + $selection->capacity = 1; + $selection->quantity = 0; + + $dto->roomSelections = [1 => $selection]; + + $changed = $dto->syncRoomSelectionsWithAvailableRooms(); + + $this->assertFalse($changed); + $this->assertCount(1, $dto->roomSelections); + $this->assertArrayHasKey(1, $dto->roomSelections); + $this->assertSame('Aktualisiertes Zimmer', $dto->roomSelections[1]->label); + $this->assertSame(125.0, $dto->roomSelections[1]->price); + $this->assertSame('Anfrage', $dto->roomSelections[1]->status); + $this->assertSame(1, $dto->roomSelections[1]->maxQuantity); + $this->assertSame(2, $dto->roomSelections[1]->capacity); + $this->assertSame(0, $dto->roomSelections[1]->quantity); + } + + public function testSyncRoomSelectionsWithAvailableRoomsAddsNewAvailableRooms(): void + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); + $travel->dateTo = new \DateTimeImmutable('2025-06-08'); + + $room = new Room(); + $room->id = 5; + $room->label = 'Familienzimmer'; + $room->price = 250.0; + $room->available = 3; + $room->minPax = 4; + $room->status = 'Frei'; + + $travel->rooms = [$room]; + + $dto = new BookingDto($travel, 1); + + $changed = $dto->syncRoomSelectionsWithAvailableRooms(); + + $this->assertFalse($changed); + $this->assertArrayHasKey(5, $dto->roomSelections); + $this->assertSame(5, $dto->roomSelections[5]->id); + $this->assertSame('Familienzimmer', $dto->roomSelections[5]->label); + $this->assertSame(250.0, $dto->roomSelections[5]->price); + $this->assertSame('Frei', $dto->roomSelections[5]->status); + $this->assertSame(0, $dto->roomSelections[5]->quantity); + $this->assertSame(3, $dto->roomSelections[5]->maxQuantity); + $this->assertSame(4, $dto->roomSelections[5]->capacity); + } + + public function testCreateRoomSelectionSnapshotDetectsPriceOnlyChange(): void + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); + $travel->dateTo = new \DateTimeImmutable('2025-06-08'); + + $dto = new BookingDto($travel, 1); + + $selection = new RoomSelectionDto(); + $selection->id = 1; + $selection->quantity = 2; + $selection->price = 100.0; + $dto->roomSelections = [$selection]; + + $before = $dto->createRoomSelectionSnapshot(); + + $selection->price = 120.0; + $after = $dto->createRoomSelectionSnapshot(); + + $this->assertSame([[1, 2, 100.0]], $before); + $this->assertSame([[1, 2, 120.0]], $after); + $this->assertNotSame($before, $after); + $this->assertTrue($dto->hasRoomSelectionChanged($before)); + } + public function testUnserializeNormalizesLegacyParticipantStringsOnLoad(): void { $travel = new Travel(); diff --git a/tests/Service/BookingConfiguratorStatusTest.php b/tests/Service/BookingConfiguratorStatusTest.php index c3128202..914cc981 100644 --- a/tests/Service/BookingConfiguratorStatusTest.php +++ b/tests/Service/BookingConfiguratorStatusTest.php @@ -158,6 +158,34 @@ class BookingConfiguratorStatusTest extends TestCase $this->bookingService->startFreshBooking($request, 123, 456); } + public function testStartFreshBookingRejectsRoomsThatGoUnavailableDuringLiveRefresh(): void + { + $travel = $this->createTravelWithRooms([ + ['status' => Constants::STATUS_AVAILABLE, 'available' => 1], + ]); + + $this->travelDataService + ->expects($this->once()) + ->method('getTravelData') + ->willReturn($travel); + + $this->travelDataService + ->expects($this->once()) + ->method('enrichWithFreshAvailabilities') + ->with($travel, true, true, true) + ->willReturnCallback(static function (Travel $travel): bool { + $travel->rooms[1]->available = 0; + + return true; + }); + + $request = $this->createRequestWithSession(); + + $this->expectException(NoRoomsAvailableException::class); + + $this->bookingService->startFreshBooking($request, 123, 456); + } + public function testStartFreshBookingWithApiRestrictionsForcesInquiry(): void { // Even with Frei rooms, if API says only 'A' allowed, booking status should be 'A' diff --git a/tests/Service/BookingSessionManagerTest.php b/tests/Service/BookingSessionManagerTest.php index 03b88e84..43bc64e5 100644 --- a/tests/Service/BookingSessionManagerTest.php +++ b/tests/Service/BookingSessionManagerTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Tests\Service; use App\BusProNet\Model\Booking; +use App\BusProNet\Model\Room; use App\BusProNet\Model\Travel; use App\Exception\BookingSessionNotFoundException; use App\Form\Model\BookingDto; @@ -52,6 +53,12 @@ class BookingSessionManagerTest extends TestCase $hydratedTravel->id = 12; $hydratedTravel->hotelId = 34; $hydratedTravel->label = 'Hydrated travel'; + $hydratedRoom = new Room(); + $hydratedRoom->id = 7; + $hydratedRoom->available = 0; + $hydratedRoom->status = 'Frei'; + $hydratedTravel->dateTo = new \DateTimeImmutable('2030-01-06'); + $hydratedTravel->rooms = [$hydratedRoom]; $this->travelDataService ->expects($this->once()) @@ -59,12 +66,99 @@ class BookingSessionManagerTest extends TestCase ->with(12, 34) ->willReturn($hydratedTravel); + $this->travelDataService + ->expects($this->once()) + ->method('enrichWithFreshAvailabilities') + ->with($hydratedTravel, true, false, true) + ->willReturnCallback(static function (Travel $travel): bool { + $travel->rooms[0]->available = 0; + + return true; + }); + + $this->service->markCreateAvailabilityRefreshed($request); $this->service->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); $loadedDto = $this->service->getBookingDto($request, BookingDto::MODE_CREATE); $this->assertNotNull($loadedDto); $this->assertSame($hydratedTravel, $loadedDto?->travel); $this->assertSame($hydratedTravel, $loadedDto?->booking?->travelData); + $this->assertSame(0, $loadedDto?->travel->rooms[0]->available); + } + + public function testSaveAndLoadBookingDtoForcesRefreshWhenCreateAvailabilityIsStale(): void + { + $request = $this->createRequestWithSession(); + + $storedTravel = new Travel(); + $storedTravel->id = 12; + $storedTravel->hotelId = 34; + + $bookingDto = new BookingDto($storedTravel, 34); + + $hydratedTravel = new Travel(); + $hydratedTravel->id = 12; + $hydratedTravel->hotelId = 34; + $hydratedTravel->dateTo = new \DateTimeImmutable('2030-01-06'); + + $request->getSession()->set( + BookingSessionManager::BOOKING_CREATE_AVAILABILITY_REFRESHED_AT_KEY, + time() - 601 + ); + + $this->travelDataService + ->expects($this->once()) + ->method('getTravelData') + ->with(12, 34) + ->willReturn($hydratedTravel); + + $this->travelDataService + ->expects($this->once()) + ->method('enrichWithFreshAvailabilities') + ->with($hydratedTravel, true, true, true) + ->willReturn(true); + + $this->service->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); + $loadedDto = $this->service->getBookingDto($request, BookingDto::MODE_CREATE); + + $this->assertNotNull($loadedDto); + $this->assertSame($hydratedTravel, $loadedDto?->travel); + } + + public function testRefreshCreateAvailabilityForcesLiveRefreshAndResetsUnavailableSelections(): void + { + $request = $this->createRequestWithSession(); + + $travel = new Travel(); + $travel->id = 12; + $travel->hotelId = 34; + + $room = new Room(); + $room->id = 7; + $room->available = 1; + $room->status = 'Frei'; + $travel->rooms = [$room]; + + $bookingDto = new BookingDto($travel, 34); + $selection = new RoomSelectionDto(); + $selection->id = 7; + $selection->quantity = 2; + $bookingDto->roomSelections = [7 => $selection]; + + $this->travelDataService + ->expects($this->once()) + ->method('enrichWithFreshAvailabilities') + ->with($travel, true, true, true) + ->willReturn(true); + + $result = $this->service->refreshCreateAvailability($request, $bookingDto, true); + + $this->assertTrue($result->availabilityRefreshed); + $this->assertTrue($result->roomSelectionQuantityReset); + $this->assertTrue($result->roomSelectionSnapshotChanged); + $this->assertTrue($result->hasAvailableRooms); + $this->assertSame(0, $bookingDto->roomSelections[7]->quantity); + $this->assertSame($bookingDto, $request->getSession()->get(BookingSessionManager::BOOKING_CREATE_KEY)); } public function testBaselineSnapshotIsCreatedAndReused(): void @@ -77,13 +171,31 @@ class BookingSessionManagerTest extends TestCase $bookingDto->roomSelections = [$selection]; $first = $this->service->getOrCreateBaselineSnapshot($request, $bookingDto); - $this->assertSame([[1, 2]], $first); + $this->assertSame([[1, 2, 0.0]], $first); $selection->quantity = 5; $second = $this->service->getOrCreateBaselineSnapshot($request, $bookingDto); $this->assertSame($first, $second); - $this->assertSame([[1, 2]], $second); + $this->assertSame([[1, 2, 0.0]], $second); + } + + public function testSaveBaselineSnapshotReplacesExistingSnapshot(): void + { + $request = $this->createRequestWithSession(); + $bookingDto = new BookingDto(new Travel(), 99); + $selection = new RoomSelectionDto(); + $selection->id = 1; + $selection->quantity = 2; + $bookingDto->roomSelections = [$selection]; + + $this->service->saveBaselineSnapshot($request, $bookingDto); + + $selection->quantity = 4; + $updated = $this->service->saveBaselineSnapshot($request, $bookingDto); + + $this->assertSame([[1, 4, 0.0]], $updated); + $this->assertSame([[1, 4, 0.0]], $request->getSession()->get(BookingSessionManager::BOOKING_CREATE_BASELINE_KEY)); } public function testStoreReturnUrlFallsBackToDefaultForInvalidInput(): void diff --git a/tests/Service/TravelDataProviderTest.php b/tests/Service/TravelDataProviderTest.php index 91cc4d57..62b6fdd7 100644 --- a/tests/Service/TravelDataProviderTest.php +++ b/tests/Service/TravelDataProviderTest.php @@ -5,6 +5,9 @@ declare(strict_types=1); namespace App\Tests\Service; use App\BusProNet\ApiClient; +use App\BusProNet\Model\BaseData; +use App\BusProNet\Model\Room; +use App\BusProNet\Model\ServiceAvailabilityResponse; use App\BusProNet\Model\Travel; use App\BusProNet\XmlLoader\TravelLoader; use App\Exception\TravelNotFoundException; @@ -440,4 +443,222 @@ class TravelDataProviderTest extends TestCase $this->assertSame($travel, $result); } + + public function testEnrichWithFreshAvailabilitiesRefreshesRoomInventory(): void + { + $dateId = 12345; + $hotelId = 67890; + + $travel = new Travel(); + $travel->id = $dateId; + $travel->hotelId = $hotelId; + $travel->dateTo = new \DateTimeImmutable('2030-01-06'); + + $room1 = new Room(); + $room1->id = 1; + $room1->label = 'Doppelzimmer'; + $room1->available = 2; + $room1->status = 'Frei'; + + $room2 = new Room(); + $room2->id = 2; + $room2->label = 'Familienzimmer'; + $room2->available = 1; + $room2->status = 'Frei'; + + $travel->rooms = [$room1, $room2]; + + $liveRoom = new Room(); + $liveRoom->id = 1; + $liveRoom->available = 0; + $liveRoom->status = 'Frei'; + $liveRoom->price = 199.5; + + $liveRoom2 = new Room(); + $liveRoom2->id = 2; + $liveRoom2->available = 1; + $liveRoom2->status = 'Frei'; + $liveRoom2->price = 249.0; + + $this->apiClient + ->expects($this->once()) + ->method('getAvailabilities') + ->with($dateId) + ->willReturn(new ServiceAvailabilityResponse([])); + + $this->apiClient + ->expects($this->once()) + ->method('getHotelAvailability') + ->with($dateId, $hotelId, $travel->dateTo) + ->willReturn(new BaseData([ + 1 => $liveRoom, + 2 => $liveRoom2, + ])); + + $this->travelLoader + ->expects($this->once()) + ->method('patchRoomAvailabilities') + ->with($travel, $this->isInstanceOf(BaseData::class)) + ->willReturnCallback(static function (Travel $travel, BaseData $roomAvailabilities): void { + $liveRooms = $roomAvailabilities->getItems(); + + foreach ($travel->rooms as $room) { + $liveRoom = $liveRooms[$room->id] ?? null; + if (!($liveRoom instanceof Room)) { + continue; + } + + $room->available = $liveRoom->available; + $room->price = $liveRoom->price; + } + }); + + $this->service->enrichWithFreshAvailabilities($travel, false, refreshRooms: true); + + $this->assertSame(0, $travel->rooms[0]->available); + $this->assertSame(199.5, $travel->rooms[0]->price); + $this->assertSame(1, $travel->rooms[1]->available); + $this->assertSame(249.0, $travel->rooms[1]->price); + $this->assertSame([2 => $travel->rooms[1]], $travel->getAvailableRooms()); + } + + public function testPatchRoomAvailabilitiesLogsMissingRoomIdsAsContractIssue(): void + { + $travel = new Travel(); + $travel->id = 12345; + $travel->hotelId = 67890; + + $room1 = new Room(); + $room1->id = 1; + $room1->available = 2; + $room1->status = 'Frei'; + + $room2 = new Room(); + $room2->id = 2; + $room2->available = 1; + $room2->status = 'Frei'; + + $travel->rooms = [$room1, $room2]; + + $liveRoom = new Room(); + $liveRoom->id = 1; + $liveRoom->available = 0; + $liveRoom->status = 'Frei'; + + $this->logger + ->expects($this->once()) + ->method('warning') + ->with( + 'Hotel availability response is missing room IDs from the travel snapshot', + $this->callback(static function (array $context): bool { + return [2] === $context['missingRoomIds'] + && 12345 === $context['travelId'] + && 67890 === $context['hotelId']; + }) + ); + + $this->travelLoader + ->expects($this->once()) + ->method('patchRoomAvailabilities') + ->with($travel, $this->isInstanceOf(BaseData::class)); + + $this->service->patchRoomAvailabilities($travel, new BaseData([ + 1 => $liveRoom, + ])); + } + + public function testEnrichWithFreshAvailabilitiesDoesNotForceRefreshHotelAvailabilityByDefault(): void + { + $travel = new Travel(); + $travel->id = 12345; + $travel->hotelId = 67890; + $travel->dateTo = new \DateTimeImmutable('2030-01-06'); + + $this->cache + ->expects($this->never()) + ->method('delete'); + + $this->cache + ->expects($this->exactly(2)) + ->method('get') + ->willReturnCallback(static fn (string $key, callable $callback) => $callback(new \Symfony\Component\Cache\CacheItem())); + + $this->apiClient + ->expects($this->once()) + ->method('getAvailabilities') + ->with($travel->id) + ->willReturn(new ServiceAvailabilityResponse([])); + + $this->apiClient + ->expects($this->once()) + ->method('getHotelAvailability') + ->with($travel->id, $travel->hotelId, $travel->dateTo) + ->willReturn(new BaseData([])); + + $this->service->enrichWithFreshAvailabilities($travel, refreshRooms: true); + } + + public function testEnrichWithFreshAvailabilitiesDoesNotRefreshRoomsByDefault(): void + { + $travel = new Travel(); + $travel->id = 12345; + $travel->hotelId = 67890; + $travel->dateTo = new \DateTimeImmutable('2030-01-06'); + + $this->cache + ->expects($this->once()) + ->method('get') + ->willReturnCallback(static fn (string $key, callable $callback) => $callback(new \Symfony\Component\Cache\CacheItem())); + + $this->apiClient + ->expects($this->once()) + ->method('getAvailabilities') + ->with($travel->id) + ->willReturn(new ServiceAvailabilityResponse([])); + + $this->apiClient + ->expects($this->never()) + ->method('getHotelAvailability'); + + $this->travelLoader + ->expects($this->never()) + ->method('patchRoomAvailabilities'); + + $this->service->enrichWithFreshAvailabilities($travel); + } + + public function testEnrichWithFreshAvailabilitiesForceRefreshInvalidatesHotelAvailabilityCache(): void + { + $travel = new Travel(); + $travel->id = 12345; + $travel->hotelId = 67890; + $travel->dateTo = new \DateTimeImmutable('2030-01-06'); + + $this->cache + ->expects($this->exactly(2)) + ->method('delete') + ->with($this->logicalOr( + $this->equalTo('availability_12345'), + $this->equalTo('hotel_availability_12345_67890_20300106') + )); + + $this->cache + ->expects($this->exactly(2)) + ->method('get') + ->willReturnCallback(static fn (string $key, callable $callback) => $callback(new \Symfony\Component\Cache\CacheItem())); + + $this->apiClient + ->expects($this->once()) + ->method('getAvailabilities') + ->with($travel->id) + ->willReturn(new ServiceAvailabilityResponse([])); + + $this->apiClient + ->expects($this->once()) + ->method('getHotelAvailability') + ->with($travel->id, $travel->hotelId, $travel->dateTo) + ->willReturn(new BaseData([])); + + $this->service->enrichWithFreshAvailabilities($travel, true, true, refreshRooms: true); + } }