fix: stop offering unbookable services in the booking edit flow
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\BusProNet\XmlLoader;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\Availability;
|
||||
use App\BusProNet\Model\Service;
|
||||
use App\BusProNet\Model\ServiceAvailabilityResponse;
|
||||
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\Contracts\Cache\CacheInterface;
|
||||
|
||||
/**
|
||||
* Tests that patchAvailabilities() carries the live service status, not just the contingent.
|
||||
*
|
||||
* When a Leistung's contingent runs out BusPro moves it to 'Anfrage' without necessarily
|
||||
* reporting frei="0", so the status is the only reliable signal. Dropping it meant every
|
||||
* availability rule read the status from the nightly XML export instead.
|
||||
*/
|
||||
class TravelLoaderAvailabilityTest extends TestCase
|
||||
{
|
||||
private TravelLoader $loader;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->loader = new TravelLoader(
|
||||
$this->createStub(HotelLoader::class),
|
||||
$this->createStub(TravelParser::class),
|
||||
'https://example.test',
|
||||
$this->createStub(CacheInterface::class),
|
||||
$this->createStub(FilesystemOperator::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function testPatchesStatusAlongsideAvailability(): void
|
||||
{
|
||||
$service = $this->createService(60, Constants::STATUS_AVAILABLE, 10);
|
||||
$travel = $this->createTravel([$service]);
|
||||
|
||||
$this->loader->patchAvailabilities(
|
||||
$travel,
|
||||
$this->createResponse([$this->createAvailability(60, Constants::STATUS_ON_REQUEST, 3)])
|
||||
);
|
||||
|
||||
$this->assertSame(Constants::STATUS_ON_REQUEST, $service->status);
|
||||
$this->assertSame(3, $service->available);
|
||||
}
|
||||
|
||||
public function testKeepsExportStatusWhenTheResponseOmitsIt(): void
|
||||
{
|
||||
$service = $this->createService(60, Constants::STATUS_AVAILABLE, 10);
|
||||
$travel = $this->createTravel([$service]);
|
||||
|
||||
$this->loader->patchAvailabilities(
|
||||
$travel,
|
||||
$this->createResponse([$this->createAvailability(60, null, 3)])
|
||||
);
|
||||
|
||||
$this->assertSame(Constants::STATUS_AVAILABLE, $service->status);
|
||||
$this->assertSame(3, $service->available);
|
||||
}
|
||||
|
||||
public function testKeepsExportStatusWhenTheResponseCarriesABlankOne(): void
|
||||
{
|
||||
$service = $this->createService(60, Constants::STATUS_AVAILABLE, 10);
|
||||
$travel = $this->createTravel([$service]);
|
||||
|
||||
$this->loader->patchAvailabilities(
|
||||
$travel,
|
||||
$this->createResponse([$this->createAvailability(60, ' ', 3)])
|
||||
);
|
||||
|
||||
$this->assertSame(Constants::STATUS_AVAILABLE, $service->status);
|
||||
}
|
||||
|
||||
public function testPatchesTransportationServicesToo(): void
|
||||
{
|
||||
$service = $this->createService(80, Constants::STATUS_AVAILABLE, 10);
|
||||
$travel = new Travel();
|
||||
$travel->transportationServices = [80 => $service];
|
||||
|
||||
$this->loader->patchAvailabilities(
|
||||
$travel,
|
||||
$this->createResponse([$this->createAvailability(80, Constants::STATUS_BLOCKED, 0)])
|
||||
);
|
||||
|
||||
$this->assertSame(Constants::STATUS_BLOCKED, $service->status);
|
||||
$this->assertSame(0, $service->available);
|
||||
}
|
||||
|
||||
/** @param array<int, Service> $services */
|
||||
private function createTravel(array $services): Travel
|
||||
{
|
||||
$travel = new Travel();
|
||||
|
||||
$indexed = [];
|
||||
foreach ($services as $service) {
|
||||
$indexed[$service->id] = $service;
|
||||
}
|
||||
$travel->additionalServices = $indexed;
|
||||
|
||||
return $travel;
|
||||
}
|
||||
|
||||
private function createService(int $id, string $status, ?int $available): Service
|
||||
{
|
||||
$service = new Service();
|
||||
$service->id = $id;
|
||||
$service->status = $status;
|
||||
$service->available = $available;
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
private function createAvailability(int $serviceId, ?string $status, ?int $available): Availability
|
||||
{
|
||||
$availability = new Availability();
|
||||
$availability->serviceId = $serviceId;
|
||||
$availability->status = $status;
|
||||
$availability->available = $available;
|
||||
|
||||
return $availability;
|
||||
}
|
||||
|
||||
/** @param array<int, Availability> $availabilities */
|
||||
private function createResponse(array $availabilities): ServiceAvailabilityResponse
|
||||
{
|
||||
$indexed = [];
|
||||
foreach ($availabilities as $availability) {
|
||||
$indexed[$availability->serviceId] = $availability;
|
||||
}
|
||||
|
||||
return new ServiceAvailabilityResponse($indexed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Form\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\Form\Service\ParticipantFieldOptionsProvider;
|
||||
use App\Service\BookingPriceCalculator;
|
||||
use App\Service\InsuranceManager;
|
||||
use App\Service\ServiceAvailabilityCalculator;
|
||||
use App\Service\ServiceLabelFormatter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* Edit mode applies the availability rules instead of bypassing them.
|
||||
*
|
||||
* Edit used to skip availability entirely for every category except ski passes, so a course
|
||||
* that had sold out or moved to 'Anfrage' was still offered. Selecting one made BusPro refuse
|
||||
* the whole update ("Status der Leistung (A) ist unterschiedlich zum Status des Teilnehmers
|
||||
* (F)"), because the participants of an existing booking are fixed at status 'F'.
|
||||
*
|
||||
* Courses stand in for the categories that render read-only rather than dropping the option.
|
||||
*/
|
||||
class ParticipantFieldOptionsProviderEditAvailabilityTest extends TestCase
|
||||
{
|
||||
private ParticipantFieldOptionsProvider $provider;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$translator = $this->createStub(TranslatorInterface::class);
|
||||
$translator->method('trans')->willReturnArgument(0);
|
||||
|
||||
$this->provider = new ParticipantFieldOptionsProvider(
|
||||
new ServiceAvailabilityCalculator(),
|
||||
$this->createStub(InsuranceManager::class),
|
||||
$this->createStub(BookingPriceCalculator::class),
|
||||
new ServiceLabelFormatter(),
|
||||
$translator
|
||||
);
|
||||
}
|
||||
|
||||
public function testOnRequestCourseIsReadonlyInEditMode(): void
|
||||
{
|
||||
$course = $this->createCourse(id: 1, status: Constants::STATUS_ON_REQUEST);
|
||||
$bookingDto = $this->createEditBookingDto([$course]);
|
||||
|
||||
$attributes = $this->getChoiceAttributes($bookingDto, $course);
|
||||
|
||||
$this->assertArrayHasKey('readonly', $attributes);
|
||||
$this->assertSame(
|
||||
'Diese Leistung ist derzeit nur auf Anfrage buchbar. Bitte kontaktiere uns.',
|
||||
$attributes['data-tooltip']
|
||||
);
|
||||
}
|
||||
|
||||
public function testSoldOutCourseIsReadonlyInEditMode(): void
|
||||
{
|
||||
$course = $this->createCourse(id: 1, available: 0);
|
||||
$bookingDto = $this->createEditBookingDto([$course]);
|
||||
|
||||
$attributes = $this->getChoiceAttributes($bookingDto, $course);
|
||||
|
||||
$this->assertArrayHasKey('readonly', $attributes);
|
||||
$this->assertSame('ausgebucht', $attributes['data-tooltip']);
|
||||
}
|
||||
|
||||
public function testOnRequestCourseStaysSelectableWhenAlreadyBooked(): void
|
||||
{
|
||||
$course = $this->createCourse(id: 1, status: Constants::STATUS_ON_REQUEST);
|
||||
$bookingDto = $this->createEditBookingDto([$course], heldServiceIds: [1]);
|
||||
|
||||
$attributes = $this->getChoiceAttributes($bookingDto, $course);
|
||||
|
||||
$this->assertArrayNotHasKey('readonly', $attributes);
|
||||
$this->assertArrayNotHasKey('data-tooltip', $attributes);
|
||||
}
|
||||
|
||||
public function testUnlimitedCourseIsNotReadonlyInEditMode(): void
|
||||
{
|
||||
// A null contingent means unlimited. The previous edit branch inverted this and made
|
||||
// such a service read-only for anyone who did not already hold it.
|
||||
$course = $this->createCourse(id: 1, available: null);
|
||||
$bookingDto = $this->createEditBookingDto([$course]);
|
||||
|
||||
$attributes = $this->getChoiceAttributes($bookingDto, $course);
|
||||
|
||||
$this->assertArrayNotHasKey('readonly', $attributes);
|
||||
}
|
||||
|
||||
public function testOnRequestCourseStaysSelectableInCreateMode(): void
|
||||
{
|
||||
$course = $this->createCourse(id: 1, status: Constants::STATUS_ON_REQUEST);
|
||||
$bookingDto = $this->createBookingDto([$course]);
|
||||
|
||||
$this->assertSame(BookingDto::MODE_CREATE, $bookingDto->getMode());
|
||||
|
||||
$attributes = $this->getChoiceAttributes($bookingDto, $course);
|
||||
|
||||
$this->assertArrayNotHasKey('readonly', $attributes);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function getChoiceAttributes(BookingDto $bookingDto, Service $service): array
|
||||
{
|
||||
$options = $this->provider->getFieldOptions('courses', $bookingDto, 0);
|
||||
|
||||
return $options['choice_attr']($service);
|
||||
}
|
||||
|
||||
/** @param Service[] $courses */
|
||||
private function createBookingDto(array $courses): BookingDto
|
||||
{
|
||||
$travel = new Travel();
|
||||
$travel->dateFrom = new \DateTimeImmutable('2027-01-09');
|
||||
$travel->dateTo = new \DateTimeImmutable('2027-01-16');
|
||||
|
||||
foreach ($courses as $course) {
|
||||
$travel->additionalServices[$course->id] = $course;
|
||||
}
|
||||
|
||||
$bookingDto = new BookingDto($travel, 1);
|
||||
|
||||
$participant = new ParticipantDto();
|
||||
$participant->index = 0;
|
||||
$participant->dateOfBirth = new \DateTimeImmutable('1990-01-01');
|
||||
$bookingDto->participants[0] = $participant;
|
||||
|
||||
return $bookingDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Service[] $courses
|
||||
* @param list<int> $heldServiceIds IDs participant 0 already holds in the booking
|
||||
*/
|
||||
private function createEditBookingDto(array $courses, array $heldServiceIds = []): BookingDto
|
||||
{
|
||||
$bookingDto = $this->createBookingDto($courses);
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
private function createCourse(int $id, ?int $available = 10, string $status = Constants::STATUS_AVAILABLE): Service
|
||||
{
|
||||
$service = new Service();
|
||||
$service->id = $id;
|
||||
$service->label = sprintf('Kurs %d', $id);
|
||||
$service->subType = Constants::TOKEN_COURSES;
|
||||
$service->category = Constants::CATEGORY_ADDITIONAL;
|
||||
$service->price = (float) $id;
|
||||
$service->available = $available;
|
||||
$service->status = $status;
|
||||
|
||||
return $service;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user