From 6dbbfd87f82fbf41bac39af7788ff8c332641980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Mon, 8 Dec 2025 15:23:09 +0100 Subject: [PATCH] feat: special treatment of services for participants of age 0-2 --- .../ParticipantFieldOptionsProvider.php | 29 +- src/Service/BookingService.php | 6 + templates/booking/_form_theme.html.twig | 12 +- templates/booking/_participant_form.html.twig | 24 +- templates/booking/_summary.html.twig | 5 +- ...articipantFieldOptionsProviderBabyTest.php | 364 ++++++++++++++++++ tests/Service/BookingServiceBabyTest.php | 213 ++++++++++ 7 files changed, 635 insertions(+), 18 deletions(-) create mode 100644 tests/Form/Service/ParticipantFieldOptionsProviderBabyTest.php create mode 100644 tests/Service/BookingServiceBabyTest.php diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 597702c..e26aa40 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -754,12 +754,26 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider return []; } - return array_filter($services, function (Service $service) use ($bookingDto, $participantIndex, $ageEvaluator) { - // No age constraints = available to all - if (false === $ageEvaluator->canEvaluate($service)) { + // Check if participant is a baby + $age = $participant->getAge($bookingDto->travel->dateFrom); + $isBaby = null !== $age && $age <= Constants::BABY_MAX_AGE; + + return array_filter($services, function (Service $service) use ($bookingDto, $participantIndex, $ageEvaluator, $isBaby) { + // Check if service has age constraints + $hasAgeConstraints = $ageEvaluator->canEvaluate($service); + + // For babies: ONLY show services with explicit age ranges that include them + // Services without age restrictions are hidden for babies + if ($isBaby && false === $hasAgeConstraints) { + return false; + } + + // No age constraints and not a baby = available to all + if (false === $hasAgeConstraints) { return true; } + // Has age constraints = check if participant's age is within range return $ageEvaluator->isServiceAvailableForParticipant($service, $bookingDto, $participantIndex); }); } @@ -887,8 +901,15 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider // If we have both discounted and regular options, apply smart filtering if (null !== $discountedPkw && null !== $regularPkw) { - // Get participant to check inbound transportation $participant = $bookingDto->getParticipant($participantIndex); + + // Baby participants always get regular PKW (no discounts) + $age = $participant?->getAge($bookingDto->travel->dateFrom); + if (null !== $age && $age <= Constants::BABY_MAX_AGE) { + return [...$otherServices, $regularPkw]; + } + + // Check if inbound is BUS $inboundIsBus = null !== $participant?->transportationInbound && DirectionMapper::SUBTYPE_BUS_API === $participant->transportationInbound->subType; diff --git a/src/Service/BookingService.php b/src/Service/BookingService.php index 5d2e6bf..c5b49a9 100644 --- a/src/Service/BookingService.php +++ b/src/Service/BookingService.php @@ -442,6 +442,12 @@ class BookingService continue; // Skip participants without age information } + // Skip babies - they only get services with explicit age ranges (handled by field filtering) + $age = $participant->getAge($bookingDto->travel->dateFrom); + if (null !== $age && $age <= Constants::BABY_MAX_AGE) { + continue; + } + // Skip ineligible participants (no skipasses available for their age) if (false === $this->participantEligibilityService->isParticipantEligible($bookingDto, $participantIndex)) { continue; diff --git a/templates/booking/_form_theme.html.twig b/templates/booking/_form_theme.html.twig index a45f86a..11238ff 100644 --- a/templates/booking/_form_theme.html.twig +++ b/templates/booking/_form_theme.html.twig @@ -1,3 +1,13 @@ +{%- block form_errors -%} + {%- if errors|length > 0 -%} + + {%- endif -%} +{%- endblock form_errors -%} + {%- block form_row -%} {%- set row_attr = row_attr|merge({ 'class': (row_attr.class|default('') ~ ' mb-4')|trim }) -%} {%- set widget_attr = {} -%} @@ -57,7 +67,7 @@ {% if choiceData %} - {% if choiceData.price %} + {% if choiceData.price is not null %} {{ choiceData.price | format_currency('EUR') }} {% endif %} diff --git a/templates/booking/_participant_form.html.twig b/templates/booking/_participant_form.html.twig index 0f42e6d..28fca6a 100644 --- a/templates/booking/_participant_form.html.twig +++ b/templates/booking/_participant_form.html.twig @@ -13,15 +13,17 @@ {# If neither condition is true, don't render anything (truly hidden) #} {% endmacro %} -{# Macro to render a field or placeholder with consistent fieldset structure #} +{# Macro to render a service field with consistent fieldset/table structure #} {% macro service_field(form, fieldName, label, options = {}, undefinedLabel = 'Nicht wählbar') %} {% if form[fieldName] is defined %} {{ form_row(form[fieldName], options) }} {% else %} -
- {{ label }} -
{{ undefinedLabel }}
-
+
+
+ {{ label }} +
{{ undefinedLabel }}
+
+
{% endif %} {% endmacro %} @@ -43,7 +45,7 @@ {%- endif -%} - {% if price %} + {% if price is not null %} {{ price | format_currency('EUR') }} {% endif %} @@ -76,7 +78,7 @@ {%- endif -%} - {% if price %} + {% if price is not null %} {{ price | format_currency('EUR') }} {% endif %} @@ -116,7 +118,7 @@ {%- endif -%} - {% if price %} + {% if price is not null %} {{ price | format_currency('EUR') }} {% endif %} @@ -313,8 +315,12 @@ ) }} {% endif %} - {% else %} + {% elseif form.skiPass is defined %} + {# Skipass field exists but no rentals yet - user needs to select skipass first #}
Bitte zuerst den Skipass auswählen
+ {% else %} + {# No skipass field (e.g., baby participant) - rentals not available #} +
Nicht wählbar
{% endif %} {% if form.rentals is defined %} diff --git a/templates/booking/_summary.html.twig b/templates/booking/_summary.html.twig index 339271e..1011622 100644 --- a/templates/booking/_summary.html.twig +++ b/templates/booking/_summary.html.twig @@ -125,10 +125,7 @@ {% for roomPricing in summaryData.pricingData.rooms %} -
{{ roomPricing.quantity }}x {{ roomPricing.label }}
- {% if summaryData.assignmentCounts[roomPricing.roomId] is defined %} -
{{ summaryData.assignmentCounts[roomPricing.roomId] }}x Erwachsener
- {% endif %} + {{ roomPricing.quantity }}x {{ roomPricing.label }} {{ roomPricing.totalPrice | format_currency('EUR') }} diff --git a/tests/Form/Service/ParticipantFieldOptionsProviderBabyTest.php b/tests/Form/Service/ParticipantFieldOptionsProviderBabyTest.php new file mode 100644 index 0000000..e9f6577 --- /dev/null +++ b/tests/Form/Service/ParticipantFieldOptionsProviderBabyTest.php @@ -0,0 +1,364 @@ +createMock(ParticipantRoomChoiceLoaderFactory::class); + $this->serviceAvailabilityCalculator = $this->createMock(ServiceAvailabilityCalculator::class); + $insuranceService = $this->createMock(InsuranceService::class); + $priceCalculatorService = $this->createMock(BookingPriceCalculatorService::class); + + $this->provider = new ParticipantFieldOptionsProvider( + $roomChoiceLoaderFactory, + $this->serviceAvailabilityCalculator, + $insuranceService, + $priceCalculatorService + ); + } + + public function testBabyOnlySeesServicesWithExplicitAgeRange(): void + { + $travel = $this->createTravelWithMixedServices(); + $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; + + $options = $this->provider->getFieldOptions('additionalServices', $bookingDto, 0); + + $choices = $options['choices']; + $choiceIds = array_map(fn (Service $service) => $service->id, $choices); + + // Baby should see only service with age range 0-14 that includes them + $this->assertContains(1, $choiceIds, 'Service with age range 0-14 should be shown for baby'); + $this->assertNotContains(2, $choiceIds, 'Service with age range 6-14 should be hidden for baby (age < 6)'); + $this->assertNotContains(3, $choiceIds, 'Service without age restrictions should be hidden for baby'); + } + + public function testTwoYearOldOnlySeesServicesWithExplicitAgeRange(): void + { + $travel = $this->createTravelWithMixedServices(); + $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; + + $options = $this->provider->getFieldOptions('additionalServices', $bookingDto, 0); + + $choices = $options['choices']; + $choiceIds = array_map(fn (Service $service) => $service->id, $choices); + + // 2-year-old should see only service with age range that includes them + $this->assertContains(1, $choiceIds, 'Service with age range 0-14 should be shown for 2-year-old'); + $this->assertNotContains(2, $choiceIds, 'Service with age range 6-14 should be hidden for 2-year-old'); + $this->assertNotContains(3, $choiceIds, 'Service without age restrictions should be hidden for 2-year-old'); + } + + public function testThreeYearOldSeesAllApplicableServices(): void + { + $travel = $this->createTravelWithMixedServices(); + $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; + + $options = $this->provider->getFieldOptions('additionalServices', $bookingDto, 0); + + $choices = $options['choices']; + $choiceIds = array_map(fn (Service $service) => $service->id, $choices); + + // 3-year-old is not a baby, should see service with age range that includes them AND no-restriction services + $this->assertContains(1, $choiceIds, 'Service with age range 0-14 should be shown for 3-year-old'); + $this->assertNotContains(2, $choiceIds, 'Service with age range 6-14 should be hidden for 3-year-old (age < 6)'); + $this->assertContains(3, $choiceIds, 'Service without age restrictions should be shown for non-baby'); + } + + public function testAdultSeesAllApplicableServices(): void + { + $travel = $this->createTravelWithMixedServices(); + $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; + + $options = $this->provider->getFieldOptions('additionalServices', $bookingDto, 0); + + $choices = $options['choices']; + $choiceIds = array_map(fn (Service $service) => $service->id, $choices); + + // Adult should see service without age restrictions but not children services + $this->assertNotContains(1, $choiceIds, 'Service with age range 0-14 should be hidden for adult (age > 14)'); + $this->assertNotContains(2, $choiceIds, 'Service with age range 6-14 should be hidden for adult (age > 14)'); + $this->assertContains(3, $choiceIds, 'Service without age restrictions should be shown for adult'); + } + + public function testBabyGetRegularPkwInTransportation(): void + { + $travel = $this->createTravelWithTransportation(); + $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; + + $options = $this->provider->getFieldOptions('transportationOutbound', $bookingDto, 0); + + $choices = $options['choices']; + $choiceIds = array_map(fn (Service $service) => $service->id, $choices); + + // Baby should see BUS and regular PKW, but NOT discounted PKW + $this->assertContains(1, $choiceIds, 'BUS should be shown for baby'); + $this->assertContains(3, $choiceIds, 'Regular PKW should be shown for baby'); + $this->assertNotContains(2, $choiceIds, 'Discounted PKW should be hidden for baby'); + } + + public function testTwoYearOldGetRegularPkwInTransportation(): void + { + $travel = $this->createTravelWithTransportation(); + $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; + + $options = $this->provider->getFieldOptions('transportationOutbound', $bookingDto, 0); + + $choices = $options['choices']; + $choiceIds = array_map(fn (Service $service) => $service->id, $choices); + + // 2-year-old (at BABY_MAX_AGE) should see BUS and regular PKW, but NOT discounted PKW + $this->assertContains(1, $choiceIds, 'BUS should be shown for 2-year-old'); + $this->assertContains(3, $choiceIds, 'Regular PKW should be shown for 2-year-old'); + $this->assertNotContains(2, $choiceIds, 'Discounted PKW should be hidden for 2-year-old'); + } + + public function testThreeYearOldGetSmartPkwFiltering(): void + { + $travel = $this->createTravelWithTransportation(); + $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; + + // Mock availability: discounted PKW is available + $this->serviceAvailabilityCalculator + ->method('isServiceUnavailable') + ->willReturn(false); + + $options = $this->provider->getFieldOptions('transportationOutbound', $bookingDto, 0); + + $choices = $options['choices']; + $choiceIds = array_map(fn (Service $service) => $service->id, $choices); + + // 3-year-old is not a baby, should get smart filtering (discounted when available) + $this->assertContains(1, $choiceIds, 'BUS should be shown for 3-year-old'); + $this->assertContains(2, $choiceIds, 'Discounted PKW should be shown for 3-year-old when available'); + $this->assertNotContains(3, $choiceIds, 'Regular PKW should be hidden when discounted is available'); + } + + public function testBabyFilteringAppliesToMultipleServiceTypes(): void + { + $travel = $this->createTravelWithMultipleServiceTypes(); + $bookingDto = new BookingDto($travel, 1); + + // Create a baby participant + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->dateOfBirth = $travel->dateFrom->modify('-1 year'); + $bookingDto->participants[0] = $participant; + + // Test board services + $boardOptions = $this->provider->getFieldOptions('board', $bookingDto, 0); + $boardChoiceIds = array_map(fn (Service $service) => $service->id, $boardOptions['choices']); + + $this->assertContains(10, $boardChoiceIds, 'Board with age range 0-5 should be shown for baby'); + $this->assertNotContains(11, $boardChoiceIds, 'Board without age range should be hidden for baby'); + + // Test courses services + $coursesOptions = $this->provider->getFieldOptions('courses', $bookingDto, 0); + $coursesChoiceIds = array_map(fn (Service $service) => $service->id, $coursesOptions['choices']); + + $this->assertContains(20, $coursesChoiceIds, 'Course with age range 0-3 should be shown for baby'); + $this->assertNotContains(21, $coursesChoiceIds, 'Course without age range should be hidden for baby'); + } + + private function createTravelWithMixedServices(): Travel + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('+30 days'); + $travel->dateTo = new \DateTimeImmutable('+37 days'); + + // Service with explicit age range including babies (0-14) + $serviceWithBabyAge = new Service(); + $serviceWithBabyAge->id = 1; + $serviceWithBabyAge->label = 'Ortstaxe Kinder'; + $serviceWithBabyAge->subType = Constants::TOKEN_ADDITIONAL; + $serviceWithBabyAge->available = 10; + $serviceWithBabyAge->price = 5.0; + $serviceWithBabyAge->ageConstraintType = 'absolute_age'; + $serviceWithBabyAge->ageFrom = 0; + $serviceWithBabyAge->ageTo = 14; + + // Service with explicit age range NOT including babies (6-14) + $serviceWithOlderAge = new Service(); + $serviceWithOlderAge->id = 2; + $serviceWithOlderAge->label = 'Mobilitätsabgabe Kinder'; + $serviceWithOlderAge->subType = Constants::TOKEN_ADDITIONAL; + $serviceWithOlderAge->available = 10; + $serviceWithOlderAge->price = 3.0; + $serviceWithOlderAge->ageConstraintType = 'absolute_age'; + $serviceWithOlderAge->ageFrom = 6; + $serviceWithOlderAge->ageTo = 14; + + // Service without age restrictions (generic service) + $serviceNoAge = new Service(); + $serviceNoAge->id = 3; + $serviceNoAge->label = 'Keycard-Pfand'; + $serviceNoAge->subType = Constants::TOKEN_ADDITIONAL; + $serviceNoAge->available = 10; + $serviceNoAge->price = 10.0; + $serviceNoAge->ageConstraintType = null; + + $travel->additionalServices = [ + 1 => $serviceWithBabyAge, + 2 => $serviceWithOlderAge, + 3 => $serviceNoAge, + ]; + + return $travel; + } + + private function createTravelWithTransportation(): Travel + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('+30 days'); + $travel->dateTo = new \DateTimeImmutable('+37 days'); + + // BUS service + $busService = new Service(); + $busService->id = 1; + $busService->label = 'Bus'; + $busService->subType = DirectionMapper::SUBTYPE_BUS_API; + $busService->direction = DirectionMapper::OUTBOUND_TRAVEL; + $busService->available = 10; + $busService->price = 0.0; + + // Discounted PKW (negative price) + $discountedPkw = new Service(); + $discountedPkw->id = 2; + $discountedPkw->label = 'Selbstorganisiert (-15€ Rabatt)'; + $discountedPkw->subType = DirectionMapper::SUBTYPE_CAR_API; + $discountedPkw->direction = DirectionMapper::OUTBOUND_TRAVEL; + $discountedPkw->available = 5; + $discountedPkw->price = -15.0; + + // Regular PKW + $regularPkw = new Service(); + $regularPkw->id = 3; + $regularPkw->label = 'Selbstorganisiert'; + $regularPkw->subType = DirectionMapper::SUBTYPE_CAR_API; + $regularPkw->direction = DirectionMapper::OUTBOUND_TRAVEL; + $regularPkw->available = 100; + $regularPkw->price = 0.0; + + $travel->transportationServices = [$busService, $discountedPkw, $regularPkw]; + + return $travel; + } + + private function createTravelWithMultipleServiceTypes(): Travel + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('+30 days'); + $travel->dateTo = new \DateTimeImmutable('+37 days'); + + // Board with baby age range + $boardWithBabyAge = new Service(); + $boardWithBabyAge->id = 10; + $boardWithBabyAge->label = 'Baby Verpflegung'; + $boardWithBabyAge->subType = Constants::TOKEN_BOARD; + $boardWithBabyAge->available = 10; + $boardWithBabyAge->price = 0.0; + $boardWithBabyAge->ageConstraintType = 'absolute_age'; + $boardWithBabyAge->ageFrom = 0; + $boardWithBabyAge->ageTo = 5; + + // Board without age range + $boardNoAge = new Service(); + $boardNoAge->id = 11; + $boardNoAge->label = 'Verpflegung Standard'; + $boardNoAge->subType = Constants::TOKEN_BOARD; + $boardNoAge->available = 10; + $boardNoAge->price = 50.0; + $boardNoAge->ageConstraintType = null; + + // Course with baby age range + $courseWithBabyAge = new Service(); + $courseWithBabyAge->id = 20; + $courseWithBabyAge->label = 'Baby Kurs'; + $courseWithBabyAge->subType = Constants::TOKEN_COURSES; + $courseWithBabyAge->available = 10; + $courseWithBabyAge->price = 20.0; + $courseWithBabyAge->ageConstraintType = 'absolute_age'; + $courseWithBabyAge->ageFrom = 0; + $courseWithBabyAge->ageTo = 3; + + // Course without age range + $courseNoAge = new Service(); + $courseNoAge->id = 21; + $courseNoAge->label = 'Standard Kurs'; + $courseNoAge->subType = Constants::TOKEN_COURSES; + $courseNoAge->available = 10; + $courseNoAge->price = 100.0; + $courseNoAge->ageConstraintType = null; + + $travel->additionalServices = [ + 10 => $boardWithBabyAge, + 11 => $boardNoAge, + 20 => $courseWithBabyAge, + 21 => $courseNoAge, + ]; + + return $travel; + } +} diff --git a/tests/Service/BookingServiceBabyTest.php b/tests/Service/BookingServiceBabyTest.php new file mode 100644 index 0000000..853dbf4 --- /dev/null +++ b/tests/Service/BookingServiceBabyTest.php @@ -0,0 +1,213 @@ +createMock(TravelDataService::class); + $priceCalculator = $this->createMock(BookingPriceCalculatorService::class); + $this->participantEligibilityService = $this->createMock(ParticipantEligibilityService::class); + + $this->bookingService = new BookingService( + $travelDataService, + $priceCalculator, + $this->participantEligibilityService + ); + } + + public function testPreselectMandatoryServicesSkipsBabyParticipant(): void + { + $travel = $this->createTravelWithMandatoryServices(); + $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'); + $participant->additionalServices = []; + $bookingDto->participants[0] = $participant; + + $this->bookingService->preselectMandatoryServices($bookingDto); + + $this->assertEmpty($participant->additionalServices, 'Mandatory services should not be preselected for baby participants'); + } + + public function testPreselectMandatoryServicesSkipsTwoYearOldParticipant(): void + { + $travel = $this->createTravelWithMandatoryServices(); + $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'); + $participant->additionalServices = []; + $bookingDto->participants[0] = $participant; + + $this->bookingService->preselectMandatoryServices($bookingDto); + + $this->assertEmpty($participant->additionalServices, 'Mandatory services should not be preselected for participants at BABY_MAX_AGE'); + } + + public function testPreselectMandatoryServicesIncludesThreeYearOldParticipant(): void + { + $travel = $this->createTravelWithMandatoryServices(); + $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'); + $participant->additionalServices = []; + $bookingDto->participants[0] = $participant; + + // Mock eligibility check - 3-year-old is eligible + $this->participantEligibilityService + ->method('isParticipantEligible') + ->willReturn(true); + + $this->bookingService->preselectMandatoryServices($bookingDto); + + $this->assertNotEmpty($participant->additionalServices, 'Mandatory services should be preselected for non-baby participants'); + $this->assertCount(1, $participant->additionalServices); + $this->assertSame('Mandatory Service', $participant->additionalServices[0]->label); + } + + public function testPreselectMandatoryServicesIncludesAdultParticipant(): void + { + $travel = $this->createTravelWithMandatoryServices(); + $bookingDto = new BookingDto($travel, 1); + + // Create an adult participant + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->dateOfBirth = $travel->dateFrom->modify('-25 years'); + $participant->additionalServices = []; + $bookingDto->participants[0] = $participant; + + // Mock eligibility check - adult is eligible + $this->participantEligibilityService + ->method('isParticipantEligible') + ->willReturn(true); + + $this->bookingService->preselectMandatoryServices($bookingDto); + + $this->assertNotEmpty($participant->additionalServices, 'Mandatory services should be preselected for adult participants'); + $this->assertCount(1, $participant->additionalServices); + } + + public function testPreselectMandatoryServicesHandlesMixedParticipants(): void + { + $travel = $this->createTravelWithMandatoryServices(); + $bookingDto = new BookingDto($travel, 1); + + // Create a baby participant (index 0) + $babyParticipant = new ParticipantDto(); + $babyParticipant->index = 0; + $babyParticipant->dateOfBirth = $travel->dateFrom->modify('-1 year'); + $babyParticipant->additionalServices = []; + $bookingDto->participants[0] = $babyParticipant; + + // Create an adult participant (index 1) + $adultParticipant = new ParticipantDto(); + $adultParticipant->index = 1; + $adultParticipant->dateOfBirth = $travel->dateFrom->modify('-25 years'); + $adultParticipant->additionalServices = []; + $bookingDto->participants[1] = $adultParticipant; + + // Mock eligibility check - adult is eligible (baby won't be checked) + $this->participantEligibilityService + ->method('isParticipantEligible') + ->with($bookingDto, 1) // Only called for adult + ->willReturn(true); + + $this->bookingService->preselectMandatoryServices($bookingDto); + + $this->assertEmpty($babyParticipant->additionalServices, 'Baby should not have mandatory services preselected'); + $this->assertNotEmpty($adultParticipant->additionalServices, 'Adult should have mandatory services preselected'); + } + + public function testPreselectMandatoryServicesSkipsNewbornBaby(): void + { + $travel = $this->createTravelWithMandatoryServices(); + $bookingDto = new BookingDto($travel, 1); + + // Create a newborn (born on travel date) + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->dateOfBirth = $travel->dateFrom; + $participant->additionalServices = []; + $bookingDto->participants[0] = $participant; + + $this->bookingService->preselectMandatoryServices($bookingDto); + + $this->assertEmpty($participant->additionalServices, 'Mandatory services should not be preselected for newborn babies'); + } + + public function testBabyAgeCalculatedAtTravelDate(): void + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); + $travel->dateTo = new \DateTimeImmutable('2025-06-08'); + $travel->additionalServices = $this->createMandatoryAdditionalServices(); + + $bookingDto = new BookingDto($travel, 1); + + // Create a participant who will be 2 at travel date + // Born 2023-06-02 (turns 2 on 2025-06-02), travel date 2025-06-01 = 1 year old at travel start + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->dateOfBirth = new \DateTimeImmutable('2023-06-02'); + $participant->additionalServices = []; + $bookingDto->participants[0] = $participant; + + $this->bookingService->preselectMandatoryServices($bookingDto); + + // At age 1, participant is a baby and should be skipped + $this->assertEmpty($participant->additionalServices, 'Age should be calculated at travel date, not current date'); + } + + private function createTravelWithMandatoryServices(): Travel + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('+30 days'); + $travel->dateTo = new \DateTimeImmutable('+37 days'); + $travel->additionalServices = $this->createMandatoryAdditionalServices(); + + return $travel; + } + + /** + * @return array + */ + private function createMandatoryAdditionalServices(): array + { + $mandatoryService = new Service(); + $mandatoryService->id = 1; + $mandatoryService->label = 'Mandatory Service'; + $mandatoryService->subType = Constants::TOKEN_ADDITIONAL; + $mandatoryService->mandatory = true; + $mandatoryService->available = 10; + $mandatoryService->price = 10.0; + + return [1 => $mandatoryService]; + } +}