fix: stop offering unbookable services in the booking edit flow

This commit is contained in:
2026-09-16 15:28:07 +02:00
parent 672fc30d7e
commit 160ebef39e
18 changed files with 1094 additions and 104 deletions
@@ -16,6 +16,7 @@ use App\Repository\BookingEditDraftRepository;
use App\Service\BookingChangeTracker;
use App\Service\BookingEditDraftManager;
use App\Service\BookingEditDraftMerger;
use App\Service\ServiceAvailabilityCalculator;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
@@ -37,7 +38,7 @@ class BookingEditDraftManagerMutabilityTest extends TestCase
$this->createStub(BookingEditDraftRepository::class),
$this->createStub(EntityManagerInterface::class),
$this->createStub(BookingChangeTracker::class),
new BookingEditDraftMerger(),
new BookingEditDraftMerger(new ServiceAvailabilityCalculator()),
new NullLogger(),
);
}
@@ -9,6 +9,7 @@ use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Service\BookingEditDraftMerger;
use App\Service\ServiceAvailabilityCalculator;
use PHPUnit\Framework\TestCase;
/**
@@ -26,7 +27,7 @@ class BookingEditDraftMergerRoomAssignmentTest extends TestCase
protected function setUp(): void
{
$this->merger = new BookingEditDraftMerger();
$this->merger = new BookingEditDraftMerger(new ServiceAvailabilityCalculator());
}
public function testDraftDoesNotUnassignRoomWhenDraftValueIsNull(): void
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Service;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Service\BookingEditDraftMerger;
use App\Service\ServiceAvailabilityCalculator;
use PHPUnit\Framework\TestCase;
/**
* Tests that draft restoration does not re-arm a selection BusPro would reject.
*
* A draft can be weeks old. Restoring a selection for a service that has since sold out or
* moved to 'Anfrage' is what makes a stuck booking stuck: the stale choice comes back on every
* re-entry and BusPro refuses the whole update again. Services the participant already holds
* still restore, because withdrawing one breaks the Leistung/Teilnehmer counts.
*/
class BookingEditDraftMergerServiceAvailabilityTest extends TestCase
{
private BookingEditDraftMerger $merger;
protected function setUp(): void
{
$this->merger = new BookingEditDraftMerger(new ServiceAvailabilityCalculator());
}
public function testDraftDropsSkiPassThatIsNowOnRequest(): void
{
$skiPass = $this->createSkiPass(60, 'Skipass 6 Tage', Constants::STATUS_ON_REQUEST);
$travel = $this->createTravel([60 => $skiPass]);
$participant = new ParticipantDto();
$participant->mutable = true;
$dropped = $this->apply($travel, $participant, ['skiPass' => 60], new Booking());
$this->assertNull($participant->skiPass);
$this->assertSame(['Skipass 6 Tage'], $dropped);
}
public function testDraftRestoresSkiPassThatIsStillFree(): void
{
$skiPass = $this->createSkiPass(60, 'Skipass 6 Tage');
$travel = $this->createTravel([60 => $skiPass]);
$participant = new ParticipantDto();
$participant->mutable = true;
$dropped = $this->apply($travel, $participant, ['skiPass' => 60], new Booking());
$this->assertSame(60, $participant->skiPass?->id);
$this->assertSame([], $dropped);
}
public function testDraftRestoresOnRequestSkiPassTheParticipantAlreadyHolds(): void
{
$skiPass = $this->createSkiPass(60, 'Skipass 6 Tage', Constants::STATUS_ON_REQUEST);
$travel = $this->createTravel([60 => $skiPass]);
$held = clone $skiPass;
$held->mapping = [1];
$booking = new Booking();
$booking->additionalServices = [60 => $held];
$participant = new ParticipantDto();
$participant->mutable = true;
$dropped = $this->apply($travel, $participant, ['skiPass' => 60], $booking);
$this->assertSame(60, $participant->skiPass?->id);
$this->assertSame([], $dropped);
}
public function testDraftDropsOnlyTheUnbookableEntryOfAMultiSelectField(): void
{
$bookable = $this->createCourse(70, 'Snowboardkurs');
$onRequest = $this->createCourse(71, 'Skikurs', Constants::STATUS_ON_REQUEST);
$travel = $this->createTravel([70 => $bookable, 71 => $onRequest]);
$participant = new ParticipantDto();
$participant->mutable = true;
$dropped = $this->apply($travel, $participant, ['courses' => [70, 71]], new Booking());
$this->assertSame([70], array_map(static fn (Service $s) => $s->id, $participant->courses));
$this->assertSame(['Skikurs'], $dropped);
}
/**
* @param array<string, mixed> $services
*
* @return list<string>
*/
private function apply(Travel $travel, ParticipantDto $participant, array $services, Booking $booking): array
{
$dto = new BookingDto($travel, 1);
$dto->participants = [1 => $participant];
// A non-null booking is what puts the DTO into edit mode
$dto->booking = $booking;
return $this->merger->apply($dto, 1, $participant, ['services' => $services], $travel);
}
/** @param array<int, Service> $additionalServices */
private function createTravel(array $additionalServices): Travel
{
$travel = new Travel();
$travel->additionalServices = $additionalServices;
$travel->additionalServicesMutable = true;
return $travel;
}
private function createSkiPass(int $id, string $label, string $status = Constants::STATUS_AVAILABLE): Service
{
return $this->createService($id, $label, Constants::TOKEN_SKI_PASS, $status);
}
private function createCourse(int $id, string $label, string $status = Constants::STATUS_AVAILABLE): Service
{
return $this->createService($id, $label, Constants::TOKEN_COURSES, $status);
}
private function createService(int $id, string $label, string $subType, string $status): Service
{
$service = new Service();
$service->id = $id;
$service->label = $label;
$service->subType = $subType;
$service->status = $status;
$service->category = Constants::CATEGORY_ADDITIONAL;
return $service;
}
}
+84 -3
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Constants;
use App\BusProNet\DataProcessor\BookingDataProcessor;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Pickup;
@@ -12,6 +13,7 @@ use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Service\BookingEditSubmitGuard;
use App\Service\ServiceAvailabilityCalculator;
use Carbon\CarbonImmutable;
use PHPUnit\Framework\TestCase;
@@ -54,7 +56,7 @@ class BookingEditSubmitGuardTest extends TestCase
->method('createBookingDtoFromBooking')
->willReturn($baselineDto);
$service = new BookingEditSubmitGuard($processor);
$service = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator());
$changed = $service->reconcileImmutableCategories($workingDto, new Booking());
@@ -91,7 +93,7 @@ class BookingEditSubmitGuardTest extends TestCase
->method('createBookingDtoFromBooking')
->willReturn($baselineDto);
$service = new BookingEditSubmitGuard($processor);
$service = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator());
$changed = $service->reconcileImmutableCategories($workingDto, new Booking());
@@ -128,7 +130,7 @@ class BookingEditSubmitGuardTest extends TestCase
->method('createBookingDtoFromBooking')
->willReturn($baselineDto);
$service = new BookingEditSubmitGuard($processor);
$service = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator());
$changed = $service->reconcileImmutableCategories($workingDto, new Booking());
@@ -136,6 +138,85 @@ class BookingEditSubmitGuardTest extends TestCase
$this->assertSame([10], array_map(static fn (Service $s) => $s->id, $workingParticipant->rentals));
}
public function testRevertUnbookableServiceAdditionsDropsOnRequestAddition(): void
{
$onRequest = $this->createService(99);
$onRequest->label = 'Bus-Hinfahrt';
$onRequest->subType = Constants::TOKEN_SKI_PASS;
$onRequest->status = Constants::STATUS_ON_REQUEST;
$travel = new Travel();
$travel->additionalServices = [99 => $onRequest];
$workingParticipant = new ParticipantDto();
$workingParticipant->index = 0;
$workingParticipant->skiPass = $onRequest;
$freshBooking = new Booking();
$workingDto = new BookingDto($travel, 1);
$workingDto->participants = [$workingParticipant];
$workingDto->booking = $freshBooking;
$baselineParticipant = new ParticipantDto();
$baselineParticipant->index = 0;
$baselineDto = new BookingDto($travel, 1);
$baselineDto->participants = [$baselineParticipant];
$processor = $this->createStub(BookingDataProcessor::class);
$processor->method('createBookingDtoFromBooking')->willReturn($baselineDto);
$guard = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator());
$reverted = $guard->revertUnbookableServiceAdditions($workingDto, $freshBooking);
$this->assertSame(['Bus-Hinfahrt'], $reverted);
$this->assertNull($workingParticipant->skiPass);
}
public function testRevertUnbookableServiceAdditionsKeepsAServiceTheParticipantAlreadyHolds(): void
{
$onRequest = $this->createService(99);
$onRequest->label = 'Bus-Hinfahrt';
$onRequest->subType = Constants::TOKEN_SKI_PASS;
$onRequest->status = Constants::STATUS_ON_REQUEST;
$travel = new Travel();
$travel->additionalServices = [99 => $onRequest];
$held = clone $onRequest;
$held->mapping = [0];
$freshBooking = new Booking();
$freshBooking->additionalServices = [99 => $held];
$workingParticipant = new ParticipantDto();
$workingParticipant->index = 0;
$workingParticipant->skiPass = $onRequest;
$workingDto = new BookingDto($travel, 1);
$workingDto->participants = [$workingParticipant];
$workingDto->booking = $freshBooking;
$baselineParticipant = new ParticipantDto();
$baselineParticipant->index = 0;
$baselineParticipant->skiPass = $held;
$baselineDto = new BookingDto($travel, 1);
$baselineDto->participants = [$baselineParticipant];
$processor = $this->createStub(BookingDataProcessor::class);
$processor->method('createBookingDtoFromBooking')->willReturn($baselineDto);
$guard = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator());
$reverted = $guard->revertUnbookableServiceAdditions($workingDto, $freshBooking);
$this->assertSame([], $reverted);
$this->assertSame($onRequest, $workingParticipant->skiPass);
}
private function createService(int $id): Service
{
$service = new Service();
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Constants;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Service;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
@@ -85,6 +86,95 @@ class ServiceAvailabilityCalculatorTest extends TestCase
$this->assertSame([1, 4], array_keys($filtered));
}
public function testOnRequestServiceStaysAvailableInCreateMode(): void
{
$skiPass = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
$bookingDto = $this->createBookingDtoWithServices([$skiPass]);
// Create may still move the whole booking to status 'A', so on request is bookable there
$this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
}
public function testOnRequestServiceIsUnavailableInEditMode(): void
{
$skiPass = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
$bookingDto = $this->createEditBookingDtoWithServices([$skiPass]);
$this->assertTrue($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
}
public function testOnRequestServiceAlreadyHeldStaysAvailableInEditMode(): void
{
$skiPass = $this->createSkiPass(id: 1, available: 0, status: Constants::STATUS_ON_REQUEST);
$bookingDto = $this->createEditBookingDtoWithServices([$skiPass], heldServiceIds: [1]);
// Withdrawing a booked service breaks the Leistung/Teilnehmer counts, so it must be keepable
$this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
}
public function testAutoBookedOnRequestServiceStaysAvailableInEditMode(): void
{
$service = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
$service->autoBook = true;
$bookingDto = $this->createEditBookingDtoWithServices([$service]);
$this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
}
public function testMandatoryNonTransportOnRequestServiceStaysAvailableInEditMode(): void
{
$service = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
$service->mandatory = true;
$service->category = Constants::CATEGORY_ADDITIONAL;
$bookingDto = $this->createEditBookingDtoWithServices([$service]);
// Blocking a service the mandatory-services validator requires makes the form unsatisfiable
$this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
}
public function testMandatoryTransportOnRequestServiceIsStillUnavailableInEditMode(): void
{
$service = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
$service->mandatory = true;
$service->category = Constants::CATEGORY_TRANSPORTATION;
$bookingDto = $this->createEditBookingDtoWithServices([$service]);
// pflicht on a transport leg means "pick one of this group", not "compulsory"
$this->assertTrue($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
}
public function testFilterAvailableServicesNeverEmptiesAGroupOnTheOnRequestRuleAlone(): void
{
$first = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
$second = $this->createSkiPass(id: 2, available: 10, status: Constants::STATUS_ON_REQUEST);
$bookingDto = $this->createEditBookingDtoWithServices([$first, $second]);
$filtered = $this->calculator->filterAvailableServices(
$bookingDto->travel->additionalServices,
$bookingDto,
0
);
$this->assertSame([1, 2], array_keys($filtered));
}
public function testFilterAvailableServicesStillEmptiesAGroupThatIsSoldOutOrBlocked(): void
{
$soldOut = $this->createSkiPass(id: 1, available: 0, status: Constants::STATUS_ON_REQUEST);
$blocked = $this->createSkiPass(id: 2, available: null, status: Constants::STATUS_BLOCKED);
$bookingDto = $this->createEditBookingDtoWithServices([$soldOut, $blocked]);
$filtered = $this->calculator->filterAvailableServices(
$bookingDto->travel->additionalServices,
$bookingDto,
0
);
$this->assertSame([], array_keys($filtered));
}
private function createParkingService(int $id, int $available): Service
{
$service = new Service();
@@ -123,4 +213,27 @@ class ServiceAvailabilityCalculatorTest extends TestCase
return $bookingDto;
}
/**
* @param array<int, Service> $services
* @param list<int> $heldServiceIds IDs participant 0 already holds in the booking
*/
private function createEditBookingDtoWithServices(array $services, array $heldServiceIds = []): BookingDto
{
$bookingDto = $this->createBookingDtoWithServices($services);
$heldServices = [];
foreach ($heldServiceIds as $serviceId) {
$held = clone $bookingDto->travel->additionalServices[$serviceId];
$held->mapping = [0];
$heldServices[$serviceId] = $held;
}
// A non-null booking is what puts the DTO into edit mode
$booking = new Booking();
$booking->additionalServices = $heldServices;
$bookingDto->booking = $booking;
return $bookingDto;
}
}