fix: always filter ski passes by participant's age

This commit is contained in:
Björn Fromme
2026-05-07 11:40:45 +02:00
parent 8ac62d2a02
commit eaff989bdd
2 changed files with 142 additions and 14 deletions
@@ -982,12 +982,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
/** /**
* Filters ski pass choices for display. * Filters ski pass choices for display.
* *
* Unlike other services, ski passes are shown even when age-restricted * Ski passes follow the same age evaluation rules as the rest of the booking flow.
* (marked as readonly instead of hidden) to avoid user confusion about * Participants only see passes that match their age or birth year. In edit mode,
* included ski passes not being visible. * the currently booked ski pass is preserved even if it would otherwise be filtered
* * out, so existing bookings remain renderable and editable.
* Baby filtering is preserved: babies only see services explicitly
* including their age range.
* *
* @param Service[] $services Array of ski pass Service objects to filter * @param Service[] $services Array of ski pass Service objects to filter
* @param BookingDto $bookingDto The booking DTO containing participant data * @param BookingDto $bookingDto The booking DTO containing participant data
@@ -1004,17 +1002,18 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
return []; return [];
} }
// Check if participant is a baby $filteredServices = $this->filterServicesByAgeConstraints($services, $bookingDto, $participantIndex);
$age = $participant->getAge($bookingDto->travel->dateFrom);
$isBaby = null !== $age && $age <= Constants::BABY_MAX_AGE;
// For babies: keep existing filtering logic (only show services with explicit baby age ranges) if (BookingDto::MODE_EDIT !== $bookingDto->getMode() || null === $participant->skiPass?->id) {
if ($isBaby) { return $filteredServices;
return $this->filterServicesByAgeConstraints($services, $bookingDto, $participantIndex);
} }
// For non-babies: return all services (age-restricted ones will be marked readonly in choice_attr) $currentSkiPassId = $participant->skiPass->id;
return $services; if (false === isset($filteredServices[$currentSkiPassId])) {
$filteredServices[$currentSkiPassId] = $services[$currentSkiPassId] ?? $participant->skiPass;
}
return $filteredServices;
} }
/** /**
@@ -5,8 +5,10 @@ declare(strict_types=1);
namespace App\Tests\Form\Service; namespace App\Tests\Form\Service;
use App\BusProNet\Constants; use App\BusProNet\Constants;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Service; use App\BusProNet\Model\Service;
use App\BusProNet\Model\Travel; use App\BusProNet\Model\Travel;
use App\BusProNet\XmlParser\TravelParser;
use App\BusProNet\Utility\DirectionMapper; use App\BusProNet\Utility\DirectionMapper;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantDto;
@@ -16,6 +18,7 @@ use App\Service\InsuranceManager;
use App\Service\ServiceAvailabilityCalculator; use App\Service\ServiceAvailabilityCalculator;
use App\Service\ServiceLabelFormatter; use App\Service\ServiceLabelFormatter;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
class ParticipantFieldOptionsProviderBabyTest extends TestCase class ParticipantFieldOptionsProviderBabyTest extends TestCase
@@ -30,6 +33,45 @@ class ParticipantFieldOptionsProviderBabyTest extends TestCase
$priceCalculatorService = $this->createMock(BookingPriceCalculator::class); $priceCalculatorService = $this->createMock(BookingPriceCalculator::class);
$serviceLabelFormatter = new ServiceLabelFormatter(); $serviceLabelFormatter = new ServiceLabelFormatter();
$translator = $this->createMock(TranslatorInterface::class); $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->provider = new ParticipantFieldOptionsProvider(
$this->serviceAvailabilityCalculator, $this->serviceAvailabilityCalculator,
@@ -128,6 +170,75 @@ class ParticipantFieldOptionsProviderBabyTest extends TestCase
$this->assertContains(3, $choiceIds, 'Service without age restrictions should be shown for adult'); $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 public function testBabyGetRegularPkwInTransportation(): void
{ {
$travel = $this->createTravelWithTransportation(); $travel = $this->createTravelWithTransportation();
@@ -364,4 +475,22 @@ class ParticipantFieldOptionsProviderBabyTest extends TestCase
return $travel; return $travel;
} }
/**
* @param list<int> $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;
}
} }