From 2e40cbc7c77e6d2c607bdd7f33e2901d3ffb4bec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 26 Nov 2025 15:38:49 +0100 Subject: [PATCH] feat: special treatment of participants with age 0-2 (babies) --- src/BusProNet/Constants.php | 4 + src/Form/Model/ParticipantDto.php | 3 +- src/Form/Model/ParticipantEditDto.php | 34 +++ .../Service/Condition/BabyAgeCondition.php | 75 ++++++ .../MultipleParticipantsCondition.php | 41 +++ src/Form/Service/CreateFieldStateProvider.php | 20 +- .../ParticipantFieldOptionsProvider.php | 7 +- src/Service/ParticipantEligibilityService.php | 13 +- src/Validator/Constraints/RoomSelection.php | 2 - .../Constraints/RoomSelectionValidator.php | 5 +- tests/Form/Model/ParticipantEditDtoTest.php | 254 ++++++++++++++++++ .../ParticipantEligibilityServiceTest.php | 172 ++++++++++++ 12 files changed, 615 insertions(+), 15 deletions(-) create mode 100644 src/Form/Service/Condition/BabyAgeCondition.php create mode 100644 src/Form/Service/Condition/MultipleParticipantsCondition.php create mode 100644 tests/Service/ParticipantEligibilityServiceTest.php diff --git a/src/BusProNet/Constants.php b/src/BusProNet/Constants.php index ecb4b6c..81696ab 100644 --- a/src/BusProNet/Constants.php +++ b/src/BusProNet/Constants.php @@ -64,4 +64,8 @@ final class Constants // Payment type IDs for API public const PAYMENT_TYPE_ID_TRANSFER = 2; public const PAYMENT_TYPE_ID_DEBIT = 5; + + // Baby room constraints + public const BABY_MAX_AGE = 2; + public const BABY_ROOM_CODE = 'Baby'; } diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index cad79e0..66f003d 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -78,7 +78,8 @@ class ParticipantDto public array $courses = []; public array $additionalServices = []; - #[Assert\NotNull(message: 'Bitte auswählen', groups: ['strict_required'])] + // Note: Ski pass validation is conditional - see ParticipantEditDto::validateSkiPassRequired() + // Babies (0-2 years) are exempt from ski pass requirement public ?Service $skiPass = null; public array $board = []; diff --git a/src/Form/Model/ParticipantEditDto.php b/src/Form/Model/ParticipantEditDto.php index 6807494..608a355 100644 --- a/src/Form/Model/ParticipantEditDto.php +++ b/src/Form/Model/ParticipantEditDto.php @@ -4,6 +4,7 @@ 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; @@ -27,6 +28,39 @@ 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 mode, 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 mode - ski pass is readonly + 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/Form/Service/Condition/BabyAgeCondition.php b/src/Form/Service/Condition/BabyAgeCondition.php new file mode 100644 index 0000000..8b718ac --- /dev/null +++ b/src/Form/Service/Condition/BabyAgeCondition.php @@ -0,0 +1,75 @@ + $formData Current form data (unused) + * + * @return bool True if the participant is at or under baby age, false otherwise + */ + public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool + { + $participant = $bookingDto->getParticipant($participantIndex); + + if (null === $participant || null === $participant->dateOfBirth) { + return false; + } + + $age = $participant->getAge($bookingDto->travel->dateFrom); + + if (null === $age) { + return false; + } + + return $age <= Constants::BABY_MAX_AGE; + } + + /** + * Returns field names that affect baby age calculation. + * + * The condition depends on the participant's date of birth field. + * When this field changes, any conditions based on baby age should be re-evaluated. + * + * @return string[] Array containing 'dateOfBirth' field name + */ + public function getDependentFields(): array + { + return ['dateOfBirth']; + } + + /** + * Returns a human-readable description of the condition. + * + * @return string Description of the baby age condition + */ + public function getDescription(): string + { + return sprintf('Participant is at or under baby age (%d years)', Constants::BABY_MAX_AGE); + } +} diff --git a/src/Form/Service/Condition/MultipleParticipantsCondition.php b/src/Form/Service/Condition/MultipleParticipantsCondition.php new file mode 100644 index 0000000..524a5b3 --- /dev/null +++ b/src/Form/Service/Condition/MultipleParticipantsCondition.php @@ -0,0 +1,41 @@ + $formData Current form data (unused) + * + * @return bool True if the booking has 2+ participants, false otherwise + */ + public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool + { + return count($bookingDto->participants) > 1; + } + + public function getDependentFields(): array + { + return []; + } + + public function getDescription(): string + { + return 'Checks if the booking has multiple participants (2+)'; + } +} diff --git a/src/Form/Service/CreateFieldStateProvider.php b/src/Form/Service/CreateFieldStateProvider.php index 5f406e0..c3a2ec0 100644 --- a/src/Form/Service/CreateFieldStateProvider.php +++ b/src/Form/Service/CreateFieldStateProvider.php @@ -8,12 +8,14 @@ use App\BusProNet\Utility\DirectionMapper; use App\Form\Service\Abstract\AbstractFieldStateProvider; use App\Form\Service\Condition\ApplicantCondition; use App\Form\Service\Condition\AuthenticatedUserPersonalDataCondition; +use App\Form\Service\Condition\BabyAgeCondition; use App\Form\Service\Condition\BookingEligibilityCondition; use App\Form\Service\Condition\BulkInsuranceBookingCondition; use App\Form\Service\Condition\CompositeCondition; use App\Form\Service\Condition\DateOfBirthProvidedCondition; use App\Form\Service\Condition\FieldValueCondition; use App\Form\Service\Condition\FinalBookingOnlyCondition; +use App\Form\Service\Condition\MultipleParticipantsCondition; use App\Form\Service\Condition\RentalSelectionCondition; use App\Form\Service\Condition\RoomSelectionCondition; use App\Form\Service\Condition\ServiceSubTypeCondition; @@ -142,6 +144,9 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider // Hide age-dependent fields when no date of birth is provided $dateOfBirthProvidedCondition = new DateOfBirthProvidedCondition(); + // Baby age condition - hide ski pass for babies (0-2 years) + $babyAgeCondition = new BabyAgeCondition(); + // Age-dependent service fields are hidden until birth date is provided OR when participant is ineligible $this->fieldStateConditions['courses'] = [ 'hidden' => CompositeCondition::or( @@ -157,11 +162,15 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider ), ]; - // Skipass field - hidden when participant is ineligible OR when no date of birth + // Skipass field - hidden when: + // - No date of birth provided + // - Participant is ineligible (no skipasses for their age) + // - Participant is baby age (0-2 years) - babies don't need ski passes $this->fieldStateConditions['skiPass'] = [ 'hidden' => CompositeCondition::or( CompositeCondition::not($dateOfBirthProvidedCondition), - $bookingEligibilityCondition + $bookingEligibilityCondition, + $babyAgeCondition ), ]; @@ -184,11 +193,14 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider // Bulk insurance booking conditions $bulkInsuranceBookingCondition = new BulkInsuranceBookingCondition(); - // Show bulk insurance booking checkbox ONLY for applicant (index 0) and when insurance field is visible + // Show bulk insurance booking checkbox ONLY for applicant (index 0) when: + // - Date of birth is provided + // - There are multiple participants (bulk assignment is meaningless for single participant) $this->fieldStateConditions['bulkInsuranceBooking'] = [ 'hidden' => CompositeCondition::or( CompositeCondition::not($dateOfBirthProvidedCondition), // Hide until date of birth provided - CompositeCondition::not(new ApplicantCondition()) // Hide for non-applicants + CompositeCondition::not(new ApplicantCondition()), // Hide for non-applicants + CompositeCondition::not(new MultipleParticipantsCondition()) // Hide when only one participant ), ]; diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 4a7cee0..66ff2c5 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -16,7 +16,6 @@ use App\Form\Service\Factory\ParticipantRoomChoiceLoaderFactory; use App\Service\BookingPriceCalculatorService; use App\Service\InsuranceService; use App\Service\ServiceAvailabilityCalculator; -use App\Validator\Constraints\RoomSelection; /** * Provides dynamic field options for participant form fields. @@ -580,9 +579,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider return false; } - // Baby rooms only available for participants 2 years or younger - if (RoomSelection::BABY_ROOM_CODE === $room->code) { - return $age <= 2; + // Baby rooms only available for participants at or under BABY_MAX_AGE + if (Constants::BABY_ROOM_CODE === $room->code) { + return $age <= Constants::BABY_MAX_AGE; } // All other rooms available for all ages diff --git a/src/Service/ParticipantEligibilityService.php b/src/Service/ParticipantEligibilityService.php index 6a46cc6..e6a866b 100644 --- a/src/Service/ParticipantEligibilityService.php +++ b/src/Service/ParticipantEligibilityService.php @@ -31,12 +31,15 @@ class ParticipantEligibilityService * 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). * + * 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. + * * Results are cached per request to avoid redundant calculations. * * @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) + * @return bool True if participant is eligible (has available skipasses or is baby age) */ public function isParticipantEligible(BookingDto $bookingDto, int $participantIndex): bool { @@ -54,7 +57,13 @@ class ParticipantEligibilityService $participantIndex ); - return $this->eligibilityCache[$cacheKey] ??= (function () use ($bookingDto, $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 true; + } + $allSkiPasses = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true, true); $availableSkiPasses = array_filter( $allSkiPasses, diff --git a/src/Validator/Constraints/RoomSelection.php b/src/Validator/Constraints/RoomSelection.php index cc5b9ed..c3f9f3e 100644 --- a/src/Validator/Constraints/RoomSelection.php +++ b/src/Validator/Constraints/RoomSelection.php @@ -9,8 +9,6 @@ use Symfony\Component\Validator\Constraint; #[\Attribute] class RoomSelection extends Constraint { - public const BABY_ROOM_CODE = 'Baby'; - public string $noRoomSelectedMessage = 'Bitte mindestens ein Zimmer/Bett auswählen'; public string $onlyBabyRoomsMessage = 'Baby-Zimmer können nur in Kombination mit regulären Zimmern gebucht werden.'; diff --git a/src/Validator/Constraints/RoomSelectionValidator.php b/src/Validator/Constraints/RoomSelectionValidator.php index 89b1846..1dcd4af 100644 --- a/src/Validator/Constraints/RoomSelectionValidator.php +++ b/src/Validator/Constraints/RoomSelectionValidator.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Validator\Constraints; +use App\BusProNet\Constants; use App\Form\Model\BookingDto; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; @@ -50,14 +51,14 @@ class RoomSelectionValidator extends ConstraintValidator continue; } - if (RoomSelection::BABY_ROOM_CODE === $room->code) { + if (Constants::BABY_ROOM_CODE === $room->code) { $hasBabyRoom = true; } else { $hasRegularRoom = true; } } - if ($hasBabyRoom && !$hasRegularRoom) { + if ($hasBabyRoom && false === $hasRegularRoom) { $this->context->buildViolation($constraint->onlyBabyRoomsMessage) ->addViolation(); } diff --git a/tests/Form/Model/ParticipantEditDtoTest.php b/tests/Form/Model/ParticipantEditDtoTest.php index bfdcdce..a7f937b 100644 --- a/tests/Form/Model/ParticipantEditDtoTest.php +++ b/tests/Form/Model/ParticipantEditDtoTest.php @@ -363,6 +363,208 @@ class ParticipantEditDtoTest extends TestCase $this->assertCount(0, $violations5); } + // ========================================= + // Ski Pass Validation Tests (Baby Age Exemption) + // ========================================= + + public function testSkiPassRequiredForAdultInCreateMode(): void + { + $bookingDto = $this->createBookingDtoWithParticipants([ + $this->createAdultParticipantWithoutSkiPass('adult@example.com'), + ]); + + $wrapper = new ParticipantEditDto( + participant: $bookingDto->participants[0], + bookingContext: $bookingDto, + ); + + $violations = $this->validator->validate($wrapper, null, ['strict_required']); + + // Should have ski pass violation + $skiPassViolations = array_filter( + iterator_to_array($violations), + fn ($v) => 'participant.skiPass' === $v->getPropertyPath() + ); + + $this->assertCount(1, $skiPassViolations, 'Adult should require ski pass in create mode'); + } + + public function testSkiPassNotRequiredForBabyInCreateMode(): void + { + $bookingDto = $this->createBookingDtoWithParticipants([ + $this->createBabyParticipantWithoutSkiPass('baby@example.com'), + ]); + + $wrapper = new ParticipantEditDto( + participant: $bookingDto->participants[0], + bookingContext: $bookingDto, + ); + + $violations = $this->validator->validate($wrapper, null, ['strict_required']); + + // Should NOT have ski pass violation for baby + $skiPassViolations = array_filter( + iterator_to_array($violations), + fn ($v) => 'participant.skiPass' === $v->getPropertyPath() + ); + + $this->assertCount(0, $skiPassViolations, 'Baby (0-2 years) should not require ski pass'); + } + + public function testSkiPassNotRequiredForTwoYearOldInCreateMode(): void + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); + $travel->dateTo = new \DateTimeImmutable('2025-06-08'); + + $bookingDto = new BookingDto($travel, 1); + + // Create a participant exactly at BABY_MAX_AGE (2 years old at travel date) + $participant = $this->createParticipantWithoutSkiPass('twoyearold@example.com'); + $participant->dateOfBirth = $travel->dateFrom->modify('-2 years'); + $participant->index = 0; + $participant->mobile = '+49 123 456789'; + $participant->address = new \App\BusProNet\Model\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']); + + // Should NOT have ski pass violation for 2-year-old + $skiPassViolations = array_filter( + iterator_to_array($violations), + fn ($v) => 'participant.skiPass' === $v->getPropertyPath() + ); + + $this->assertCount(0, $skiPassViolations, 'Participant at BABY_MAX_AGE (2 years) should not require ski pass'); + } + + public function testSkiPassRequiredForThreeYearOldInCreateMode(): void + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); + $travel->dateTo = new \DateTimeImmutable('2025-06-08'); + + $bookingDto = new BookingDto($travel, 1); + + // Create a participant just over BABY_MAX_AGE (3 years old at travel date) + $participant = $this->createParticipantWithoutSkiPass('threeyearold@example.com'); + $participant->dateOfBirth = $travel->dateFrom->modify('-3 years'); + $participant->index = 0; + $participant->mobile = '+49 123 456789'; + $participant->address = new \App\BusProNet\Model\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']); + + // Should have ski pass violation for 3-year-old + $skiPassViolations = array_filter( + iterator_to_array($violations), + fn ($v) => 'participant.skiPass' === $v->getPropertyPath() + ); + + $this->assertCount(1, $skiPassViolations, 'Participant over BABY_MAX_AGE (3 years) should require ski pass'); + } + + public function testSkiPassValidationSkippedInEditMode(): void + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); + $travel->dateTo = new \DateTimeImmutable('2025-06-08'); + + $bookingDto = new BookingDto($travel, 1); + + // Create mock booking to simulate edit mode + $mockBooking = new \App\BusProNet\Model\Booking(); + $bookingDto->booking = $mockBooking; + + // Create adult participant without ski pass in edit mode + $participant = $this->createParticipantWithoutSkiPass('adult@example.com'); + $participant->dateOfBirth = new \DateTimeImmutable('1990-01-01'); + $participant->index = 0; + $participant->mutable = false; + $participant->mobile = '+49 123 456789'; + $participant->address = new \App\BusProNet\Model\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']); + + // Should NOT have ski pass violation in edit mode + $skiPassViolations = array_filter( + iterator_to_array($violations), + fn ($v) => 'participant.skiPass' === $v->getPropertyPath() + ); + + $this->assertCount(0, $skiPassViolations, 'Ski pass validation should be skipped in edit mode'); + } + + public function testSkiPassAgeCalculatedAtTravelDate(): void + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); + $travel->dateTo = new \DateTimeImmutable('2025-06-08'); + + $bookingDto = new BookingDto($travel, 1); + + // Create a participant who will be 2 at travel date + // Born 2023-06-02, travel date 2025-06-01 = 1 year 364 days = 1 year old + $participant = $this->createParticipantWithoutSkiPass('almosttwo@example.com'); + $participant->dateOfBirth = new \DateTimeImmutable('2023-06-02'); + $participant->index = 0; + $participant->mobile = '+49 123 456789'; + $participant->address = new \App\BusProNet\Model\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']); + + // Should NOT have ski pass violation (age calculated at travel date) + $skiPassViolations = array_filter( + iterator_to_array($violations), + fn ($v) => 'participant.skiPass' === $v->getPropertyPath() + ); + + $this->assertCount(0, $skiPassViolations, 'Age should be calculated at travel date for baby exemption'); + } + private function createBookingDtoWithParticipants(array $participants): BookingDto { $travel = new Travel(); @@ -465,4 +667,56 @@ class ParticipantEditDtoTest extends TestCase return $insurance; } + + private function createAdultParticipantWithoutSkiPass(string $email): ParticipantDto + { + $participant = $this->createValidParticipant( + new \DateTimeImmutable('1990-01-01'), // Adult (over 18) + $email + ); + + // Add required fields EXCEPT ski pass + $participant->assignedRoomId = 1; + $participant->skiPass = null; // No ski pass + $participant->transportationOutbound = $this->createMockService(); + $participant->transportationInbound = $this->createMockService(); + $participant->insurance = $this->createMockInsurance(); + + return $participant; + } + + private function createBabyParticipantWithoutSkiPass(string $email): ParticipantDto + { + // Baby is 1 year old at travel date (2025-06-01) + $participant = $this->createValidParticipant( + new \DateTimeImmutable('2024-06-01'), // 1 year old at travel date + $email + ); + + // Add required fields EXCEPT ski pass (babies don't need ski pass) + $participant->assignedRoomId = 1; + $participant->skiPass = null; // No ski pass + $participant->transportationOutbound = $this->createMockService(); + $participant->transportationInbound = $this->createMockService(); + $participant->insurance = $this->createMockInsurance(); + + return $participant; + } + + private function createParticipantWithoutSkiPass(string $email): ParticipantDto + { + $participant = new ParticipantDto(); + $participant->firstName = 'John'; + $participant->lastName = 'Doe'; + $participant->email = $email; + + // Add required fields EXCEPT ski pass + $participant->assignedRoomId = 1; + $participant->skiPass = null; // No ski pass + $participant->transportationOutbound = $this->createMockService(); + $participant->transportationInbound = $this->createMockService(); + $participant->insurance = $this->createMockInsurance(); + + return $participant; + } } diff --git a/tests/Service/ParticipantEligibilityServiceTest.php b/tests/Service/ParticipantEligibilityServiceTest.php new file mode 100644 index 0000000..a59521c --- /dev/null +++ b/tests/Service/ParticipantEligibilityServiceTest.php @@ -0,0 +1,172 @@ +service = new ParticipantEligibilityService(); + } + + public function testBabyParticipantIsAlwaysEligible(): void + { + $travel = $this->createTravelWithNoSkiPasses(); + $bookingDto = new BookingDto($travel, 1); + + // Create a baby participant (1 year old at travel date) + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->dateOfBirth = $travel->dateFrom->modify('-1 year'); + $bookingDto->participants[0] = $participant; + + $result = $this->service->isParticipantEligible($bookingDto, 0); + + $this->assertTrue($result, 'Baby participant (1 year old) should be eligible regardless of ski pass availability'); + } + + public function testTwoYearOldParticipantIsEligibleAsBaby(): void + { + $travel = $this->createTravelWithNoSkiPasses(); + $bookingDto = new BookingDto($travel, 1); + + // Create a participant exactly at BABY_MAX_AGE (2 years old at travel date) + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->dateOfBirth = $travel->dateFrom->modify('-2 years'); + $bookingDto->participants[0] = $participant; + + $result = $this->service->isParticipantEligible($bookingDto, 0); + + $this->assertTrue($result, 'Participant at BABY_MAX_AGE (2 years) should be eligible'); + } + + public function testThreeYearOldParticipantIsNotBabyEligible(): void + { + $travel = $this->createTravelWithNoSkiPasses(); + $bookingDto = new BookingDto($travel, 1); + + // Create a participant just over BABY_MAX_AGE (3 years old at travel date) + $participant = new ParticipantDto(); + $participant->index = 0; + $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'); + } + + public function testAdultParticipantWithSkiPassIsEligible(): void + { + $travel = $this->createTravelWithSkiPasses(); + $bookingDto = new BookingDto($travel, 1); + + // Create an adult participant (25 years old at travel date) + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->dateOfBirth = $travel->dateFrom->modify('-25 years'); + $bookingDto->participants[0] = $participant; + + $result = $this->service->isParticipantEligible($bookingDto, 0); + + $this->assertTrue($result, 'Adult participant with available ski passes should be eligible'); + } + + public function testParticipantWithoutDateOfBirthIsNotEligible(): void + { + $travel = $this->createTravelWithSkiPasses(); + $bookingDto = new BookingDto($travel, 1); + + // Create a participant without date of birth + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->dateOfBirth = null; + $bookingDto->participants[0] = $participant; + + $result = $this->service->isParticipantEligible($bookingDto, 0); + + $this->assertFalse($result, 'Participant without date of birth should not be eligible'); + } + + public function testNewbornBabyIsEligible(): void + { + $travel = $this->createTravelWithNoSkiPasses(); + $bookingDto = new BookingDto($travel, 1); + + // Create a newborn (born on travel date) + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->dateOfBirth = $travel->dateFrom; + $bookingDto->participants[0] = $participant; + + $result = $this->service->isParticipantEligible($bookingDto, 0); + + $this->assertTrue($result, 'Newborn baby (0 years old) should be eligible'); + } + + public function testBabyAgeIsCalculatedAtTravelDate(): void + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); + $travel->dateTo = new \DateTimeImmutable('2025-06-08'); + $travel->additionalServices = []; + + $bookingDto = new BookingDto($travel, 1); + + // Create a participant who will be 2 at travel date but 3 now + // Born 2023-06-02, travel date 2025-06-01 = 1 year 364 days = 1 year old + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->dateOfBirth = new \DateTimeImmutable('2023-06-02'); + $bookingDto->participants[0] = $participant; + + $result = $this->service->isParticipantEligible($bookingDto, 0); + + $this->assertTrue($result, 'Age should be calculated at travel date, not current date'); + } + + private function createTravelWithNoSkiPasses(): Travel + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('+30 days'); + $travel->dateTo = new \DateTimeImmutable('+37 days'); + $travel->additionalServices = []; + + return $travel; + } + + private function createTravelWithSkiPasses(): 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 + $skiPass = new Service(); + $skiPass->id = 1; + $skiPass->label = 'Test Ski Pass'; + $skiPass->subType = Constants::TOKEN_SKI_PASS; + $skiPass->available = 10; + $skiPass->price = 100.0; + $skiPass->ageConstraintType = null; // No age constraint + $skiPass->dateFrom = $travel->dateFrom; + $skiPass->dateTo = $travel->dateTo; + + $travel->additionalServices = [1 => $skiPass]; + + return $travel; + } +}