fix: resync room selections with travel data before step 2
This commit is contained in:
1 parent
be0e15b3d4
commit
d0e7ba017c
5 files changed
+708
-1
No files matched your search
@@ -13,6 +13,7 @@ use App\Form\BookingCreateStep2Type;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Form\Model\RoomSelectionDto;
|
||||
use App\Model\RoomSelectionAdjustment;
|
||||
use App\Service\BookingConfigurator;
|
||||
use App\Service\BookingCreateContextFactory;
|
||||
use App\Service\BookingSessionManager;
|
||||
@@ -66,6 +67,12 @@ class Step2Controller extends AbstractBookingCreateController
|
||||
// Enrich with fresh availability data
|
||||
$this->travelDataService->enrichWithFreshAvailabilities($bookingCreateDto->travel);
|
||||
|
||||
// Resync the room selections with the refreshed travel before any participant count is derived
|
||||
// from them, and send the customer back to step 1 when a room they picked is gone.
|
||||
if (null !== $redirect = $this->guardAgainstUnavailableRoomSelection($request, $bookingCreateDto)) {
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
// Ensure correct number of participants first, then prepopulate the applicant if needed.
|
||||
if (null !== $redirect = $this->guardAgainstEmptyParticipantList($bookingCreateDto)) {
|
||||
return $redirect;
|
||||
@@ -120,6 +127,79 @@ class Step2Controller extends AbstractBookingCreateController
|
||||
return $this->render('booking/create/step_2.html.twig', $templateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the customer back to step 1 when the refreshed travel data no longer covers their rooms.
|
||||
*
|
||||
* Deriving the participant count from a room that has disappeared would silently fall back to a
|
||||
* capacity of one and render too few participants, and a quantity the contingent no longer covers
|
||||
* reaches the BusPro submit unchecked, because no later step re-reads room availability. The
|
||||
* selections are resynced and saved before every exit, so step 1 and the neighbouring guard both
|
||||
* see a collection that matches the rooms the template renders: leaving a stale entry behind would
|
||||
* fail step 1's own validation on a field it never shows.
|
||||
*/
|
||||
private function guardAgainstUnavailableRoomSelection(Request $request, BookingDto $bookingCreateDto): ?RedirectResponse
|
||||
{
|
||||
$adjustments = $this->bookingService->reconcileRoomSelections($bookingCreateDto);
|
||||
|
||||
$this->bookingSessionService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE);
|
||||
|
||||
if ([] === $bookingCreateDto->travel->getAvailableRooms()) {
|
||||
$this->handleApiError(
|
||||
$this->logger,
|
||||
'Travel has no bookable rooms left while a booking is in progress.',
|
||||
$this->createRoomContext($bookingCreateDto, $adjustments),
|
||||
'Für diese Reise sind leider keine Zimmer mehr verfügbar.'
|
||||
);
|
||||
|
||||
return $this->redirectToRoute('app_booking_create_error');
|
||||
}
|
||||
|
||||
if ([] === $adjustments) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hasDroppedRoom = false;
|
||||
foreach ($adjustments as $adjustment) {
|
||||
$hasDroppedRoom = $hasDroppedRoom || $adjustment->isDropped();
|
||||
}
|
||||
|
||||
$this->handleApiError(
|
||||
$this->logger,
|
||||
'Room selection exceeds what the current travel data still offers.',
|
||||
$this->createRoomContext($bookingCreateDto, $adjustments),
|
||||
true === $hasDroppedRoom
|
||||
? 'Dein gewähltes Zimmer ist leider nicht mehr verfügbar. Bitte wähle dein Zimmer erneut.'
|
||||
: 'Für dein gewähltes Zimmer sind nicht mehr genügend Plätze verfügbar. Wir haben deine Auswahl angepasst, bitte überprüfe sie.'
|
||||
);
|
||||
|
||||
return $this->redirectToRoute('app_booking_create_step_1');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<RoomSelectionAdjustment> $adjustments
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function createRoomContext(BookingDto $bookingCreateDto, array $adjustments): array
|
||||
{
|
||||
$rooms = [];
|
||||
foreach ($adjustments as $adjustment) {
|
||||
$rooms[] = [
|
||||
'roomId' => $adjustment->roomId,
|
||||
'label' => $adjustment->label,
|
||||
'requestedQuantity' => $adjustment->requestedQuantity,
|
||||
'bookableQuantity' => $adjustment->bookableQuantity,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'travelId' => $bookingCreateDto->travel->id,
|
||||
'travelCode' => $bookingCreateDto->travel->code,
|
||||
'hotelId' => $bookingCreateDto->hotelId,
|
||||
'rooms' => $rooms,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a room selection that resolves to no participants at all.
|
||||
*
|
||||
@@ -190,7 +270,14 @@ class Step2Controller extends AbstractBookingCreateController
|
||||
$rooms = $travelData->getAvailableRooms();
|
||||
|
||||
foreach ($roomSelections as $roomSelection) {
|
||||
$room = $rooms[$roomSelection->id];
|
||||
$room = $rooms[$roomSelection->id] ?? null;
|
||||
|
||||
if (null === $room) {
|
||||
// Unreachable from index(), which reconciles the selections first. Kept so a future
|
||||
// caller cannot silently fall back to a capacity of one and book too few people.
|
||||
continue;
|
||||
}
|
||||
|
||||
$participantsCount += ($room->minPax ?? 1) * $roomSelection->quantity;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
/**
|
||||
* A room selection the current travel data no longer supports in full.
|
||||
*
|
||||
* A room that disappeared altogether is the same case with nothing left to book, so it is reported
|
||||
* as an adjustment down to zero rather than as a second kind of result.
|
||||
*/
|
||||
final readonly class RoomSelectionAdjustment
|
||||
{
|
||||
public function __construct(
|
||||
public int $roomId,
|
||||
public ?string $label,
|
||||
public int $requestedQuantity,
|
||||
public int $bookableQuantity,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the room is gone entirely rather than merely reduced.
|
||||
*/
|
||||
public function isDropped(): bool
|
||||
{
|
||||
return 0 === $this->bookableQuantity;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Form\Model\RoomSelectionDto;
|
||||
use App\Form\Service\ServiceAgeEvaluator;
|
||||
use App\Model\RoomSelectionAdjustment;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
@@ -91,6 +92,54 @@ class BookingConfigurator
|
||||
return $bookingCreateDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings the room selections back in line with the rooms the travel currently offers.
|
||||
*
|
||||
* Room selections are built once when the booking session starts, while the Travel is re-fetched
|
||||
* from BusPro on every request. A room that sells out or leaves a bookable status in between drops
|
||||
* out of getAvailableRooms() and leaves a selection behind that points at a room that no longer
|
||||
* exists; one that only loses part of its contingent leaves a quantity nothing else re-checks
|
||||
* before the booking is submitted; and one that becomes bookable again has no selection at all.
|
||||
* Rebuilding the selections from the current rooms, with the chosen quantities carried over and
|
||||
* capped at what is left, keeps both sides in step.
|
||||
*
|
||||
* @return list<RoomSelectionAdjustment> The selections that had to give up quantity, in selection order
|
||||
*/
|
||||
public function reconcileRoomSelections(BookingDto $bookingDto): array
|
||||
{
|
||||
$availableRooms = $bookingDto->travel->getAvailableRooms();
|
||||
|
||||
$adjustments = [];
|
||||
$quantitiesByRoomId = [];
|
||||
|
||||
foreach ($bookingDto->roomSelections as $roomSelection) {
|
||||
if (null === $roomSelection->id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$requestedQuantity = $roomSelection->quantity ?? 0;
|
||||
$bookableQuantity = min($requestedQuantity, $availableRooms[$roomSelection->id]->available ?? 0);
|
||||
|
||||
$quantitiesByRoomId[$roomSelection->id] = $bookableQuantity;
|
||||
|
||||
if (0 < $requestedQuantity && $bookableQuantity < $requestedQuantity) {
|
||||
$adjustments[] = new RoomSelectionAdjustment(
|
||||
$roomSelection->id,
|
||||
$roomSelection->label,
|
||||
$requestedQuantity,
|
||||
$bookableQuantity
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$bookingDto->roomSelections = array_map(
|
||||
fn (Room $room) => $this->createRoomSelection($room, $quantitiesByRoomId),
|
||||
$availableRooms
|
||||
);
|
||||
|
||||
return $adjustments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a room selection DTO from room data and quantities.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Booking\Create;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\Room;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Controller\Booking\Create\Step2Controller;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\RoomSelectionDto;
|
||||
use App\Model\RoomSelectionAdjustment;
|
||||
use App\Service\BookingConfigurator;
|
||||
use App\Service\BookingCreateContextFactory;
|
||||
use App\Service\BookingSessionManager;
|
||||
use App\Service\ParticipantDataPrefiller;
|
||||
use App\Service\RoomAssigner;
|
||||
use App\Service\TravelDataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
class Step2ControllerTest extends TestCase
|
||||
{
|
||||
public function testSendsCustomerBackToStep1WhenASelectedRoomIsNoLongerBookable(): void
|
||||
{
|
||||
$request = Request::create('/bookings/create/participants', 'GET');
|
||||
$travel = $this->createTravel([$this->createRoom(1409, 'Einzelzimmer', minPax: 1)]);
|
||||
$bookingDto = $this->createBookingDto($travel);
|
||||
|
||||
$bookingService = $this->createMock(BookingConfigurator::class);
|
||||
$bookingService->expects($this->once())
|
||||
->method('reconcileRoomSelections')
|
||||
->with($bookingDto)
|
||||
->willReturn([new RoomSelectionAdjustment(1408, 'Doppelzimmer', 2, 0)]);
|
||||
$bookingService->expects($this->never())->method('preselectDefaultServices');
|
||||
|
||||
$bookingSessionService = $this->createMock(BookingSessionManager::class);
|
||||
$bookingSessionService->method('getOrCreateBookingCreateDto')->willReturn($bookingDto);
|
||||
$bookingSessionService->expects($this->once())
|
||||
->method('saveBookingDto')
|
||||
->with($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$logger->expects($this->once())
|
||||
->method('error')
|
||||
->with(
|
||||
'Room selection exceeds what the current travel data still offers.',
|
||||
[
|
||||
'travelId' => 4711,
|
||||
'travelCode' => 'TRV-4711',
|
||||
'hotelId' => 1,
|
||||
'rooms' => [[
|
||||
'roomId' => 1408,
|
||||
'label' => 'Doppelzimmer',
|
||||
'requestedQuantity' => 2,
|
||||
'bookableQuantity' => 0,
|
||||
]],
|
||||
]
|
||||
);
|
||||
|
||||
$controller = $this->createController($bookingService, $bookingSessionService, $logger);
|
||||
|
||||
$response = $controller->index($request);
|
||||
|
||||
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||
$this->assertSame('/app_booking_create_step_1', $response->getTargetUrl());
|
||||
$this->assertSame(
|
||||
[['error', 'Dein gewähltes Zimmer ist leider nicht mehr verfügbar. Bitte wähle dein Zimmer erneut.']],
|
||||
$controller->flashes
|
||||
);
|
||||
}
|
||||
|
||||
public function testSendsCustomerBackToStep1WithTheCappedQuantityPrefilled(): void
|
||||
{
|
||||
$request = Request::create('/bookings/create/participants', 'GET');
|
||||
$travel = $this->createTravel([$this->createRoom(1408, 'Doppelzimmer', minPax: 2)]);
|
||||
$bookingDto = $this->createBookingDto($travel);
|
||||
$bookingDto->roomSelections = [
|
||||
1408 => $this->createRoomSelection(1408, 'Doppelzimmer', quantity: 1, capacity: 2),
|
||||
];
|
||||
|
||||
$bookingService = $this->createMock(BookingConfigurator::class);
|
||||
$bookingService->expects($this->once())
|
||||
->method('reconcileRoomSelections')
|
||||
->with($bookingDto)
|
||||
->willReturn([new RoomSelectionAdjustment(1408, 'Doppelzimmer', 3, 1)]);
|
||||
|
||||
$bookingSessionService = $this->createMock(BookingSessionManager::class);
|
||||
$bookingSessionService->method('getOrCreateBookingCreateDto')->willReturn($bookingDto);
|
||||
$bookingSessionService->expects($this->once())
|
||||
->method('saveBookingDto')
|
||||
->with($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||
|
||||
$controller = $this->createController(
|
||||
$bookingService,
|
||||
$bookingSessionService,
|
||||
$this->createStub(LoggerInterface::class)
|
||||
);
|
||||
|
||||
$response = $controller->index($request);
|
||||
|
||||
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||
$this->assertSame('/app_booking_create_step_1', $response->getTargetUrl());
|
||||
$this->assertSame(
|
||||
[['error', 'Für dein gewähltes Zimmer sind nicht mehr genügend Plätze verfügbar. Wir haben deine Auswahl angepasst, bitte überprüfe sie.']],
|
||||
$controller->flashes
|
||||
);
|
||||
$this->assertSame(1, $bookingDto->roomSelections[1408]->quantity, 'Step 1 reopens on the capped selection');
|
||||
}
|
||||
|
||||
public function testSendsCustomerToTheErrorPageWhenTheTravelSoldOutCompletely(): void
|
||||
{
|
||||
$request = Request::create('/bookings/create/participants', 'GET');
|
||||
$bookingDto = $this->createBookingDto($this->createTravel([]));
|
||||
|
||||
$bookingService = $this->createMock(BookingConfigurator::class);
|
||||
$bookingService->expects($this->once())
|
||||
->method('reconcileRoomSelections')
|
||||
->with($bookingDto)
|
||||
->willReturn([new RoomSelectionAdjustment(1408, 'Doppelzimmer', 2, 0)]);
|
||||
|
||||
$bookingSessionService = $this->createMock(BookingSessionManager::class);
|
||||
$bookingSessionService->method('getOrCreateBookingCreateDto')->willReturn($bookingDto);
|
||||
$bookingSessionService->expects($this->once())
|
||||
->method('saveBookingDto')
|
||||
->with($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$logger->expects($this->once())
|
||||
->method('error')
|
||||
->with('Travel has no bookable rooms left while a booking is in progress.', $this->anything());
|
||||
|
||||
$controller = $this->createController($bookingService, $bookingSessionService, $logger);
|
||||
|
||||
$response = $controller->index($request);
|
||||
|
||||
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||
$this->assertSame('/app_booking_create_error', $response->getTargetUrl());
|
||||
$this->assertSame(
|
||||
[['error', 'Für diese Reise sind leider keine Zimmer mehr verfügbar.']],
|
||||
$controller->flashes
|
||||
);
|
||||
}
|
||||
|
||||
public function testContinuesWhenOnlyUnselectedRoomsWereDropped(): void
|
||||
{
|
||||
$request = Request::create('/bookings/create/participants', 'GET');
|
||||
$travel = $this->createTravel([$this->createRoom(1409, 'Einzelzimmer', minPax: 3)]);
|
||||
$bookingDto = $this->createBookingDto($travel);
|
||||
$bookingDto->roomSelections = [
|
||||
1409 => $this->createRoomSelection(1409, 'Einzelzimmer', quantity: 2, capacity: 3),
|
||||
];
|
||||
|
||||
$bookingService = $this->createMock(BookingConfigurator::class);
|
||||
$bookingService->expects($this->once())
|
||||
->method('reconcileRoomSelections')
|
||||
->with($bookingDto)
|
||||
->willReturn([]);
|
||||
|
||||
$bookingSessionService = $this->createMock(BookingSessionManager::class);
|
||||
$bookingSessionService->method('getOrCreateBookingCreateDto')->willReturn($bookingDto);
|
||||
$bookingSessionService->expects($this->exactly(2))
|
||||
->method('saveBookingDto')
|
||||
->with($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$logger->expects($this->never())->method('error');
|
||||
|
||||
$controller = $this->createController($bookingService, $bookingSessionService, $logger);
|
||||
|
||||
$response = $controller->index($request);
|
||||
|
||||
$this->assertSame([], $controller->flashes);
|
||||
$this->assertInstanceOf(Response::class, $response);
|
||||
$this->assertCount(6, $bookingDto->participants, 'Two rooms for three people each yield six participants');
|
||||
}
|
||||
|
||||
private function createController(
|
||||
BookingConfigurator $bookingService,
|
||||
BookingSessionManager $bookingSessionService,
|
||||
LoggerInterface $logger,
|
||||
): TestableStep2Controller {
|
||||
$form = $this->createStub(FormInterface::class);
|
||||
$form->method('isSubmitted')->willReturn(false);
|
||||
|
||||
return new TestableStep2Controller(
|
||||
$bookingService,
|
||||
$bookingSessionService,
|
||||
$this->createStub(BookingCreateContextFactory::class),
|
||||
$this->createStub(TravelDataProvider::class),
|
||||
$this->createStub(RoomAssigner::class),
|
||||
$this->createStub(ParticipantDataPrefiller::class),
|
||||
$logger,
|
||||
$form,
|
||||
);
|
||||
}
|
||||
|
||||
private function createBookingDto(Travel $travel): BookingDto
|
||||
{
|
||||
$bookingDto = new BookingDto($travel, 1);
|
||||
$bookingDto->currentStep = 2;
|
||||
|
||||
return $bookingDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, Room> $rooms
|
||||
*/
|
||||
private function createTravel(array $rooms): Travel
|
||||
{
|
||||
$travel = new Travel();
|
||||
$travel->id = 4711;
|
||||
$travel->code = 'TRV-4711';
|
||||
$travel->rooms = $rooms;
|
||||
|
||||
return $travel;
|
||||
}
|
||||
|
||||
private function createRoom(int $id, string $label, int $minPax): Room
|
||||
{
|
||||
$room = new Room();
|
||||
$room->id = $id;
|
||||
$room->label = $label;
|
||||
$room->available = 4;
|
||||
$room->minPax = $minPax;
|
||||
$room->status = Constants::STATUS_AVAILABLE;
|
||||
|
||||
return $room;
|
||||
}
|
||||
|
||||
private function createRoomSelection(int $id, string $label, int $quantity, int $capacity): RoomSelectionDto
|
||||
{
|
||||
$selection = new RoomSelectionDto();
|
||||
$selection->id = $id;
|
||||
$selection->label = $label;
|
||||
$selection->quantity = $quantity;
|
||||
$selection->capacity = $capacity;
|
||||
|
||||
return $selection;
|
||||
}
|
||||
}
|
||||
|
||||
final class TestableStep2Controller extends Step2Controller
|
||||
{
|
||||
/**
|
||||
* @var array<int, array{string, mixed}>
|
||||
*/
|
||||
public array $flashes = [];
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
public array $renderParameters = [];
|
||||
|
||||
/**
|
||||
* @var FormInterface<mixed>
|
||||
*/
|
||||
private readonly FormInterface $form;
|
||||
|
||||
/**
|
||||
* @param FormInterface<mixed> $form
|
||||
*/
|
||||
public function __construct(
|
||||
BookingConfigurator $bookingService,
|
||||
BookingSessionManager $bookingSessionService,
|
||||
BookingCreateContextFactory $createContextFactory,
|
||||
TravelDataProvider $travelDataService,
|
||||
RoomAssigner $roomAssignmentService,
|
||||
ParticipantDataPrefiller $prepopulationService,
|
||||
LoggerInterface $logger,
|
||||
FormInterface $form,
|
||||
) {
|
||||
$this->form = $form;
|
||||
|
||||
parent::__construct(
|
||||
$bookingService,
|
||||
$bookingSessionService,
|
||||
$createContextFactory,
|
||||
$travelDataService,
|
||||
$roomAssignmentService,
|
||||
$prepopulationService,
|
||||
$logger,
|
||||
);
|
||||
}
|
||||
|
||||
protected function getUser(): ?UserInterface
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 redirectToRoute(string $route, array $parameters = [], int $status = 302): RedirectResponse
|
||||
{
|
||||
return new RedirectResponse('/'.$route, $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
|
||||
{
|
||||
$this->renderParameters = $parameters;
|
||||
|
||||
return new Response('');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\Room;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\Service\BookingStatusRuleRegistry;
|
||||
use App\BusProNet\XmlLoader\AgencyLoader;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\RoomSelectionDto;
|
||||
use App\Service\BookingConfigurator;
|
||||
use App\Service\BookingSessionManager;
|
||||
use App\Service\ParticipantEligibilityChecker;
|
||||
use App\Service\TravelDataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class BookingConfiguratorRoomReconciliationTest extends TestCase
|
||||
{
|
||||
public function testKeepsQuantitiesOfRoomsThatAreStillBookable(): void
|
||||
{
|
||||
$travel = $this->createTravel([
|
||||
$this->createRoom(1408, 'Doppelzimmer', available: 4, minPax: 2),
|
||||
$this->createRoom(1409, 'Einzelzimmer', available: 2, minPax: 1),
|
||||
]);
|
||||
|
||||
$bookingDto = new BookingDto($travel, 1);
|
||||
$bookingDto->roomSelections = [
|
||||
1408 => $this->createRoomSelection(1408, quantity: 2),
|
||||
1409 => $this->createRoomSelection(1409, quantity: 0),
|
||||
];
|
||||
|
||||
$adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto);
|
||||
|
||||
$this->assertSame([], $adjustments);
|
||||
$this->assertSame([1408, 1409], array_keys($bookingDto->roomSelections));
|
||||
$this->assertSame(2, $bookingDto->roomSelections[1408]->quantity);
|
||||
$this->assertSame(0, $bookingDto->roomSelections[1409]->quantity);
|
||||
$this->assertSame(2, $bookingDto->roomSelections[1408]->capacity);
|
||||
$this->assertSame(4, $bookingDto->roomSelections[1408]->maxQuantity);
|
||||
}
|
||||
|
||||
public function testReportsAndRemovesASelectedRoomThatIsNoLongerBookable(): void
|
||||
{
|
||||
$travel = $this->createTravel([
|
||||
$this->createRoom(1408, 'Doppelzimmer', available: 0, minPax: 2),
|
||||
$this->createRoom(1409, 'Einzelzimmer', available: 2, minPax: 1),
|
||||
]);
|
||||
|
||||
$bookingDto = new BookingDto($travel, 1);
|
||||
$bookingDto->roomSelections = [
|
||||
1408 => $this->createRoomSelection(1408, quantity: 2),
|
||||
1409 => $this->createRoomSelection(1409, quantity: 0),
|
||||
];
|
||||
|
||||
$adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto);
|
||||
|
||||
$this->assertCount(1, $adjustments);
|
||||
$this->assertSame(1408, $adjustments[0]->roomId);
|
||||
$this->assertSame(2, $adjustments[0]->requestedQuantity);
|
||||
$this->assertSame(0, $adjustments[0]->bookableQuantity);
|
||||
$this->assertTrue($adjustments[0]->isDropped());
|
||||
$this->assertSame([1409], array_keys($bookingDto->roomSelections));
|
||||
}
|
||||
|
||||
public function testCapsAQuantityThatExceedsTheRemainingContingent(): void
|
||||
{
|
||||
$travel = $this->createTravel([
|
||||
$this->createRoom(1408, 'Doppelzimmer', available: 1, minPax: 2),
|
||||
]);
|
||||
|
||||
$bookingDto = new BookingDto($travel, 1);
|
||||
$bookingDto->roomSelections = [
|
||||
1408 => $this->createRoomSelection(1408, quantity: 3),
|
||||
];
|
||||
|
||||
$adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto);
|
||||
|
||||
$this->assertCount(1, $adjustments);
|
||||
$this->assertSame(1408, $adjustments[0]->roomId);
|
||||
$this->assertSame('Aus der Session', $adjustments[0]->label);
|
||||
$this->assertSame(3, $adjustments[0]->requestedQuantity);
|
||||
$this->assertSame(1, $adjustments[0]->bookableQuantity);
|
||||
$this->assertFalse($adjustments[0]->isDropped());
|
||||
$this->assertSame(1, $bookingDto->roomSelections[1408]->quantity, 'The selection carries the capped quantity');
|
||||
$this->assertSame(1, $bookingDto->roomSelections[1408]->maxQuantity);
|
||||
}
|
||||
|
||||
public function testLeavesAQuantityThatStillFitsTheReducedContingent(): void
|
||||
{
|
||||
$travel = $this->createTravel([
|
||||
$this->createRoom(1408, 'Doppelzimmer', available: 2, minPax: 2),
|
||||
]);
|
||||
|
||||
$bookingDto = new BookingDto($travel, 1);
|
||||
$bookingDto->roomSelections = [
|
||||
1408 => $this->createRoomSelection(1408, quantity: 2),
|
||||
];
|
||||
|
||||
$adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto);
|
||||
|
||||
$this->assertSame([], $adjustments);
|
||||
$this->assertSame(2, $bookingDto->roomSelections[1408]->quantity);
|
||||
}
|
||||
|
||||
public function testRemovesAnUnselectedRoomThatIsNoLongerBookableWithoutReportingIt(): void
|
||||
{
|
||||
$travel = $this->createTravel([
|
||||
$this->createRoom(1408, 'Doppelzimmer', available: 4, minPax: 2),
|
||||
$this->createRoom(1409, 'Einzelzimmer', available: 0, minPax: 1),
|
||||
]);
|
||||
|
||||
$bookingDto = new BookingDto($travel, 1);
|
||||
$bookingDto->roomSelections = [
|
||||
1408 => $this->createRoomSelection(1408, quantity: 1),
|
||||
1409 => $this->createRoomSelection(1409, quantity: 0),
|
||||
];
|
||||
|
||||
$adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto);
|
||||
|
||||
$this->assertSame([], $adjustments);
|
||||
$this->assertSame([1408], array_keys($bookingDto->roomSelections));
|
||||
}
|
||||
|
||||
public function testAddsARoomThatBecameBookableAgain(): void
|
||||
{
|
||||
$travel = $this->createTravel([
|
||||
$this->createRoom(1408, 'Doppelzimmer', available: 4, minPax: 2),
|
||||
$this->createRoom(1409, 'Einzelzimmer', available: 2, minPax: 1),
|
||||
]);
|
||||
|
||||
$bookingDto = new BookingDto($travel, 1);
|
||||
$bookingDto->roomSelections = [
|
||||
1408 => $this->createRoomSelection(1408, quantity: 1),
|
||||
];
|
||||
|
||||
$adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto);
|
||||
|
||||
$this->assertSame([], $adjustments);
|
||||
$this->assertSame([1408, 1409], array_keys($bookingDto->roomSelections));
|
||||
$this->assertSame(0, $bookingDto->roomSelections[1409]->quantity);
|
||||
$this->assertSame('Einzelzimmer', $bookingDto->roomSelections[1409]->label);
|
||||
}
|
||||
|
||||
public function testEmptiesTheSelectionsWhenTheTravelSoldOutCompletely(): void
|
||||
{
|
||||
$travel = $this->createTravel([
|
||||
$this->createRoom(1408, 'Doppelzimmer', available: 0, minPax: 2),
|
||||
]);
|
||||
|
||||
$bookingDto = new BookingDto($travel, 1);
|
||||
$bookingDto->roomSelections = [
|
||||
1408 => $this->createRoomSelection(1408, quantity: 2),
|
||||
];
|
||||
|
||||
$adjustments = $this->bookingService()->reconcileRoomSelections($bookingDto);
|
||||
|
||||
$this->assertCount(1, $adjustments);
|
||||
$this->assertTrue($adjustments[0]->isDropped());
|
||||
$this->assertSame([], $bookingDto->roomSelections);
|
||||
}
|
||||
|
||||
private function bookingService(): BookingConfigurator
|
||||
{
|
||||
return new BookingConfigurator(
|
||||
$this->createStub(BookingSessionManager::class),
|
||||
$this->createStub(TravelDataProvider::class),
|
||||
$this->createStub(ParticipantEligibilityChecker::class),
|
||||
$this->createStub(BookingStatusRuleRegistry::class),
|
||||
$this->createStub(AgencyLoader::class),
|
||||
'F'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, Room> $rooms
|
||||
*/
|
||||
private function createTravel(array $rooms): Travel
|
||||
{
|
||||
$travel = new Travel();
|
||||
$travel->id = 4711;
|
||||
$travel->code = 'TRV-4711';
|
||||
$travel->rooms = $rooms;
|
||||
|
||||
return $travel;
|
||||
}
|
||||
|
||||
private function createRoom(int $id, string $label, int $available, int $minPax): Room
|
||||
{
|
||||
$room = new Room();
|
||||
$room->id = $id;
|
||||
$room->label = $label;
|
||||
$room->available = $available;
|
||||
$room->minPax = $minPax;
|
||||
$room->status = Constants::STATUS_AVAILABLE;
|
||||
$room->price = 199.0;
|
||||
|
||||
return $room;
|
||||
}
|
||||
|
||||
private function createRoomSelection(int $id, int $quantity): RoomSelectionDto
|
||||
{
|
||||
$selection = new RoomSelectionDto();
|
||||
$selection->id = $id;
|
||||
$selection->label = 'Aus der Session';
|
||||
$selection->quantity = $quantity;
|
||||
$selection->capacity = 2;
|
||||
$selection->maxQuantity = 9;
|
||||
|
||||
return $selection;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user