From af04ff690d27359a8317fd5a76fddae913acd533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 11 Aug 2026 11:16:11 +0200 Subject: [PATCH] fix: enable bookings without ski passes --- src/Form/Model/ParticipantDto.php | 4 +- src/Form/Model/ParticipantEditDto.php | 35 +--- src/Service/ParticipantEligibilityChecker.php | 142 +++++++++------- .../Constraints/SkiPassSelection.php | 26 +++ .../Constraints/SkiPassSelectionValidator.php | 75 +++++++++ tests/Form/Model/ParticipantEditDtoTest.php | 145 ++++++++++++++--- .../Service/CreateFieldStateProviderTest.php | 7 +- ...OptionsProviderSkiPassAvailabilityTest.php | 11 ++ .../BookingEditPreFlightCheckerTest.php | 5 + .../ParticipantEligibilityCheckerTest.php | 154 ++++++++++++++++-- 10 files changed, 480 insertions(+), 124 deletions(-) create mode 100644 src/Validator/Constraints/SkiPassSelection.php create mode 100644 src/Validator/Constraints/SkiPassSelectionValidator.php diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index 6535a57..d815035 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -102,8 +102,8 @@ class ParticipantDto /** @var list */ public array $autoBookOptOutRentalIds = []; - // Note: Ski pass validation is conditional - see ParticipantEditDto::validateSkiPassRequired() - // Babies (0-2 years) are exempt from ski pass requirement + // Note: Ski pass validation is conditional - see App\Validator\Constraints\SkiPassSelectionValidator. + // Babies (0-2 years) and travels that offer no ski passes at all are exempt. public ?Service $skiPass = null; /** @var list */ diff --git a/src/Form/Model/ParticipantEditDto.php b/src/Form/Model/ParticipantEditDto.php index 3f6dfd3..5779ad6 100644 --- a/src/Form/Model/ParticipantEditDto.php +++ b/src/Form/Model/ParticipantEditDto.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Form\Model; -use App\BusProNet\Constants; use App\Validator\Constraints as AppAssert; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; @@ -21,6 +20,7 @@ use function Symfony\Component\String\u; #[AppAssert\PurchaseVoucher(groups: ['booking_create', 'booking_edit'])] #[AppAssert\PromoVoucher(groups: ['booking_create', 'booking_edit'])] #[AppAssert\MandatoryAdditionalServicesSelected(groups: ['booking_create', 'booking_edit'])] +#[AppAssert\SkiPassSelection(groups: ['strict_required'])] class ParticipantEditDto { public function __construct( @@ -30,39 +30,6 @@ class ParticipantEditDto ) { } - /** - * Validates that ski pass is selected when required. - * - * Ski pass is required for all participants in create mode, EXCEPT for babies - * (participants aged 0-2 years at travel date). Babies don't qualify for any - * ski pass and the field is hidden for them. - * - * In edit submissions, this validation is skipped as the ski pass is readonly. - * - * This validation only runs when strict_required group is active. - */ - #[Assert\Callback(groups: ['strict_required'])] - public function validateSkiPassRequired(ExecutionContextInterface $context): void - { - // Skip validation in edit submissions - ski pass is readonly there. - if (BookingDto::MODE_EDIT === $this->bookingContext->getMode()) { - return; - } - - // Baby age exemption: skip validation for participants at or under BABY_MAX_AGE - $age = $this->participant->getAge($this->bookingContext->travel->dateFrom); - if (null !== $age && $age <= Constants::BABY_MAX_AGE) { - return; - } - - // Ski pass is required for non-baby participants - if (null === $this->participant->skiPass) { - $context->buildViolation('Bitte auswählen') - ->atPath('participant.skiPass') - ->addViolation(); - } - } - /** * Validates that insurance is selected when required. * diff --git a/src/Service/ParticipantEligibilityChecker.php b/src/Service/ParticipantEligibilityChecker.php index 9ea7e26..bc21e82 100644 --- a/src/Service/ParticipantEligibilityChecker.php +++ b/src/Service/ParticipantEligibilityChecker.php @@ -7,15 +7,22 @@ namespace App\Service; use App\BusProNet\Constants; use App\BusProNet\Model\Service; use App\Form\Model\BookingDto; -use Carbon\CarbonImmutable; +use App\Form\Service\ServiceAgeEvaluator; /** * Service for evaluating participant eligibility for booking. * - * A participant is considered eligible when at least one skipass is available - * for their age at the travel date. This service provides the business logic - * for eligibility checks used by both the conditional field state system and - * the view layer via Twig extension. + * Ski passes gate the booking process: a participant who cannot obtain one cannot book. + * This service owns that gate and distinguishes the two cases the travel data allows: + * + * - The travel offers ski passes, but none is selectable for the participant (age rules, + * sold out, Buchungsstop) - the participant is ineligible and all service fields are hidden. + * - The travel offers no ski passes at all - rare, but legitimate. There is nothing to gate + * on, so the participant is eligible and books without a pass, exactly like a baby does. + * + * Selectability is evaluated through the same pipeline the form uses to build the ski pass + * choices (ServiceAgeEvaluator + ServiceAvailabilityCalculator), so eligibility and the + * rendered choices cannot disagree. * * Results are cached per request using instance-level arrays to avoid redundant calculations * when checking the same participant multiple times. @@ -25,11 +32,17 @@ class ParticipantEligibilityChecker /** @var array Request-scoped cache for participant eligibility */ private array $eligibilityCache = []; + public function __construct( + private readonly ServiceAgeEvaluator $serviceAgeEvaluator, + private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator, + ) { + } + /** * Checks if a participant is eligible for booking. * - * Returns true when the participant can book services (at least one skipass available). - * Returns false when the participant cannot book (no skipasses available for their age). + * Returns true when the participant can book services, false when the booking process + * would be a dead end because no ski pass can be obtained for them. * * Baby age exemption: Participants aged 0-2 years (at travel date) are always eligible * regardless of skipass availability, as babies don't require ski passes. @@ -39,7 +52,7 @@ class ParticipantEligibilityChecker * @param BookingDto $bookingDto The current booking data * @param int $participantIndex The index of the participant being evaluated * - * @return bool True if participant is eligible (has available skipasses or is baby age) + * @return bool True if participant is eligible (has a selectable skipass or needs none) */ public function isParticipantEligible(BookingDto $bookingDto, int $participantIndex): bool { @@ -49,7 +62,9 @@ class ParticipantEligibilityChecker return false; } - // Create cache key based on participant's birth date, travel date, and index + // Create cache key based on participant's birth date, travel date, and index. + // Selections of the other participants feed into availability but cannot change + // within a single request, so they need no representation in the key. $cacheKey = sprintf( 'participant_eligibility_%s_%s_%d', $participant->dateOfBirth->format('Y-m-d'), @@ -57,72 +72,85 @@ class ParticipantEligibilityChecker $participantIndex ); - return $this->eligibilityCache[$cacheKey] ??= (function () use ($bookingDto, $participantIndex, $participant) { - // Baby age exemption: participants at or under BABY_MAX_AGE are always eligible - $age = $participant->getAge($bookingDto->travel->dateFrom); - if (null !== $age && $age <= Constants::BABY_MAX_AGE) { + return $this->eligibilityCache[$cacheKey] ??= (function () use ($bookingDto, $participantIndex) { + // Nothing gates the booking when no ski pass is required - baby age or a travel + // that does not offer ski passes at all + if (false === $this->isSkiPassRequired($bookingDto, $participantIndex)) { return true; } - $allSkiPasses = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true); - $availableSkiPasses = array_filter( - $allSkiPasses, - fn (Service $service) => $this->isSkiPassAvailableForParticipant($service, $bookingDto, $participantIndex) - ); - - return !empty($availableSkiPasses); + return [] !== $this->getSelectableSkiPasses($bookingDto, $participantIndex); })(); } /** - * Checks if a skipass service is available for the given participant based on age constraints. + * Checks whether the participant has to select a ski pass to complete the booking. + * + * Returns false for babies, who never qualify for a pass, and for travels that offer no + * ski passes at all, where there is nothing to select. + * + * Note the asymmetry with eligibility: a travel that offers passes but has none left for + * the participant does NOT turn the pass optional. Such a booking has to stay blocked, + * not silently go through without a pass. + * + * @param BookingDto $bookingDto The current booking data + * @param int $participantIndex The index of the participant being evaluated + * + * @return bool True if a ski pass selection is required for this participant */ - private function isSkiPassAvailableForParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool + public function isSkiPassRequired(BookingDto $bookingDto, int $participantIndex): bool { $participant = $bookingDto->getParticipant($participantIndex); - if (null === $participant || null === $participant->dateOfBirth) { + if (null === $participant) { return false; } - // No age constraints = available to all - if (null === $service->ageConstraintType) { - return true; + // Baby age exemption: participants at or under BABY_MAX_AGE never need a ski pass + $age = $participant->getAge($bookingDto->travel->dateFrom); + if (null !== $age && $age <= Constants::BABY_MAX_AGE) { + return false; } - $travelStartDate = CarbonImmutable::instance($bookingDto->travel->dateFrom); - $birthDate = CarbonImmutable::instance($participant->dateOfBirth); - $ageAtTravelStart = (int) $birthDate->diffInYears($travelStartDate); - $birthYear = (int) $birthDate->format('Y'); + return [] !== $this->getOfferedSkiPasses($bookingDto); + } - $constraintType = $service->ageConstraintType ?? 'absolute_age'; + /** + * Collects the ski passes the participant could actually select. + * + * Applies the same filters as the form's ski pass choices: age constraints first, then + * availability (sold out at the API level, exhausted by the other participants of this + * booking, or on Buchungsstop). + * + * @param BookingDto $bookingDto The current booking data + * @param int $participantIndex The index of the participant being evaluated + * + * @return array The selectable ski pass services + */ + private function getSelectableSkiPasses(BookingDto $bookingDto, int $participantIndex): array + { + $ageAppropriateSkiPasses = array_filter( + $this->getOfferedSkiPasses($bookingDto), + fn (Service $service) => false === $this->serviceAgeEvaluator->canEvaluate($service) + || true === $this->serviceAgeEvaluator->isServiceAvailableForParticipant($service, $bookingDto, $participantIndex) + ); - if ('absolute_age' === $constraintType || 'mixed' === $constraintType) { - $minAge = $service->ageFrom; - $maxAge = $service->ageTo; + return $this->serviceAvailabilityCalculator->filterAvailableServices( + $ageAppropriateSkiPasses, + $bookingDto, + $participantIndex + ); + } - if (null !== $minAge && $ageAtTravelStart < $minAge) { - return false; - } - - if (null !== $maxAge && $ageAtTravelStart > $maxAge) { - return false; - } - } - - if ('birth_year' === $constraintType || 'mixed' === $constraintType) { - $minBirthYear = $service->birthYearFrom; - $maxBirthYear = $service->birthYearTo; - - if (null !== $minBirthYear && $birthYear < $minBirthYear) { - return false; - } - - if (null !== $maxBirthYear && $birthYear > $maxBirthYear) { - return false; - } - } - - return true; + /** + * Collects the ski passes the travel offers within its date range, ignoring the participant. + * + * @param BookingDto $bookingDto The current booking data + * + * @return array The ski pass services offered by the travel + */ + private function getOfferedSkiPasses(BookingDto $bookingDto): array + { + return $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true); } } diff --git a/src/Validator/Constraints/SkiPassSelection.php b/src/Validator/Constraints/SkiPassSelection.php new file mode 100644 index 0000000..6e3ae8d --- /dev/null +++ b/src/Validator/Constraints/SkiPassSelection.php @@ -0,0 +1,26 @@ +bookingContext->getMode()) { + return; + } + + $participant = $value->participant; + + // Without a birth date nothing can be evaluated - the date of birth constraint reports it + if (null === $participant->dateOfBirth) { + return; + } + + $participantIndex = $participant->index ?? 0; + + if (false === $this->participantEligibilityService->isParticipantEligible($value->bookingContext, $participantIndex)) { + $this->context->buildViolation($constraint->notBookableMessage) + ->setParameter('{{ number }}', (string) ($participantIndex + 1)) + ->atPath('participant.skiPass') + ->addViolation(); + + return; + } + + if (false === $this->participantEligibilityService->isSkiPassRequired($value->bookingContext, $participantIndex)) { + return; + } + + if (null === $participant->skiPass) { + $this->context->buildViolation($constraint->message) + ->atPath('participant.skiPass') + ->addViolation(); + } + } +} diff --git a/tests/Form/Model/ParticipantEditDtoTest.php b/tests/Form/Model/ParticipantEditDtoTest.php index 5caf758..aea49ea 100644 --- a/tests/Form/Model/ParticipantEditDtoTest.php +++ b/tests/Form/Model/ParticipantEditDtoTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Tests\Form\Model; +use App\BusProNet\Constants; use App\BusProNet\Model\Booking; use App\BusProNet\Model\Address; use App\Form\Model\AddressDto; @@ -17,10 +18,12 @@ use App\Form\Model\ParticipantEditDto; use App\Form\Service\ServiceAgeEvaluator; use App\Service\BookingPriceCalculator; use App\Service\ParticipantEligibilityChecker; +use App\Service\ServiceAvailabilityCalculator; use App\Service\VoucherValidator; use App\Validator\Constraints\MandatoryAdditionalServicesSelectedValidator; use App\Validator\Constraints\PromoVoucherValidator; use App\Validator\Constraints\PurchaseVoucherValidator; +use App\Validator\Constraints\SkiPassSelectionValidator; use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\Email; @@ -49,13 +52,21 @@ class ParticipantEditDtoTest extends TestCase $mockParticipantEligibilityChecker = $this->createMock(ParticipantEligibilityChecker::class); $mockServiceAgeEvaluator = $this->createMock(ServiceAgeEvaluator::class); + // Ski pass validation is exercised against the real gate, not a mock: which participant + // needs a pass is exactly the behaviour under test here. + $participantEligibilityChecker = new ParticipantEligibilityChecker( + new ServiceAgeEvaluator(), + new ServiceAvailabilityCalculator() + ); + // Create custom validator factory that can inject dependencies - $validatorFactory = new class($mockVoucherService, $mockPriceCalculatorService, $mockParticipantEligibilityChecker, $mockServiceAgeEvaluator) implements ConstraintValidatorFactoryInterface { + $validatorFactory = new class($mockVoucherService, $mockPriceCalculatorService, $mockParticipantEligibilityChecker, $mockServiceAgeEvaluator, $participantEligibilityChecker) implements ConstraintValidatorFactoryInterface { public function __construct( private readonly VoucherValidator $voucherService, private readonly BookingPriceCalculator $priceCalculatorService, private readonly ParticipantEligibilityChecker $participantEligibilityService, private readonly ServiceAgeEvaluator $serviceAgeEvaluator, + private readonly ParticipantEligibilityChecker $realEligibilityChecker, ) { } @@ -75,6 +86,10 @@ class ParticipantEditDtoTest extends TestCase return new EmailValidator(Email::VALIDATION_MODE_HTML5); } + if (SkiPassSelectionValidator::class === $className) { + return new SkiPassSelectionValidator($this->realEligibilityChecker); + } + if (MandatoryAdditionalServicesSelectedValidator::class === $className) { return new MandatoryAdditionalServicesSelectedValidator( $this->participantEligibilityService, @@ -312,9 +327,7 @@ class ParticipantEditDtoTest extends TestCase public function testValidationInEditMode(): void { - $travel = new Travel(); - $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); - $travel->dateTo = new \DateTimeImmutable('2025-06-08'); + $travel = $this->createTravelOfferingSkiPasses(); $bookingDto = new BookingDto($travel, 1); // hotelId must be int @@ -483,9 +496,7 @@ class ParticipantEditDtoTest extends TestCase public function testSkiPassNotRequiredForTwoYearOldInCreateMode(): void { - $travel = new Travel(); - $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); - $travel->dateTo = new \DateTimeImmutable('2025-06-08'); + $travel = $this->createTravelOfferingSkiPasses(); $bookingDto = new BookingDto($travel, 1); @@ -520,9 +531,7 @@ class ParticipantEditDtoTest extends TestCase public function testSkiPassRequiredForThreeYearOldInCreateMode(): void { - $travel = new Travel(); - $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); - $travel->dateTo = new \DateTimeImmutable('2025-06-08'); + $travel = $this->createTravelOfferingSkiPasses(); $bookingDto = new BookingDto($travel, 1); @@ -555,11 +564,81 @@ class ParticipantEditDtoTest extends TestCase $this->assertCount(1, $skiPassViolations, 'Participant over BABY_MAX_AGE (3 years) should require ski pass'); } + public function testSkiPassNotRequiredWhenTravelOffersNone(): void + { + $travel = $this->createTravelWithoutSkiPasses(); + + $bookingDto = new BookingDto($travel, 1); + + $participant = $this->createParticipantWithoutSkiPass('adult@example.com'); + $participant->dateOfBirth = new \DateTimeImmutable('1990-01-01'); + $participant->index = 0; + $participant->mobile = '+49 123 456789'; + $participant->address = new Address(); + $participant->address->street = 'Test Street 1'; + $participant->address->postCode = '12345'; + $participant->address->city = 'Test City'; + $participant->address->country = 'DE'; + + $bookingDto->participants = [$participant]; + + $wrapper = new ParticipantEditDto( + participant: $bookingDto->participants[0], + bookingContext: $bookingDto, + ); + + $violations = $this->validator->validate($wrapper, null, ['strict_required']); + + $skiPassViolations = array_filter( + iterator_to_array($violations), + fn ($v) => 'participant.skiPass' === $v->getPropertyPath() + ); + + $this->assertCount(0, $skiPassViolations, 'A travel offering no ski passes must be bookable without one'); + } + + public function testIneligibleParticipantIsReportedAsNotBookable(): void + { + // Ski passes exist, but the only one starts at 18 - a 14 year old cannot obtain any + $travel = $this->createTravelOfferingSkiPasses(ageFrom: 18); + + $bookingDto = new BookingDto($travel, 1); + + $participant = $this->createParticipantWithoutSkiPass('teen@example.com'); + $participant->dateOfBirth = $travel->dateFrom->modify('-14 years'); + $participant->index = 0; + $participant->mobile = '+49 123 456789'; + $participant->address = new Address(); + $participant->address->street = 'Test Street 1'; + $participant->address->postCode = '12345'; + $participant->address->city = 'Test City'; + $participant->address->country = 'DE'; + + $bookingDto->participants = [$participant]; + + $wrapper = new ParticipantEditDto( + participant: $bookingDto->participants[0], + bookingContext: $bookingDto, + ); + + $violations = $this->validator->validate($wrapper, null, ['strict_required']); + + $skiPassViolations = array_values(array_filter( + iterator_to_array($violations), + fn ($v) => 'participant.skiPass' === $v->getPropertyPath() + )); + + $this->assertCount(1, $skiPassViolations); + $this->assertSame( + 'Für Teilnehmer:in 1 ist keine Buchung möglich', + $skiPassViolations[0]->getMessage(), + 'The user must not be asked to select from a field that is not rendered' + ); + } + public function testSkiPassValidationSkippedInEditMode(): void { - $travel = new Travel(); - $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); - $travel->dateTo = new \DateTimeImmutable('2025-06-08'); + $travel = $this->createTravelOfferingSkiPasses(); $bookingDto = new BookingDto($travel, 1); @@ -599,9 +678,7 @@ class ParticipantEditDtoTest extends TestCase public function testEditSubmissionValidatesApplicantAddressOnlyForFirstParticipant(): void { - $travel = new Travel(); - $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); - $travel->dateTo = new \DateTimeImmutable('2025-06-08'); + $travel = $this->createTravelOfferingSkiPasses(); $bookingDto = new BookingDto($travel, 1); $bookingDto->booking = new Booking(); @@ -644,9 +721,7 @@ class ParticipantEditDtoTest extends TestCase public function testSkiPassAgeCalculatedAtTravelDate(): void { - $travel = new Travel(); - $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); - $travel->dateTo = new \DateTimeImmutable('2025-06-08'); + $travel = $this->createTravelOfferingSkiPasses(); $bookingDto = new BookingDto($travel, 1); @@ -776,12 +851,42 @@ class ParticipantEditDtoTest extends TestCase $this->assertCount(1, $insuranceViolations); } - private function createBookingDtoWithParticipants(array $participants): BookingDto + /** + * Builds the default travel fixture: a regular travel that offers one unconstrained, + * available ski pass. Ski pass validation depends on the travel actually offering + * passes, so a travel without them is a distinct fixture, not the default. + */ + private function createTravelOfferingSkiPasses(?int $ageFrom = null): Travel + { + $travel = $this->createTravelWithoutSkiPasses(); + + $skiPass = new Service(); + $skiPass->id = 900; + $skiPass->label = 'Test Ski Pass'; + $skiPass->subType = Constants::TOKEN_SKI_PASS; + $skiPass->available = 10; + $skiPass->price = 100.0; + $skiPass->ageConstraintType = null === $ageFrom ? null : 'absolute_age'; + $skiPass->ageFrom = $ageFrom; + + $travel->additionalServices = [900 => $skiPass]; + + return $travel; + } + + private function createTravelWithoutSkiPasses(): Travel { $travel = new Travel(); $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); $travel->dateTo = new \DateTimeImmutable('2025-06-08'); + return $travel; + } + + private function createBookingDtoWithParticipants(array $participants): BookingDto + { + $travel = $this->createTravelOfferingSkiPasses(); + $bookingDto = new BookingDto($travel, 1); // hotelId must be int // Set index for each participant diff --git a/tests/Form/Service/CreateFieldStateProviderTest.php b/tests/Form/Service/CreateFieldStateProviderTest.php index 72ab142..22797f5 100644 --- a/tests/Form/Service/CreateFieldStateProviderTest.php +++ b/tests/Form/Service/CreateFieldStateProviderTest.php @@ -9,7 +9,9 @@ use App\BusProNet\Model\Travel; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; use App\Form\Service\CreateFieldStateProvider; +use App\Form\Service\ServiceAgeEvaluator; use App\Service\ParticipantEligibilityChecker; +use App\Service\ServiceAvailabilityCalculator; use PHPUnit\Framework\TestCase; /** @@ -24,7 +26,10 @@ class CreateFieldStateProviderTest extends TestCase protected function setUp(): void { - $this->provider = new CreateFieldStateProvider(new ParticipantEligibilityChecker()); + $this->provider = new CreateFieldStateProvider(new ParticipantEligibilityChecker( + new ServiceAgeEvaluator(), + new ServiceAvailabilityCalculator() + )); } public function testDependentInsuranceFieldHidesWhenApplicantSelectsFamilyInsuranceThenReappearsAfterSwitch(): void diff --git a/tests/Form/Service/ParticipantFieldOptionsProviderSkiPassAvailabilityTest.php b/tests/Form/Service/ParticipantFieldOptionsProviderSkiPassAvailabilityTest.php index ec8eed8..cd4ac78 100644 --- a/tests/Form/Service/ParticipantFieldOptionsProviderSkiPassAvailabilityTest.php +++ b/tests/Form/Service/ParticipantFieldOptionsProviderSkiPassAvailabilityTest.php @@ -134,6 +134,17 @@ class ParticipantFieldOptionsProviderSkiPassAvailabilityTest extends TestCase } } + public function testTravelWithoutSkiPassesRendersNoField(): void + { + $bookingDto = $this->createBookingDto([]); + + $this->assertSame( + [], + $this->provider->getFieldOptions('skiPass', $bookingDto, 0), + 'The field has to be absent entirely, not rendered with an empty choice list' + ); + } + /** @return int[] */ private function getSkiPassChoiceIds(BookingDto $bookingDto): array { diff --git a/tests/Service/BookingEditPreFlightCheckerTest.php b/tests/Service/BookingEditPreFlightCheckerTest.php index f60b423..6c19366 100644 --- a/tests/Service/BookingEditPreFlightCheckerTest.php +++ b/tests/Service/BookingEditPreFlightCheckerTest.php @@ -21,6 +21,7 @@ use App\Form\Service\ServiceAgeEvaluator; use App\Validator\Constraints\MandatoryAdditionalServicesSelectedValidator; use App\Validator\Constraints\PromoVoucherValidator; use App\Validator\Constraints\PurchaseVoucherValidator; +use App\Validator\Constraints\SkiPassSelectionValidator; use Carbon\CarbonImmutable; use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraint; @@ -291,6 +292,10 @@ class BookingEditPreFlightCheckerTest extends TestCase ); } + if (SkiPassSelectionValidator::class === $className) { + return new SkiPassSelectionValidator($this->participantEligibilityChecker); + } + return new $className(); } }; diff --git a/tests/Service/ParticipantEligibilityCheckerTest.php b/tests/Service/ParticipantEligibilityCheckerTest.php index 657cce2..9c93d80 100644 --- a/tests/Service/ParticipantEligibilityCheckerTest.php +++ b/tests/Service/ParticipantEligibilityCheckerTest.php @@ -9,7 +9,9 @@ use App\BusProNet\Model\Service; use App\BusProNet\Model\Travel; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; +use App\Form\Service\ServiceAgeEvaluator; use App\Service\ParticipantEligibilityChecker; +use App\Service\ServiceAvailabilityCalculator; use PHPUnit\Framework\TestCase; class ParticipantEligibilityCheckerTest extends TestCase @@ -18,7 +20,10 @@ class ParticipantEligibilityCheckerTest extends TestCase protected function setUp(): void { - $this->service = new ParticipantEligibilityChecker(); + $this->service = new ParticipantEligibilityChecker( + new ServiceAgeEvaluator(), + new ServiceAvailabilityCalculator() + ); } public function testBabyParticipantIsAlwaysEligible(): void @@ -53,9 +58,9 @@ class ParticipantEligibilityCheckerTest extends TestCase $this->assertTrue($result, 'Participant at BABY_MAX_AGE (2 years) should be eligible'); } - public function testThreeYearOldParticipantIsNotBabyEligible(): void + public function testThreeYearOldParticipantIsNotExemptAsBaby(): void { - $travel = $this->createTravelWithNoSkiPasses(); + $travel = $this->createTravelWithSkiPasses(ageFrom: 6); $bookingDto = new BookingDto($travel, 1); // Create a participant just over BABY_MAX_AGE (3 years old at travel date) @@ -64,9 +69,14 @@ class ParticipantEligibilityCheckerTest extends TestCase $participant->dateOfBirth = $travel->dateFrom->modify('-3 years'); $bookingDto->participants[0] = $participant; - $result = $this->service->isParticipantEligible($bookingDto, 0); - - $this->assertFalse($result, 'Participant over BABY_MAX_AGE (3 years) should not be eligible without ski passes'); + $this->assertTrue( + $this->service->isSkiPassRequired($bookingDto, 0), + 'Participant over BABY_MAX_AGE (3 years) should still need a ski pass' + ); + $this->assertFalse( + $this->service->isParticipantEligible($bookingDto, 0), + 'Participant over BABY_MAX_AGE (3 years) should not be eligible when no ski pass fits their age' + ); } public function testAdultParticipantWithSkiPassIsEligible(): void @@ -138,6 +148,128 @@ class ParticipantEligibilityCheckerTest extends TestCase $this->assertTrue($result, 'Age should be calculated at travel date, not current date'); } + public function testAdultIsEligibleOnTravelWithoutSkiPasses(): void + { + $travel = $this->createTravelWithNoSkiPasses(); + $bookingDto = new BookingDto($travel, 1); + + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->dateOfBirth = $travel->dateFrom->modify('-25 years'); + $bookingDto->participants[0] = $participant; + + $this->assertFalse( + $this->service->isSkiPassRequired($bookingDto, 0), + 'A travel that offers no ski passes cannot require one' + ); + $this->assertTrue( + $this->service->isParticipantEligible($bookingDto, 0), + 'Adult should be eligible on a travel that offers no ski passes at all' + ); + } + + public function testTeenagerIsEligibleOnTravelWithoutSkiPasses(): void + { + $travel = $this->createTravelWithNoSkiPasses(); + $bookingDto = new BookingDto($travel, 1); + + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->dateOfBirth = $travel->dateFrom->modify('-14 years'); + $bookingDto->participants[0] = $participant; + + $this->assertFalse($this->service->isSkiPassRequired($bookingDto, 0)); + $this->assertTrue( + $this->service->isParticipantEligible($bookingDto, 0), + 'Age must not be blamed when there is no ski pass to match against' + ); + } + + public function testParticipantIsNotEligibleWhenNoSkiPassMatchesTheirAge(): void + { + $travel = $this->createTravelWithSkiPasses(ageFrom: 18); + $bookingDto = new BookingDto($travel, 1); + + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->dateOfBirth = $travel->dateFrom->modify('-14 years'); + $bookingDto->participants[0] = $participant; + + $this->assertTrue($this->service->isSkiPassRequired($bookingDto, 0)); + $this->assertFalse( + $this->service->isParticipantEligible($bookingDto, 0), + 'Ski passes exist but none fits the age - the participant stays ineligible' + ); + } + + public function testParticipantIsNotEligibleWhenSkiPassIsSoldOut(): void + { + $travel = $this->createTravelWithSkiPasses(available: 0); + $bookingDto = new BookingDto($travel, 1); + + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->dateOfBirth = $travel->dateFrom->modify('-25 years'); + $bookingDto->participants[0] = $participant; + + $this->assertTrue( + $this->service->isSkiPassRequired($bookingDto, 0), + 'A sold out ski pass must not turn the requirement off' + ); + $this->assertFalse($this->service->isParticipantEligible($bookingDto, 0)); + } + + public function testParticipantIsNotEligibleWhenSkiPassIsOnBookingStop(): void + { + $travel = $this->createTravelWithSkiPasses(status: Constants::STATUS_BLOCKED); + $bookingDto = new BookingDto($travel, 1); + + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->dateOfBirth = $travel->dateFrom->modify('-25 years'); + $bookingDto->participants[0] = $participant; + + $this->assertTrue($this->service->isSkiPassRequired($bookingDto, 0)); + $this->assertFalse($this->service->isParticipantEligible($bookingDto, 0)); + } + + public function testParticipantIsNotEligibleWhenLastSkiPassIsTakenByAnotherParticipant(): void + { + $travel = $this->createTravelWithSkiPasses(available: 1); + $bookingDto = new BookingDto($travel, 2); + + $firstParticipant = new ParticipantDto(); + $firstParticipant->index = 0; + $firstParticipant->dateOfBirth = $travel->dateFrom->modify('-25 years'); + $firstParticipant->skiPass = $travel->additionalServices[1]; + $bookingDto->participants[0] = $firstParticipant; + + $secondParticipant = new ParticipantDto(); + $secondParticipant->index = 1; + $secondParticipant->dateOfBirth = $travel->dateFrom->modify('-25 years'); + $bookingDto->participants[1] = $secondParticipant; + + $this->assertTrue($this->service->isParticipantEligible($bookingDto, 0)); + $this->assertFalse( + $this->service->isParticipantEligible($bookingDto, 1), + 'The only ski pass is consumed by the first participant' + ); + } + + public function testBabyNeedsNoSkiPassEvenWhenTravelOffersThem(): void + { + $travel = $this->createTravelWithSkiPasses(); + $bookingDto = new BookingDto($travel, 1); + + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->dateOfBirth = $travel->dateFrom->modify('-1 year'); + $bookingDto->participants[0] = $participant; + + $this->assertFalse($this->service->isSkiPassRequired($bookingDto, 0)); + $this->assertTrue($this->service->isParticipantEligible($bookingDto, 0)); + } + private function createTravelWithNoSkiPasses(): Travel { $travel = new Travel(); @@ -148,20 +280,22 @@ class ParticipantEligibilityCheckerTest extends TestCase return $travel; } - private function createTravelWithSkiPasses(): Travel + private function createTravelWithSkiPasses(?int $ageFrom = null, ?int $available = 10, ?string $status = null): Travel { $travel = new Travel(); $travel->dateFrom = new \DateTimeImmutable('+30 days'); $travel->dateTo = new \DateTimeImmutable('+37 days'); - // Create a ski pass service available for all ages + // Create a ski pass service, by default available for all ages $skiPass = new Service(); $skiPass->id = 1; $skiPass->label = 'Test Ski Pass'; $skiPass->subType = Constants::TOKEN_SKI_PASS; - $skiPass->available = 10; + $skiPass->available = $available; + $skiPass->status = $status; $skiPass->price = 100.0; - $skiPass->ageConstraintType = null; // No age constraint + $skiPass->ageConstraintType = null === $ageFrom ? null : 'absolute_age'; + $skiPass->ageFrom = $ageFrom; $skiPass->dateFrom = $travel->dateFrom; $skiPass->dateTo = $travel->dateTo;