serviceAvailabilityCalculator = $this->createMock(ServiceAvailabilityCalculator::class); // These tests target age filtering, so availability filtering passes everything through // unless a test stubs it explicitly $this->serviceAvailabilityCalculator ->method('filterAvailableServices') ->willReturnArgument(0) ; $insuranceService = $this->createMock(InsuranceManager::class); $priceCalculatorService = $this->createMock(BookingPriceCalculator::class); $serviceLabelFormatter = new ServiceLabelFormatter(); $translator = $this->createMock(TranslatorInterface::class); $translator->method('trans')->willReturnCallback( function (string $message, array $parameters = []): string { return match ($message) { 'service.age_constraint.prefix' => sprintf('Nur %s', $parameters['%constraint%'] ?? ''), 'service.age_constraint.absolute_age.range' => sprintf( 'Alter %s-%s', $parameters['%ageFrom%'] ?? '', $parameters['%ageTo%'] ?? '' ), 'service.age_constraint.absolute_age.min' => sprintf( 'Alter ab %s', $parameters['%ageFrom%'] ?? '' ), 'service.age_constraint.absolute_age.max' => sprintf( 'Alter bis %s', $parameters['%ageTo%'] ?? '' ), 'service.age_constraint.birth_year.single' => sprintf( 'Jahrgang %s', $parameters['%year%'] ?? '' ), 'service.age_constraint.birth_year.range' => sprintf( 'Jahrgang %s-%s', $parameters['%yearFrom%'] ?? '', $parameters['%yearTo%'] ?? '' ), 'service.age_constraint.birth_year.min' => sprintf( 'Jahrgang ab %s', $parameters['%yearFrom%'] ?? '' ), 'service.age_constraint.birth_year.max' => sprintf( 'Jahrgang bis %s', $parameters['%yearTo%'] ?? '' ), 'service.age_constraint.mixed.separator' => ' und ', default => $message, }; } ); $this->provider = new ParticipantFieldOptionsProvider( $this->serviceAvailabilityCalculator, $insuranceService, $priceCalculatorService, $serviceLabelFormatter, $translator ); } 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 testSkiPassesAreFilteredByBirthYearForAdultParticipant(): void { $travel = $this->createTravelWithExportedSkiPasses([195992, 195984]); $bookingDto = new BookingDto($travel, 1); $participant = new ParticipantDto(); $participant->index = 0; $participant->dateOfBirth = new \DateTimeImmutable('1990-12-26'); $bookingDto->participants[0] = $participant; $options = $this->provider->getFieldOptions('skiPass', $bookingDto, 0); $this->assertEmpty($options, 'Adult participant should not see birth-year-mismatched ski passes'); } public function testSkiPassesKeepMatchingBirthYearAndHideMismatchedPasses(): void { $travel = $this->createTravelWithExportedSkiPasses([195992, 195984]); $bookingDto = new BookingDto($travel, 1); $participant = new ParticipantDto(); $participant->index = 0; $participant->dateOfBirth = new \DateTimeImmutable('2011-12-26'); $bookingDto->participants[0] = $participant; $options = $this->provider->getFieldOptions('skiPass', $bookingDto, 0); $choices = $options['choices']; $choiceIds = array_map(fn (Service $service) => $service->id, $choices); $this->assertContains(195992, $choiceIds, 'Matching 2009-2012 ski pass should remain visible'); $this->assertNotContains(195984, $choiceIds, '2013-2020 ski pass should be hidden for a 2011-born participant'); } public function testEditModePreservesBookedSkiPassEvenWhenBirthYearMismatched(): void { $travel = $this->createTravelWithExportedSkiPasses([195992, 195984]); $bookingDto = new BookingDto($travel, 1); $bookedSkiPass = clone $travel->additionalServices[195992]; $bookedSkiPass->mapping = [0]; $booking = new Booking(); $booking->additionalServices = [ 195992 => $bookedSkiPass, ]; $bookingDto->booking = $booking; $participant = new ParticipantDto(); $participant->index = 0; $participant->dateOfBirth = new \DateTimeImmutable('1990-12-26'); $participant->skiPass = $bookedSkiPass; $bookingDto->participants[0] = $participant; $options = $this->provider->getFieldOptions('skiPass', $bookingDto, 0); $choices = $options['choices']; $choiceIds = array_map(fn (Service $service) => $service->id, $choices); $this->assertContains(195992, $choiceIds, 'Booked ski pass should remain visible in edit mode'); $choiceAttr = $options['choice_attr']; $attributes = $choiceAttr($choices[195992] ?? $choices[array_key_first($choices)]); $this->assertTrue($attributes['readonly']); $this->assertArrayHasKey('data-tooltip', $attributes); $this->assertStringContainsString('2009', $attributes['data-tooltip']); } 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; } /** * @param list $serviceIds */ private function createTravelWithExportedSkiPasses(array $serviceIds): Travel { $parser = new TravelParser(); $crawler = new Crawler(file_get_contents('/var/www/html/var/xmlexport/Ziel_2456.xml')); $travelNode = $crawler->filterXPath('//reise/termin')->first(); $travel = $parser->parse($travelNode); $travel->additionalServices = array_intersect_key( $travel->additionalServices, array_flip($serviceIds) ); return $travel; } }