fix: refresh live room availability during create flow
This commit is contained in:
1 parent
a73e8a593f
commit
cdce7d57e0
26 files changed
+1976
-54
No files matched your search
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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')
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<int, array{int, int}> Array of [roomId, quantity] pairs
|
||||
* @return array<int, array{int, int, float}> 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<int, array{int, int}> $oldSnapshot The baseline room selection snapshot
|
||||
* @param array<int, array{int, int, float}> $oldSnapshot The baseline room selection snapshot
|
||||
*
|
||||
* @return bool True if room selections have changed, false otherwise
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
final readonly class BookingAvailabilityRefreshResult
|
||||
{
|
||||
public function __construct(
|
||||
public bool $availabilityRefreshed,
|
||||
public bool $roomSelectionQuantityReset,
|
||||
public bool $roomSelectionSnapshotChanged,
|
||||
public bool $hasAvailableRooms,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -51,11 +51,10 @@ class BookingConfigurator
|
||||
throw new NotFoundHttpException(sprintf('Travel data not found for date ID %d and hotel ID %d', $dateId, $hotelId));
|
||||
}
|
||||
|
||||
// Fetch and patch availability data (includes allowedBookingStatus from API)
|
||||
// Force refresh ensures fresh data at booking start, then populates cache for subsequent loads
|
||||
$availabilities = $this->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)
|
||||
|
||||
@@ -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<int, array{int, int}>
|
||||
* @return array<int, array{int, int, float}>
|
||||
*/
|
||||
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<int, array{int, int, float}>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\BusProNet\XmlLoader;
|
||||
|
||||
use App\BusProNet\Model\BaseData;
|
||||
use App\BusProNet\Model\Room;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\XmlLoader\HotelLoader;
|
||||
use App\BusProNet\XmlLoader\TravelLoader;
|
||||
use App\BusProNet\XmlParser\TravelParser;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
|
||||
class TravelLoaderTest extends TestCase
|
||||
{
|
||||
public function testPatchRoomAvailabilitiesAppliesLiveRoomDataToMatchingRooms(): void
|
||||
{
|
||||
$loader = $this->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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\BusProNet\XmlParser;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\Room;
|
||||
use App\BusProNet\XmlParser\AvailabilitiesParser;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
|
||||
class AvailabilitiesParserTest extends TestCase
|
||||
{
|
||||
private AvailabilitiesParser $parser;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->parser = new AvailabilitiesParser();
|
||||
}
|
||||
|
||||
public function testParseRoomsNormalizesHotelAvailabilityStatuses(): void
|
||||
{
|
||||
$xml = <<<'XML'
|
||||
<ergebnis>
|
||||
<unterbringungen>
|
||||
<unterbringung id_zimmer="1" bezeichnung="Room 1" kategorie="A" verpflegung="Board" frei="1" status="buchbar" preis="749,00" />
|
||||
<unterbringung id_zimmer="2" bezeichnung="Room 2" kategorie="B" verpflegung="Board" frei="-900" status="anfrage" preis="849,00" />
|
||||
<unterbringung id_zimmer="3" bezeichnung="Room 3" kategorie="C" verpflegung="Board" frei="0" status="stopp" preis="949,00" />
|
||||
</unterbringungen>
|
||||
</ergebnis>
|
||||
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'
|
||||
<ergebnis>
|
||||
<unterbringungen>
|
||||
<unterbringung id_zimmer="1" bezeichnung="Room 1" kategorie="A" verpflegung="Board" frei="1" status="foo" preis="749,00" />
|
||||
</unterbringungen>
|
||||
</ergebnis>
|
||||
XML;
|
||||
|
||||
$rooms = $parser->parseRooms(new Crawler($xml))->getItems();
|
||||
|
||||
$this->assertSame('foo', $rooms[1]->status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Api;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Model\BaseData;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\BusProNet\Model\Room;
|
||||
use App\Controller\Api\TravelController;
|
||||
use App\Service\TravelDataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\DependencyInjection\Container;
|
||||
use Symfony\Component\Serializer\Encoder\JsonEncoder;
|
||||
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory;
|
||||
use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader;
|
||||
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
|
||||
use Symfony\Component\Serializer\Serializer;
|
||||
|
||||
class TravelControllerTest extends TestCase
|
||||
{
|
||||
public function testHotelAvailabilityOnlySerializesApiGroupFields(): void
|
||||
{
|
||||
$room = new Room();
|
||||
$room->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Booking\Create;
|
||||
|
||||
use App\BusProNet\Model\Room;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Controller\Booking\Create\Step1Controller;
|
||||
use App\Form\Model\BookingCreateContext;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\BookingSummaryDto;
|
||||
use App\Form\Model\RoomGroupsDto;
|
||||
use App\Form\Model\RoomSelectionDto;
|
||||
use App\Service\BookingAvailabilityRefreshResult;
|
||||
use App\Service\BookingConfigurator;
|
||||
use App\Service\BookingCreateContextFactory;
|
||||
use App\Service\BookingSessionManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
|
||||
class Step1ControllerTest extends TestCase
|
||||
{
|
||||
public function testIndexRefreshesBaselineAfterAutoPruningUnavailableRooms(): void
|
||||
{
|
||||
$request = Request::create('/bookings/create/rooms', 'GET');
|
||||
$bookingDto = $this->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<int, array{string, mixed}>
|
||||
*/
|
||||
public array $flashes = [];
|
||||
|
||||
/**
|
||||
* @param FormInterface<mixed> $form
|
||||
*/
|
||||
public function __construct(
|
||||
BookingConfigurator $bookingService,
|
||||
BookingSessionManager $bookingSessionService,
|
||||
BookingCreateContextFactory $createContextFactory,
|
||||
private readonly FormInterface $form,
|
||||
) {
|
||||
parent::__construct($bookingService, $bookingSessionService, $createContextFactory);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return FormInterface<mixed>
|
||||
*/
|
||||
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<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Booking\Create;
|
||||
|
||||
use App\BusProNet\Model\Room;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Controller\Booking\Create\Step2ParticipantController;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Form\Model\RoomSelectionDto;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Service\BookingAvailabilityRefreshResult;
|
||||
use App\Service\BookingConfigurator;
|
||||
use App\Service\BookingCreateContextFactory;
|
||||
use App\Service\BookingSessionManager;
|
||||
use App\Service\ParticipantDataPrefiller;
|
||||
use App\Service\ParticipantFormSupport;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class Step2ParticipantControllerTest extends TestCase
|
||||
{
|
||||
public function testEditParticipantPersistsBookingStatusOnPlainGetRequest(): void
|
||||
{
|
||||
$request = Request::create('/bookings/create/participants/0', 'GET');
|
||||
$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: 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<int, array{string, mixed}>
|
||||
*/
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Booking\Create;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Model\Room;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Controller\Booking\Create\Step4Controller;
|
||||
use App\Form\Model\BookingCreateContext;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\BookingSummaryDto;
|
||||
use App\Form\Model\RoomGroupsDto;
|
||||
use App\Form\Model\RoomSelectionDto;
|
||||
use App\Service\BookingAvailabilityRefreshResult;
|
||||
use App\Service\BookingConfigurator;
|
||||
use App\Service\BookingCreateContextFactory;
|
||||
use App\Service\BookingSessionManager;
|
||||
use App\Service\NewsletterManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
|
||||
class Step4ControllerTest extends TestCase
|
||||
{
|
||||
public function testIndexRerendersWithErrorWhenForcedRefreshFails(): 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: 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<int, array{string, mixed}>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user