dateFrom = CarbonImmutable::now()->addMonth()->toDateTimeImmutable(); $bookingDto = new BookingDto($travel, 1); $bookingDto->booking = new Booking(); $bookingDto->agencyCode = AgencyLoader::INTERNAL_AGENCY_CODE; $bookingDto->participants = [ $this->createCompleteParticipant(0), $this->createInternalAgencyParticipantNeedingAttention(1), ]; $checker = new BookingEditPreFlightChecker($this->createValidator()); $result = $checker->findMissingValueLabelsByParticipantIndex($bookingDto); $this->assertSame( [1 => ['Vorname', 'Nachname', 'E-Mail']], $result ); } public function testScanAttentionDoesNotFlagMissingAddressForNonApplicantParticipantsInNonInternalAgencyEdit(): void { $travel = new Travel(); $travel->dateFrom = CarbonImmutable::now()->addMonth()->toDateTimeImmutable(); $bookingDto = new BookingDto($travel, 1); $bookingDto->booking = new Booking(); $bookingDto->agencyCode = 'OTHER'; $bookingDto->participants = [ $this->createCompleteParticipant(0), $this->createParticipantWithMissingAddressOnly(1), ]; $checker = new BookingEditPreFlightChecker($this->createValidator()); $result = $checker->findMissingValueLabelsByParticipantIndex($bookingDto); $this->assertSame([], $result); } public function testScanAttentionIgnoresBlankBodyDimensionsEvenWhenRentalsAreSelected(): void { $travel = new Travel(); $travel->dateFrom = CarbonImmutable::now()->addMonth()->toDateTimeImmutable(); $bookingDto = new BookingDto($travel, 1); $bookingDto->booking = new Booking(); $bookingDto->agencyCode = AgencyLoader::INTERNAL_AGENCY_CODE; $participant = $this->createCompleteParticipant(0); $participant->rentals = [$this->createRentalService()]; $participant->height = null; $participant->weight = null; $participant->shoeSize = null; $bookingDto->participants = [$participant]; $checker = new BookingEditPreFlightChecker($this->createValidator()); $result = $checker->findMissingValueLabelsByParticipantIndex($bookingDto); $this->assertSame([], $result); } public function testScanAttentionShowsBodyDimensionLabelsWhenProvidedValuesAreOutOfRange(): void { $bookingDto = $this->createBookingDto(true); $participant = $this->createCompleteParticipant(0); $participant->rentals = [$this->createRentalService()]; $participant->height = '999'; $participant->weight = '999'; $participant->shoeSize = '999'; $bookingDto->participants = [$participant]; $checker = new BookingEditPreFlightChecker($this->createValidator()); $result = $checker->findMissingValueLabelsByParticipantIndex($bookingDto); $this->assertSame( [0 => ['Körpergröße', 'Gewicht', 'Schuhgröße']], $result ); } public function testScanAttentionSuppressesOutOfRangeBodyDimensionLabelsWhenSelection1475Applies(): void { $bookingDto = $this->createBookingDto(true, [1475]); $participant = $this->createCompleteParticipant(0); $participant->rentals = [$this->createRentalService()]; $participant->height = '999'; $participant->weight = '999'; $participant->shoeSize = '999'; $bookingDto->participants = [$participant]; $checker = new BookingEditPreFlightChecker($this->createValidator()); $result = $checker->findMissingValueLabelsByParticipantIndex($bookingDto); $this->assertSame([], $result); } public function testScanAttentionShowsPickupAndDropOffLabelsWhenBusTransportRequiresThem(): void { $travel = new Travel(); $travel->dateFrom = CarbonImmutable::now()->addMonth()->toDateTimeImmutable(); $bookingDto = new BookingDto($travel, 1); $bookingDto->booking = new Booking(); $bookingDto->agencyCode = AgencyLoader::INTERNAL_AGENCY_CODE; $participant = $this->createCompleteParticipant(0); $participant->transportationOutbound = $this->createBusService(); $participant->transportationInbound = $this->createBusService(); $participant->differentDropOff = true; $participant->pickup = null; $participant->dropOff = null; $bookingDto->participants = [$participant]; $checker = new BookingEditPreFlightChecker($this->createValidator()); $result = $checker->findMissingValueLabelsByParticipantIndex($bookingDto); $this->assertSame( [0 => ['Zustieg', 'Ausstieg']], $result ); } private function createCompleteParticipant(int $index): ParticipantDto { $participant = new ParticipantDto(); $participant->index = $index; $participant->firstName = 'Max'; $participant->lastName = 'Mustermann'; $participant->gender = 'M'; $participant->nationality = 'D'; $participant->dateOfBirth = new \DateTimeImmutable('1990-01-01'); $participant->email = 'max@example.com'; $participant->assignedRoomId = 1; $participant->transportationOutbound = $this->createTransportService(); $participant->transportationInbound = $this->createTransportService(); $participant->address = new Address(); $participant->address->street = 'Test Street 1'; $participant->address->postCode = '12345'; $participant->address->city = 'Test City'; $participant->address->country = 'DE'; return $participant; } private function createInternalAgencyParticipantNeedingAttention(int $index): ParticipantDto { $participant = $this->createCompleteParticipant($index); $participant->firstName = null; $participant->lastName = null; $participant->email = null; $participant->address->street = null; return $participant; } private function createParticipantWithMissingAddressOnly(int $index): ParticipantDto { $participant = $this->createCompleteParticipant($index); $participant->email = sprintf('other%d@example.com', $index); $participant->address = new Address(); return $participant; } private function createTransportService(): Service { $service = new Service(); $service->subType = 'TRAIN'; return $service; } private function createBusService(): Service { $service = new Service(); $service->subType = 'BUS'; return $service; } private function createRentalService(): Service { $service = new Service(); $service->subType = 'VER'; return $service; } private function createValidator(): ValidatorInterface { $voucherValidator = $this->createStub(VoucherValidator::class); $bookingPriceCalculator = $this->createStub(BookingPriceCalculator::class); $participantEligibilityChecker = $this->createStub(ParticipantEligibilityChecker::class); $participantEligibilityChecker ->method('isParticipantEligible') ->willReturn(false); $validatorFactory = new class($voucherValidator, $bookingPriceCalculator, $participantEligibilityChecker, new ServiceAgeEvaluator()) implements ConstraintValidatorFactoryInterface { public function __construct( private readonly VoucherValidator $voucherValidator, private readonly BookingPriceCalculator $bookingPriceCalculator, private readonly ParticipantEligibilityChecker $participantEligibilityChecker, private readonly ServiceAgeEvaluator $serviceAgeEvaluator, ) { } public function getInstance(Constraint $constraint): ConstraintValidatorInterface { $className = $constraint->validatedBy(); if (EmailValidator::class === $className) { return new EmailValidator(Email::VALIDATION_MODE_HTML5); } if (PurchaseVoucherValidator::class === $className) { return new PurchaseVoucherValidator($this->voucherValidator); } if (PromoVoucherValidator::class === $className) { return new PromoVoucherValidator($this->voucherValidator, $this->bookingPriceCalculator); } if (MandatoryAdditionalServicesSelectedValidator::class === $className) { return new MandatoryAdditionalServicesSelectedValidator( $this->participantEligibilityChecker, $this->serviceAgeEvaluator ); } if (SkiPassSelectionValidator::class === $className) { return new SkiPassSelectionValidator($this->participantEligibilityChecker); } return new $className(); } }; return Validation::createValidatorBuilder() ->enableAttributeMapping() ->setConstraintValidatorFactory($validatorFactory) ->getValidator(); } /** * @param list $selectionIds */ private function createBookingDto(bool $internalAgency, array $selectionIds = []): BookingDto { $travel = new Travel(); $travel->dateFrom = CarbonImmutable::now()->addMonth()->toDateTimeImmutable(); $this->applySelectionIds($travel, $selectionIds); $bookingDto = new BookingDto($travel, 1); $bookingDto->booking = new Booking(); $bookingDto->agencyCode = $internalAgency ? AgencyLoader::INTERNAL_AGENCY_CODE : 'OTHER'; return $bookingDto; } public function testScanAttentionReturnsEmptyArrayForCreateMode(): void { $travel = new Travel(); $travel->dateFrom = CarbonImmutable::now()->addMonth()->toDateTimeImmutable(); $bookingDto = new BookingDto($travel, 1); // No $bookingDto->booking set → getMode() returns MODE_CREATE $participant = $this->createCompleteParticipant(0); $participant->firstName = null; $participant->lastName = null; $bookingDto->participants = [$participant]; $checker = new BookingEditPreFlightChecker($this->createValidator()); $this->assertSame([], $checker->findMissingValueLabelsByParticipantIndex($bookingDto)); } public function testScanAttentionSkipsCanceledParticipants(): void { $bookingDto = $this->createBookingDto(true); $canceledParticipant = $this->createCompleteParticipant(1); $canceledParticipant->status = 'S'; $canceledParticipant->firstName = null; $canceledParticipant->lastName = null; $canceledParticipant->email = null; $bookingDto->participants = [ $this->createCompleteParticipant(0), $canceledParticipant, ]; $checker = new BookingEditPreFlightChecker($this->createValidator()); $this->assertSame([], $checker->findMissingValueLabelsByParticipantIndex($bookingDto)); } public function testScanAttentionFlagsEmailUniquenessViolationForDuplicateAdultEmail(): void { $bookingDto = $this->createBookingDto(false); // All three participants share the same email address (from createCompleteParticipant). // Index 0 (applicant) is exempt; indices 1 and 2 each see a duplicate and are flagged. $bookingDto->participants = [ $this->createCompleteParticipant(0), $this->createCompleteParticipant(1), $this->createCompleteParticipant(2), ]; $checker = new BookingEditPreFlightChecker($this->createValidator()); $this->assertSame( [ 1 => ['E-Mail'], 2 => ['E-Mail'], ], $checker->findMissingValueLabelsByParticipantIndex($bookingDto) ); } public function testScanAttentionCoversRemainingPropertyLabels(): void { $bookingDto = $this->createBookingDto(true); $participant = $this->createCompleteParticipant(0); $participant->gender = null; $participant->nationality = null; $participant->dateOfBirth = null; $participant->assignedRoomId = null; $participant->transportationOutbound = null; $participant->transportationInbound = null; $bookingDto->participants = [$participant]; $checker = new BookingEditPreFlightChecker($this->createValidator()); $this->assertSame( [0 => ['Anrede', 'Nationalität', 'Geburtsdatum', 'Zimmer', 'Anreise', 'Abreise']], $checker->findMissingValueLabelsByParticipantIndex($bookingDto) ); } public function testScanAttentionFlagsAddressFieldLabelsForApplicantInNonInternalAgencyBooking(): void { $bookingDto = $this->createBookingDto(false); $participant = $this->createCompleteParticipant(0); $participant->address = new Address(); $bookingDto->participants = [$participant]; $checker = new BookingEditPreFlightChecker($this->createValidator()); $this->assertSame( [0 => ['Straße', 'PLZ', 'Ort', 'Land']], $checker->findMissingValueLabelsByParticipantIndex($bookingDto) ); } /** * @param list $selectionIds */ private function applySelectionIds(Travel $travel, array $selectionIds): void { if ([] === $selectionIds) { return; } $group = new CrmSelectionGroup(); $group->id = 1; $group->selections = []; foreach ($selectionIds as $selectionId) { $selection = new CrmSelection(); $selection->id = $selectionId; $group->selections[] = $selection; } $travel->selectionGroups = [1 => $group]; } }