createStub(VoucherValidator::class); $mockPriceCalculatorService = $this->createStub(BookingPriceCalculator::class); $mockParticipantEligibilityChecker = $this->createStub(ParticipantEligibilityChecker::class); $mockServiceAgeEvaluator = $this->createStub(ServiceAgeEvaluator::class); // Ski pass validation is exercised against the real gate, not a mock: which participant // needs a pass is exactly the behaviour under test here. $participantEligibilityChecker = new ParticipantEligibilityChecker( new ServiceAgeEvaluator(), new ServiceAvailabilityCalculator() ); // Create custom validator factory that can inject dependencies $validatorFactory = new class($mockVoucherService, $mockPriceCalculatorService, $mockParticipantEligibilityChecker, $mockServiceAgeEvaluator, $participantEligibilityChecker) implements ConstraintValidatorFactoryInterface { public function __construct( private readonly VoucherValidator $voucherService, private readonly BookingPriceCalculator $priceCalculatorService, private readonly ParticipantEligibilityChecker $participantEligibilityService, private readonly ServiceAgeEvaluator $serviceAgeEvaluator, private readonly ParticipantEligibilityChecker $realEligibilityChecker, ) { } public function getInstance(Constraint $constraint): ConstraintValidatorInterface { $className = $constraint->validatedBy(); if (PurchaseVoucherValidator::class === $className) { return new PurchaseVoucherValidator($this->voucherService); } if (PromoVoucherValidator::class === $className) { return new PromoVoucherValidator($this->voucherService, $this->priceCalculatorService); } if (EmailValidator::class === $className) { return new EmailValidator(Email::VALIDATION_MODE_HTML5); } if (SkiPassSelectionValidator::class === $className) { return new SkiPassSelectionValidator($this->realEligibilityChecker); } if (MandatoryAdditionalServicesSelectedValidator::class === $className) { return new MandatoryAdditionalServicesSelectedValidator( $this->participantEligibilityService, $this->serviceAgeEvaluator ); } return new $className(); } }; $this->validator = Validation::createValidatorBuilder() ->enableAttributeMapping() ->setConstraintValidatorFactory($validatorFactory) ->getValidator(); } public function testAdultWithUniqueEmailPassesValidation(): void { $bookingDto = $this->createBookingDtoWithParticipants([ $this->createValidAdultParticipant('unique1@example.com'), $this->createValidAdultParticipant('unique2@example.com'), ]); $wrapper = new ParticipantEditDto( participant: $bookingDto->participants[0], bookingContext: $bookingDto, ); $violations = $this->validator->validate($wrapper, null, ['booking_create']); $this->assertCount(0, $violations); } public function testAdultWithDuplicateEmailFailsValidation(): void { $bookingDto = $this->createBookingDtoWithParticipants([ $this->createAdultParticipant('duplicate@example.com'), $this->createAdultParticipant('duplicate@example.com'), ]); // Validate second participant (index 1) - applicant (index 0) is exempt from email uniqueness $wrapper = new ParticipantEditDto( participant: $bookingDto->participants[1], bookingContext: $bookingDto, ); $violations = $this->validator->validate($wrapper, null, ['booking_create']); $this->assertCount(1, $violations); $this->assertSame('participant.email', $violations->get(0)->getPropertyPath()); $this->assertSame( 'Diese E-Mail Adresse wird bereits von einem anderen Teilnehmer verwendet', $violations->get(0)->getMessage() ); } public function testChildWithDuplicateEmailPassesValidation(): void { $bookingDto = $this->createBookingDtoWithParticipants([ $this->createAdultParticipant('shared@example.com'), $this->createChildParticipant('shared@example.com'), ]); // Validate the child participant (index 1) $wrapper = new ParticipantEditDto( participant: $bookingDto->participants[1], bookingContext: $bookingDto, ); $violations = $this->validator->validate($wrapper, null, ['booking_create']); $this->assertCount(0, $violations); } public function testEmptyEmailPassesValidation(): void { $bookingDto = $this->createBookingDtoWithParticipants([ $this->createAdultParticipant(''), $this->createAdultParticipant(''), ]); $wrapper = new ParticipantEditDto( participant: $bookingDto->participants[0], bookingContext: $bookingDto, ); // Should not trigger uniqueness validation (handled by @Email and @NotBlank constraints) $violations = $this->validator->validate($wrapper, null, ['booking_create']); // May have other violations, but not uniqueness $hasUniquenessViolation = false; foreach ($violations as $violation) { if (str_contains($violation->getMessage(), 'wird bereits von einem anderen Teilnehmer verwendet')) { $hasUniquenessViolation = true; break; } } $this->assertFalse($hasUniquenessViolation, 'Should not have uniqueness violation for empty emails'); } public function testNullEmailPassesValidation(): void { $participant1 = $this->createAdultParticipant('temp@example.com'); $participant1->email = null; $participant2 = $this->createAdultParticipant('temp@example.com'); $participant2->email = null; $bookingDto = $this->createBookingDtoWithParticipants([$participant1, $participant2]); $wrapper = new ParticipantEditDto( participant: $bookingDto->participants[0], bookingContext: $bookingDto, ); // Should not trigger uniqueness validation (handled by @Email and @NotBlank constraints) $violations = $this->validator->validate($wrapper, null, ['booking_create']); // May have other violations, but not uniqueness $hasUniquenessViolation = false; foreach ($violations as $violation) { if (str_contains($violation->getMessage(), 'wird bereits von einem anderen Teilnehmer verwendet')) { $hasUniquenessViolation = true; break; } } $this->assertFalse($hasUniquenessViolation, 'Should not have uniqueness violation for null emails'); } public function testCaseInsensitiveEmailComparison(): void { $bookingDto = $this->createBookingDtoWithParticipants([ $this->createAdultParticipant('TEST@Example.COM'), $this->createAdultParticipant('test@example.com'), ]); // Validate second participant (index 1) - applicant (index 0) is exempt from email uniqueness $wrapper = new ParticipantEditDto( participant: $bookingDto->participants[1], bookingContext: $bookingDto, ); $violations = $this->validator->validate($wrapper, null, ['booking_create']); // Should have uniqueness violation (case-insensitive comparison) $uniquenessViolations = array_filter( iterator_to_array($violations), fn ($v) => str_contains($v->getMessage(), 'wird bereits von einem anderen Teilnehmer verwendet') ); $this->assertCount(1, $uniquenessViolations); } public function testWhitespaceNormalizationInEmailComparison(): void { $participant1 = $this->createAdultParticipant('test@example.com'); $participant2 = $this->createAdultParticipant('test@example.com'); $participant2->email = ' test@example.com '; $bookingDto = $this->createBookingDtoWithParticipants([$participant1, $participant2]); // Validate second participant (index 1) - applicant (index 0) is exempt from email uniqueness $wrapper = new ParticipantEditDto( participant: $bookingDto->participants[1], bookingContext: $bookingDto, ); $violations = $this->validator->validate($wrapper, null, ['booking_create']); // Should have uniqueness violation (whitespace trimmed during comparison) $uniquenessViolations = array_filter( iterator_to_array($violations), fn ($v) => str_contains($v->getMessage(), 'wird bereits von einem anderen Teilnehmer verwendet') ); $this->assertCount(1, $uniquenessViolations); } public function testMultipleDuplicatesDetectedCorrectly(): void { $bookingDto = $this->createBookingDtoWithParticipants([ $this->createAdultParticipant('same@example.com'), $this->createAdultParticipant('same@example.com'), $this->createAdultParticipant('same@example.com'), ]); // Validate first participant (applicant) - should pass (applicant is exempt from email uniqueness) $wrapper1 = new ParticipantEditDto( participant: $bookingDto->participants[0], bookingContext: $bookingDto, ); $violations1 = $this->validator->validate($wrapper1, null, ['booking_create']); $this->assertCount(0, $violations1); // Validate second participant - should fail (duplicate with applicant and third participant) $wrapper2 = new ParticipantEditDto( participant: $bookingDto->participants[1], bookingContext: $bookingDto, ); $violations2 = $this->validator->validate($wrapper2, null, ['booking_create']); $this->assertCount(1, $violations2); // Validate third participant - should fail (duplicate with applicant and second participant) $wrapper3 = new ParticipantEditDto( participant: $bookingDto->participants[2], bookingContext: $bookingDto, ); $violations3 = $this->validator->validate($wrapper3, null, ['booking_create']); $this->assertCount(1, $violations3); } public function testSelfComparisonSkipped(): void { $bookingDto = $this->createBookingDtoWithParticipants([ $this->createAdultParticipant('only@example.com'), ]); // Participant should not validate against itself $wrapper = new ParticipantEditDto( participant: $bookingDto->participants[0], bookingContext: $bookingDto, ); $violations = $this->validator->validate($wrapper, null, ['booking_create']); $this->assertCount(0, $violations); } public function testValidationInEditMode(): void { $travel = $this->createTravelOfferingSkiPasses(); $bookingDto = new BookingDto($travel, 1); // hotelId must be int // Create mock booking to simulate edit mode $mockBooking = new Booking(); $bookingDto->booking = $mockBooking; // Create valid participants with all required fields for strict validation $participant1 = $this->createAdultParticipant('duplicate@example.com'); $participant1->index = 0; $participant1->mutable = false; // Immutable - strict validation applies $participant1->mobile = '+49 123 456789'; // Required for applicant $participant1->address = new Address(); $participant1->address->street = 'Test Street 1'; $participant1->address->postCode = '12345'; $participant1->address->city = 'Test City'; $participant1->address->country = 'DE'; $participant2 = $this->createAdultParticipant('duplicate@example.com'); $participant2->index = 1; $participant2->mutable = false; $bookingDto->participants = [$participant1, $participant2]; // Validate second participant (index 1) - applicant (index 0) is exempt from email uniqueness $wrapper = new ParticipantEditDto( participant: $bookingDto->participants[1], bookingContext: $bookingDto, ); $violations = $this->validator->validate($wrapper, null, ['booking_edit']); // Should have uniqueness violation (edit mode still validates email uniqueness for non-applicants) $this->assertCount(1, $violations); } public function testMixedScenario(): void { $bookingDto = $this->createBookingDtoWithParticipants([ $this->createAdultParticipant('adult1@example.com'), // Unique - OK $this->createAdultParticipant('duplicate@example.com'), // Duplicate - ERROR $this->createChildParticipant('duplicate@example.com'), // Child with duplicate - OK $this->createAdultParticipant('duplicate@example.com'), // Duplicate - ERROR $this->createChildParticipant('kids@example.com'), // Unique child - OK $this->createAdultParticipant('adult2@example.com'), // Unique - OK ]); // Validate participant at index 0 - should pass (unique) $wrapper0 = new ParticipantEditDto($bookingDto->participants[0], $bookingDto); $violations0 = $this->validator->validate($wrapper0, null, ['booking_create']); $this->assertCount(0, $violations0); // Validate participant at index 1 - should fail (duplicate) $wrapper1 = new ParticipantEditDto($bookingDto->participants[1], $bookingDto); $violations1 = $this->validator->validate($wrapper1, null, ['booking_create']); $this->assertCount(1, $violations1); // Validate participant at index 2 - should pass (child exempt) $wrapper2 = new ParticipantEditDto($bookingDto->participants[2], $bookingDto); $violations2 = $this->validator->validate($wrapper2, null, ['booking_create']); $this->assertCount(0, $violations2); // Validate participant at index 3 - should fail (duplicate) $wrapper3 = new ParticipantEditDto($bookingDto->participants[3], $bookingDto); $violations3 = $this->validator->validate($wrapper3, null, ['booking_create']); $this->assertCount(1, $violations3); // Validate participant at index 4 - should pass (unique child) $wrapper4 = new ParticipantEditDto($bookingDto->participants[4], $bookingDto); $violations4 = $this->validator->validate($wrapper4, null, ['booking_create']); $this->assertCount(0, $violations4); // Validate participant at index 5 - should pass (unique) $wrapper5 = new ParticipantEditDto($bookingDto->participants[5], $bookingDto); $violations5 = $this->validator->validate($wrapper5, null, ['booking_create']); $this->assertCount(0, $violations5); } public function testInternalAgencyBookingSkipsEmailUniqueness(): void { $bookingDto = $this->createBookingDtoWithParticipants([ $this->createAdultParticipant('shared@ep-reisen.de'), $this->createAdultParticipant('shared@ep-reisen.de'), $this->createAdultParticipant('shared@ep-reisen.de'), ]); // Mark as internal agency booking $bookingDto->agencyCode = AgencyLoader::INTERNAL_AGENCY_CODE; // Validate second participant (non-applicant adult with duplicate email) $wrapper = new ParticipantEditDto( participant: $bookingDto->participants[1], bookingContext: $bookingDto, ); $violations = $this->validator->validate($wrapper, null, ['booking_create']); $this->assertCount(0, $violations, 'Internal agency bookings should skip email uniqueness validation'); } public function testNonAgencyBookingStillEnforcesEmailUniqueness(): void { $bookingDto = $this->createBookingDtoWithParticipants([ $this->createAdultParticipant('shared@example.com'), $this->createAdultParticipant('shared@example.com'), ]); // Non-agency booking (default agencyCode is null) $wrapper = new ParticipantEditDto( participant: $bookingDto->participants[1], bookingContext: $bookingDto, ); $violations = $this->validator->validate($wrapper, null, ['booking_create']); $this->assertCount(1, $violations, 'Non-agency bookings should still enforce email uniqueness'); } // ========================================= // 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 = $this->createTravelOfferingSkiPasses(); $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 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 = $this->createTravelOfferingSkiPasses(); $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 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 testSkiPassNotRequiredWhenTravelOffersNone(): void { $travel = $this->createTravelWithoutSkiPasses(); $bookingDto = new BookingDto($travel, 1); $participant = $this->createParticipantWithoutSkiPass('adult@example.com'); $participant->dateOfBirth = new \DateTimeImmutable('1990-01-01'); $participant->index = 0; $participant->mobile = '+49 123 456789'; $participant->address = new 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']); $skiPassViolations = array_filter( iterator_to_array($violations), fn ($v) => 'participant.skiPass' === $v->getPropertyPath() ); $this->assertCount(0, $skiPassViolations, 'A travel offering no ski passes must be bookable without one'); } public function testIneligibleParticipantIsReportedAsNotBookable(): void { // Ski passes exist, but the only one starts at 18 - a 14 year old cannot obtain any $travel = $this->createTravelOfferingSkiPasses(ageFrom: 18); $bookingDto = new BookingDto($travel, 1); $participant = $this->createParticipantWithoutSkiPass('teen@example.com'); $participant->dateOfBirth = $travel->dateFrom->modify('-14 years'); $participant->index = 0; $participant->mobile = '+49 123 456789'; $participant->address = new 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']); $skiPassViolations = array_values(array_filter( iterator_to_array($violations), fn ($v) => 'participant.skiPass' === $v->getPropertyPath() )); $this->assertCount(1, $skiPassViolations); $this->assertSame( 'Für Teilnehmer:in 1 ist keine Buchung möglich', $skiPassViolations[0]->getMessage(), 'The user must not be asked to select from a field that is not rendered' ); } public function testSkiPassValidationSkippedInEditMode(): void { $travel = $this->createTravelOfferingSkiPasses(); $bookingDto = new BookingDto($travel, 1); // Create mock booking to simulate edit mode $mockBooking = new 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 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 testEditSubmissionValidatesApplicantAddressOnlyForFirstParticipant(): void { $travel = $this->createTravelOfferingSkiPasses(); $bookingDto = new BookingDto($travel, 1); $bookingDto->booking = new Booking(); $bookingDto->agencyCode = AgencyLoader::INTERNAL_AGENCY_CODE; $participant0 = $this->createAdultParticipant('first@example.com'); $participant0->index = 0; $participant0->address = null; $participant1 = $this->createAdultParticipant('second@example.com'); $participant1->index = 1; $participant1->address = null; $bookingDto->participants = [$participant0, $participant1]; $wrapper0 = new ParticipantEditDto( participant: $bookingDto->participants[0], bookingContext: $bookingDto, ); $wrapper1 = new ParticipantEditDto( participant: $bookingDto->participants[1], bookingContext: $bookingDto, ); $violations0 = $this->validator->validate($wrapper0, null, ['booking_edit', 'strict_required']); $addressViolations0 = array_filter( iterator_to_array($violations0), fn ($v) => 'address' === $v->getPropertyPath() || 'participant.address' === $v->getPropertyPath() ); $violations1 = $this->validator->validate($wrapper1, null, ['booking_edit', 'strict_required']); $addressViolations1 = array_filter( iterator_to_array($violations1), fn ($v) => 'address' === $v->getPropertyPath() || 'participant.address' === $v->getPropertyPath() ); $this->assertCount(1, $addressViolations0, 'Applicant address should still be required in edit submission'); $this->assertCount(0, $addressViolations1, 'Non-applicant address should not be required in edit submission'); } public function testSkiPassAgeCalculatedAtTravelDate(): void { $travel = $this->createTravelOfferingSkiPasses(); $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 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'); } public function testDependentWithoutInsurancePassesWhenApplicantUsesBulkInsurance(): void { $applicant = $this->createAdultParticipant('applicant@example.com'); $applicant->bulkInsuranceBooking = true; $dependent = $this->createAdultParticipant('dependent@example.com'); $dependent->insurance = null; $bookingDto = $this->createBookingDtoWithParticipants([$applicant, $dependent]); $wrapper = new ParticipantEditDto( participant: $bookingDto->participants[1], bookingContext: $bookingDto, ); $violations = $this->validator->validate($wrapper, null, ['strict_required']); $insuranceViolations = array_filter( iterator_to_array($violations), fn ($v) => 'participant.insurance' === $v->getPropertyPath() ); $this->assertCount(0, $insuranceViolations); } public function testDependentWithoutInsuranceFailsWhenApplicantDoesNotUseBulkInsurance(): void { $applicant = $this->createAdultParticipant('applicant@example.com'); $applicant->bulkInsuranceBooking = false; $dependent = $this->createAdultParticipant('dependent@example.com'); $dependent->insurance = null; $bookingDto = $this->createBookingDtoWithParticipants([$applicant, $dependent]); $wrapper = new ParticipantEditDto( participant: $bookingDto->participants[1], bookingContext: $bookingDto, ); $violations = $this->validator->validate($wrapper, null, ['strict_required']); $insuranceViolations = array_filter( iterator_to_array($violations), fn ($v) => 'participant.insurance' === $v->getPropertyPath() ); $this->assertCount(1, $insuranceViolations); } public function testDependentWithoutInsurancePassesWhenApplicantHasFamilyInsurance(): void { $applicant = $this->createAdultParticipant('applicant@example.com'); $applicant->insurance = $this->createMockInsurance(familyInsurance: true); $dependent = $this->createAdultParticipant('dependent@example.com'); $dependent->insurance = null; $bookingDto = $this->createBookingDtoWithParticipants([$applicant, $dependent]); $wrapper = new ParticipantEditDto( participant: $bookingDto->participants[1], bookingContext: $bookingDto, ); $violations = $this->validator->validate($wrapper, null, ['strict_required']); $insuranceViolations = array_filter( iterator_to_array($violations), fn ($v) => 'participant.insurance' === $v->getPropertyPath() ); $this->assertCount(0, $insuranceViolations); } public function testDependentWithoutInsuranceFailsWhenApplicantHasNonFamilyInsurance(): void { $applicant = $this->createAdultParticipant('applicant@example.com'); $applicant->insurance = $this->createMockInsurance(familyInsurance: false); $dependent = $this->createAdultParticipant('dependent@example.com'); $dependent->insurance = null; $bookingDto = $this->createBookingDtoWithParticipants([$applicant, $dependent]); $wrapper = new ParticipantEditDto( participant: $bookingDto->participants[1], bookingContext: $bookingDto, ); $violations = $this->validator->validate($wrapper, null, ['strict_required']); $insuranceViolations = array_filter( iterator_to_array($violations), fn ($v) => 'participant.insurance' === $v->getPropertyPath() ); $this->assertCount(1, $insuranceViolations); } /** * Builds the default travel fixture: a regular travel that offers one unconstrained, * available ski pass. Ski pass validation depends on the travel actually offering * passes, so a travel without them is a distinct fixture, not the default. */ public function testChildWithoutEmailPassesStrictValidation(): void { $bookingDto = $this->createBookingDtoWithParticipants([ $this->createAdultParticipant('applicant@example.com'), $this->createChildParticipant(''), ]); $violations = $this->validateStrict($bookingDto, 1); $this->assertSame([], $this->emailRequiredMessages($violations)); } public function testChildWithNullEmailPassesStrictValidation(): void { $child = $this->createChildParticipant(''); $child->email = null; $bookingDto = $this->createBookingDtoWithParticipants([ $this->createAdultParticipant('applicant@example.com'), $child, ]); $violations = $this->validateStrict($bookingDto, 1); $this->assertSame([], $this->emailRequiredMessages($violations)); } public function testChildWithMalformedEmailStillFailsEmailFormatValidation(): void { $bookingDto = $this->createBookingDtoWithParticipants([ $this->createAdultParticipant('applicant@example.com'), $this->createChildParticipant('not-an-email'), ]); $violations = $this->validator->validate( new ParticipantEditDto( participant: $bookingDto->participants[1], bookingContext: $bookingDto, ), null, ['booking_create', 'strict_required'] ); $messages = []; foreach ($violations as $violation) { if ('participant.email' === $violation->getPropertyPath()) { $messages[] = $violation->getMessage(); } } $this->assertSame(['Bitte eine gültige E-Mail Adresse angeben'], $messages); } public function testAdultDependentWithoutEmailFailsStrictValidation(): void { $bookingDto = $this->createBookingDtoWithParticipants([ $this->createAdultParticipant('applicant@example.com'), $this->createAdultParticipant(''), ]); $violations = $this->validateStrict($bookingDto, 1); $this->assertSame(['Bitte angeben'], $this->emailRequiredMessages($violations)); } public function testDependentWithUnknownDateOfBirthAndNoEmailFailsStrictValidation(): void { $participant = $this->createAdultParticipant(''); $participant->dateOfBirth = null; $bookingDto = $this->createBookingDtoWithParticipants([ $this->createAdultParticipant('applicant@example.com'), $participant, ]); $violations = $this->validateStrict($bookingDto, 1); $this->assertSame(['Bitte angeben'], $this->emailRequiredMessages($violations)); } public function testApplicantChildWithoutEmailStillFailsStrictValidation(): void { $bookingDto = $this->createBookingDtoWithParticipants([ $this->createChildParticipant(''), $this->createAdultParticipant('other@example.com'), ]); $violations = $this->validateStrict($bookingDto, 0); $this->assertSame(['Bitte angeben'], $this->emailRequiredMessages($violations)); } public function testDependentTurningExactlySixteenRequiresEmail(): void { $participant = $this->createAdultParticipant(''); $participant->dateOfBirth = (new \DateTimeImmutable('today'))->modify('-16 years'); $bookingDto = $this->createBookingDtoWithParticipants([ $this->createAdultParticipant('applicant@example.com'), $participant, ]); $violations = $this->validateStrict($bookingDto, 1); $this->assertSame(['Bitte angeben'], $this->emailRequiredMessages($violations)); } public function testDependentOneDayShortOfSixteenDoesNotRequireEmail(): void { $participant = $this->createAdultParticipant(''); $participant->dateOfBirth = (new \DateTimeImmutable('today'))->modify('-16 years')->modify('+1 day'); $bookingDto = $this->createBookingDtoWithParticipants([ $this->createAdultParticipant('applicant@example.com'), $participant, ]); $violations = $this->validateStrict($bookingDto, 1); $this->assertSame([], $this->emailRequiredMessages($violations)); } /** * Validates one participant with the strict group active, as the booking create flow does. */ private function validateStrict(BookingDto $bookingDto, int $index): ConstraintViolationListInterface { return $this->validator->validate( new ParticipantEditDto( participant: $bookingDto->participants[$index], bookingContext: $bookingDto, ), null, ['booking_create', 'strict_required'] ); } /** * Narrows a violation list down to the "email is mandatory" messages. * * @return list */ private function emailRequiredMessages(ConstraintViolationListInterface $violations): array { $messages = []; foreach ($violations as $violation) { if ('participant.email' === $violation->getPropertyPath() && 'Bitte angeben' === $violation->getMessage()) { $messages[] = $violation->getMessage(); } } return $messages; } private function createTravelOfferingSkiPasses(?int $ageFrom = null): Travel { $travel = $this->createTravelWithoutSkiPasses(); $skiPass = new Service(); $skiPass->id = 900; $skiPass->label = 'Test Ski Pass'; $skiPass->subType = Constants::TOKEN_SKI_PASS; $skiPass->available = 10; $skiPass->price = 100.0; $skiPass->ageConstraintType = null === $ageFrom ? null : 'absolute_age'; $skiPass->ageFrom = $ageFrom; $travel->additionalServices = [900 => $skiPass]; return $travel; } private function createTravelWithoutSkiPasses(): Travel { $travel = new Travel(); $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); $travel->dateTo = new \DateTimeImmutable('2025-06-08'); return $travel; } private function createBookingDtoWithParticipants(array $participants): BookingDto { $travel = $this->createTravelOfferingSkiPasses(); $bookingDto = new BookingDto($travel, 1); // hotelId must be int // Set index for each participant foreach ($participants as $index => $participant) { $participant->index = $index; // First participant (applicant) needs address and mobile if (0 === $index) { $participant->mobile = '+49 123 456789'; if (null === $participant->address) { $participant->address = new AddressDto(); } $participant->address->street = 'Test Street 1'; $participant->address->postCode = '12345'; $participant->address->city = 'Test City'; $participant->address->country = 'DE'; } } $bookingDto->participants = $participants; return $bookingDto; } private function createAdultParticipant(string $email): ParticipantDto { $participant = $this->createValidParticipant( new \DateTimeImmutable('1990-01-01'), // Adult (over 18) $email ); // Add required fields for full validation $participant->assignedRoomId = 1; $participant->skiPass = $this->createMockService(); $participant->transportationOutbound = $this->createMockService(); $participant->transportationInbound = $this->createMockService(); $participant->insurance = $this->createMockInsurance(); return $participant; } private function createValidAdultParticipant(string $email): ParticipantDto { // Same as createAdultParticipant - all adults need full validation return $this->createAdultParticipant($email); } private function createChildParticipant(string $email): ParticipantDto { $participant = $this->createValidParticipant( new \DateTimeImmutable('2015-01-01'), // Child (under 16) $email ); // Children also need transportation (required for all participants) $participant->assignedRoomId = 1; $participant->skiPass = $this->createMockService(); $participant->transportationOutbound = $this->createMockService(); $participant->transportationInbound = $this->createMockService(); $participant->insurance = $this->createMockInsurance(); return $participant; } private function createValidParticipant(\DateTimeImmutable $dateOfBirth, string $email): ParticipantDto { $participant = new ParticipantDto(); $participant->firstName = 'John'; $participant->lastName = 'Doe'; $participant->dateOfBirth = $dateOfBirth; $participant->email = $email; return $participant; } private function createMockService(): Service { $service = new Service(); $service->id = 1; $service->label = 'Test Service'; $service->subType = 'TEST'; $service->price = 0.0; return $service; } private function createMockInsurance(bool $familyInsurance = false): Insurance { $insurance = new Insurance(); $insurance->id = '1'; $insurance->label = 'Test Insurance'; $insurance->price = 10.0; $insurance->familyInsurance = $familyInsurance; 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; } }