From f705a851c2ac00380c880f2a2deaf813028c78fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 25 Feb 2026 17:09:13 +0100 Subject: [PATCH] fix: validate mandatory services selection --- src/Form/Model/ParticipantEditDto.php | 1 + .../MandatoryAdditionalServicesSelected.php | 25 +++ ...oryAdditionalServicesSelectedValidator.php | 86 +++++++++ ...dditionalServicesSelectedValidatorTest.php | 179 ++++++++++++++++++ 4 files changed, 291 insertions(+) create mode 100644 src/Validator/Constraints/MandatoryAdditionalServicesSelected.php create mode 100644 src/Validator/Constraints/MandatoryAdditionalServicesSelectedValidator.php create mode 100644 tests/Validator/Constraints/MandatoryAdditionalServicesSelectedValidatorTest.php diff --git a/src/Form/Model/ParticipantEditDto.php b/src/Form/Model/ParticipantEditDto.php index 30b6afc..8631b54 100644 --- a/src/Form/Model/ParticipantEditDto.php +++ b/src/Form/Model/ParticipantEditDto.php @@ -19,6 +19,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; */ #[AppAssert\PurchaseVoucher(groups: ['booking_create', 'booking_edit'])] #[AppAssert\PromoVoucher(groups: ['booking_create', 'booking_edit'])] +#[AppAssert\MandatoryAdditionalServicesSelected(groups: ['booking_create', 'booking_edit'])] class ParticipantEditDto { public function __construct( diff --git a/src/Validator/Constraints/MandatoryAdditionalServicesSelected.php b/src/Validator/Constraints/MandatoryAdditionalServicesSelected.php new file mode 100644 index 0000000..4e93e5a --- /dev/null +++ b/src/Validator/Constraints/MandatoryAdditionalServicesSelected.php @@ -0,0 +1,25 @@ +participant; + if (true === $participant->isCanceled()) { + return; + } + + $participantIndex = $participant->index ?? 0; + if (false === $this->participantEligibilityService->isParticipantEligible($value->bookingContext, $participantIndex)) { + return; + } + + $mandatoryAdditionalServices = array_filter( + $value->bookingContext->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_ADDITIONAL), + function (Service $service) use ($value, $participantIndex): bool { + if (true !== $service->mandatory) { + return false; + } + + if (true === $this->serviceAgeEvaluator->canEvaluate($service)) { + return $this->serviceAgeEvaluator->isServiceAvailableForParticipant( + $service, + $value->bookingContext, + $participantIndex + ); + } + + return true; + } + ); + + if (true === empty($mandatoryAdditionalServices)) { + return; + } + + $selectedAdditionalServiceIds = array_map( + static fn (Service $service): int => $service->id, + $participant->additionalServices + ); + + foreach ($mandatoryAdditionalServices as $mandatoryService) { + if (false === in_array($mandatoryService->id, $selectedAdditionalServiceIds, true)) { + $this->context->buildViolation($constraint->message) + ->atPath('participant.additionalServices') + ->addViolation(); + + return; + } + } + } +} + diff --git a/tests/Validator/Constraints/MandatoryAdditionalServicesSelectedValidatorTest.php b/tests/Validator/Constraints/MandatoryAdditionalServicesSelectedValidatorTest.php new file mode 100644 index 0000000..d186d65 --- /dev/null +++ b/tests/Validator/Constraints/MandatoryAdditionalServicesSelectedValidatorTest.php @@ -0,0 +1,179 @@ +participantEligibilityService = $this->createMock(ParticipantEligibilityService::class); + $this->participantEligibilityService + ->method('isParticipantEligible') + ->willReturnCallback(fn (): bool => $this->participantEligible); + + return new MandatoryAdditionalServicesSelectedValidator( + $this->participantEligibilityService, + new ServiceAgeEvaluator() + ); + } + + public function testMissingMandatoryAdditionalServiceCreatesViolation(): void + { + $participantEditDto = $this->createWrapperWithParticipant( + $this->createParticipant(), + [ + $this->createMandatoryAdditionalService(194695, 'Ortstaxe'), + ] + ); + + $this->validator->validate($participantEditDto, new MandatoryAdditionalServicesSelected()); + + $this->buildViolation('Bitte wähle alle Pflichtleistungen aus.') + ->atPath('property.path.participant.additionalServices') + ->assertRaised(); + } + + public function testSelectedMandatoryAdditionalServicePassesValidation(): void + { + $mandatoryService = $this->createMandatoryAdditionalService(194695, 'Ortstaxe'); + + $participant = $this->createParticipant(); + $participant->additionalServices = [$mandatoryService]; + + $participantEditDto = $this->createWrapperWithParticipant($participant, [$mandatoryService]); + + $this->validator->validate($participantEditDto, new MandatoryAdditionalServicesSelected()); + + $this->assertNoViolation(); + } + + public function testAgeEligibleMandatoryServiceMissingCreatesViolation(): void + { + $adultMandatory = $this->createMandatoryAdditionalService( + id: 2, + label: 'Adult Mandatory', + ageConstraintType: 'absolute_age', + ageFrom: 18, + ageTo: 99 + ); + $teenMandatory = $this->createMandatoryAdditionalService( + id: 1, + label: 'Teen Mandatory', + ageConstraintType: 'absolute_age', + ageFrom: 12, + ageTo: 17 + ); + + $participant = $this->createParticipant(); + $participant->additionalServices = [$teenMandatory]; + $participant->dateOfBirth = new \DateTimeImmutable('2000-01-01'); + + $participantEditDto = $this->createWrapperWithParticipant($participant, [$teenMandatory, $adultMandatory]); + + $this->validator->validate($participantEditDto, new MandatoryAdditionalServicesSelected()); + + $this->buildViolation('Bitte wähle alle Pflichtleistungen aus.') + ->atPath('property.path.participant.additionalServices') + ->assertRaised(); + } + + public function testCanceledParticipantSkipsValidation(): void + { + $participant = $this->createParticipant(); + $participant->status = 'S'; + + $participantEditDto = $this->createWrapperWithParticipant( + $participant, + [$this->createMandatoryAdditionalService(194695, 'Ortstaxe')] + ); + + $this->validator->validate($participantEditDto, new MandatoryAdditionalServicesSelected()); + + $this->assertNoViolation(); + } + + public function testIneligibleParticipantSkipsValidation(): void + { + $this->participantEligible = false; + + $participantEditDto = $this->createWrapperWithParticipant( + $this->createParticipant(), + [$this->createMandatoryAdditionalService(194695, 'Ortstaxe')] + ); + + $this->validator->validate($participantEditDto, new MandatoryAdditionalServicesSelected()); + + $this->assertNoViolation(); + } + + private function createWrapperWithParticipant(ParticipantDto $participant, array $additionalServices): ParticipantEditDto + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('2026-04-11'); + $travel->additionalServices = array_reduce( + $additionalServices, + function (array $carry, Service $service): array { + $carry[$service->id] = $service; + + return $carry; + }, + [] + ); + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$participant]; + + return new ParticipantEditDto( + participant: $participant, + bookingContext: $bookingDto, + ); + } + + private function createParticipant(): ParticipantDto + { + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->status = 'F'; + $participant->dateOfBirth = new \DateTimeImmutable('2005-01-01'); + $participant->additionalServices = []; + + return $participant; + } + + private function createMandatoryAdditionalService( + int $id, + string $label, + ?string $ageConstraintType = null, + ?int $ageFrom = null, + ?int $ageTo = null, + ): Service { + $service = new Service(); + $service->id = $id; + $service->label = $label; + $service->subType = Constants::TOKEN_ADDITIONAL; + $service->mandatory = true; + $service->ageConstraintType = $ageConstraintType; + $service->ageFrom = $ageFrom; + $service->ageTo = $ageTo; + + return $service; + } +} +