dateFrom = new \DateTimeImmutable('2025-06-01'); $travel->dateTo = new \DateTimeImmutable('2025-06-08'); $bookingDto = new BookingDto($travel, 1); $this->assertInstanceOf(BookingDto::class, $bookingDto); $this->assertSame($travel, $bookingDto->travel); $this->assertSame(BookingDto::MODE_CREATE, $bookingDto->getMode()); } public function testUnserializeHandlesIntTravelId(): void { $travel = new Travel(); $travel->id = 42; $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); $travel->dateTo = new \DateTimeImmutable('2025-06-08'); $dto = new BookingDto($travel, 1); $serialized = serialize($dto); $restored = unserialize($serialized); $this->assertInstanceOf(BookingDto::class, $restored); $this->assertSame(42, $restored->travel->id); } public function testUnserializeHandlesLegacyTravelObject(): void { $travel = new Travel(); $travel->id = 42; $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); $travel->dateTo = new \DateTimeImmutable('2025-06-08'); // Simulate old session format where 'travel' is a full Travel object $data = [ 'travel' => $travel, 'hotelId' => 1, ]; $dto = new BookingDto(new Travel(), 1); $dto->__unserialize($data); $this->assertSame($travel, $dto->travel); $this->assertSame(42, $dto->travel->id); } public function testUnserializeNormalizesLegacyBodyDimensionsOnParticipants(): void { $travel = new Travel(); $travel->id = 42; $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); $travel->dateTo = new \DateTimeImmutable('2025-06-08'); $participant = new ParticipantDto(); $participant->height = '149-157'; $participant->weight = '75'; $participant->shoeSize = '043'; $dto = new BookingDto($travel, 1); $dto->participants = [$participant]; $restored = unserialize(serialize($dto)); $this->assertInstanceOf(BookingDto::class, $restored); $this->assertNull($restored->participants[0]->height); $this->assertSame('75', $restored->participants[0]->weight); $this->assertSame('43', $restored->participants[0]->shoeSize); } public function testUnserializeNormalizesLegacyParticipantStringsOnLoad(): void { $travel = new Travel(); $travel->id = 42; $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); $travel->dateTo = new \DateTimeImmutable('2025-06-08'); $participant = new ParticipantDto(); $participant->firstName = ' Max '; $participant->lastName = ' Mustermann '; $participant->email = ' max@example.com '; $participant->remarksRoom = ' '; $participant->licensePlate = ' B-AB 123 '; $participant->purchaseVoucherCode = ' ABC-123 '; $participant->promoVoucherCode = ' PROMO '; $participant->address = new Address(); $participant->address->street = ' Test Street 1 '; $participant->address->postCode = ' 12345 '; $participant->address->city = ' Test City '; $participant->address->country = ' DE '; $dto = new BookingDto($travel, 1); $dto->participants = [$participant]; $restored = unserialize(serialize($dto)); $this->assertInstanceOf(BookingDto::class, $restored); $this->assertSame('Max', $restored->participants[0]->firstName); $this->assertSame('Mustermann', $restored->participants[0]->lastName); $this->assertSame('max@example.com', $restored->participants[0]->email); $this->assertNull($restored->participants[0]->remarksRoom); $this->assertSame('B-AB 123', $restored->participants[0]->licensePlate); $this->assertSame('ABC-123', $restored->participants[0]->purchaseVoucherCode); $this->assertSame('PROMO', $restored->participants[0]->promoVoucherCode); $this->assertSame('Test Street 1', $restored->participants[0]->address->street); $this->assertSame('12345', $restored->participants[0]->address->postCode); $this->assertSame('Test City', $restored->participants[0]->address->city); $this->assertSame('DE', $restored->participants[0]->address->country); } public function testFirstParticipantIsTheApplicantByDefaultInCreateMode(): void { $dto = $this->createCreateModeDto(); $this->assertTrue($dto->isFirstParticipantApplicant()); } public function testFirstParticipantIsNotTheApplicantWhenTheApplicantDoesNotTravel(): void { $dto = $this->createCreateModeDto(); $dto->applicantTravels = false; $this->assertFalse($dto->isFirstParticipantApplicant()); } public function testRestoringTheTravellingApplicantEndsTheSplit(): void { $dto = $this->createCreateModeDto(); $dto->applicantTravels = false; $applicant = new ParticipantDto(); $applicant->firstName = 'Elternteil'; $dto->applicant = $applicant; $child = new ParticipantDto(); $child->index = 0; $child->firstName = 'Kind'; $child->assignedRoomId = 1408; $sibling = new ParticipantDto(); $sibling->index = 1; $dto->participants = [$child, $sibling]; $dto->restoreTravellingApplicant(); $this->assertTrue($dto->applicantTravels); $this->assertNull($dto->applicant); $this->assertSame($applicant, $dto->participants[0]); $this->assertSame(0, $applicant->index); $this->assertSame(1408, $applicant->assignedRoomId, 'The slot keeps its room'); $this->assertSame($sibling, $dto->participants[1]); } public function testRestoringWithoutAnApplicantRecordKeepsTheFirstParticipant(): void { $dto = $this->createCreateModeDto(); $dto->applicantTravels = false; $traveller = new ParticipantDto(); $traveller->index = 0; $dto->participants = [$traveller]; $dto->restoreTravellingApplicant(); $this->assertTrue($dto->applicantTravels); $this->assertSame($traveller, $dto->participants[0]); } public function testInternalAgencyBookingNeverHasTheFirstParticipantAsApplicantInEditMode(): void { $dto = $this->createEditModeDto(100, 100); $dto->agencyCode = AgencyLoader::INTERNAL_AGENCY_CODE; $this->assertFalse( $dto->isFirstParticipantApplicant(), 'Their applicant is a customer who is not the first traveller, whatever the ids say' ); } public function testAgencyCodeDoesNotDecideAnythingInCreateMode(): void { $dto = $this->createCreateModeDto(); $dto->agencyCode = AgencyLoader::INTERNAL_AGENCY_CODE; $this->assertTrue( $dto->isFirstParticipantApplicant(), 'Internal agency bookings are never created here, so the code is only passed on' ); } public function testEditModeDerivesTheSplitFromPersonIds(): void { $dto = $this->createEditModeDto(applicantPersonId: 4711, firstParticipantPersonId: 4711); $this->assertTrue($dto->isFirstParticipantApplicant()); $split = $this->createEditModeDto(applicantPersonId: 4711, firstParticipantPersonId: 4712); $this->assertFalse($split->isFirstParticipantApplicant()); } public function testEditModeTreatsAnUnknownPersonIdAsTheSamePerson(): void { $dto = $this->createEditModeDto(applicantPersonId: 4711, firstParticipantPersonId: null); $this->assertTrue( $dto->isFirstParticipantApplicant(), 'Incomplete BPN data must fall back to the established, locked-down behaviour' ); } public function testFamilyBookingRequiresTheApplicantToTravel(): void { $dto = $this->createCreateModeDto(); $dto->participants = [ $this->createParticipantAged(40), $this->createParticipantAged(12), ]; $this->assertTrue($dto->isFamilyBooking()); $dto->applicantTravels = false; $this->assertFalse( $dto->isFamilyBooking(), 'Family insurance is not offered when the applicant does not travel' ); } public function testAdultSiblingWithAChildIsNotAFamilyBookingOnceTheApplicantStaysHome(): void { $dto = $this->createCreateModeDto(); $dto->applicantTravels = false; $dto->participants = [ $this->createParticipantAged(18), $this->createParticipantAged(12), ]; $this->assertFalse( $dto->isFamilyBooking(), 'The age distribution alone would still qualify, which is why the flag is checked first' ); } public function testOldSessionsWithoutTheApplicantFieldsRestoreToTheDefaults(): void { $travel = new Travel(); $travel->id = 42; $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); $dto = new BookingDto($travel, 1); $data = $dto->__serialize(); unset($data['applicantTravels'], $data['applicant']); $restored = new BookingDto($travel, 1); $restored->__unserialize($data); $this->assertTrue($restored->applicantTravels); $this->assertNull($restored->applicant); } private function createCreateModeDto(): BookingDto { $travel = new Travel(); $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); $travel->dateTo = new \DateTimeImmutable('2025-06-08'); $dto = new BookingDto($travel, 1); $dto->participants = [$this->createParticipantAged(40)]; return $dto; } private function createEditModeDto(?int $applicantPersonId, ?int $firstParticipantPersonId): BookingDto { $dto = $this->createCreateModeDto(); $applicant = new PersonalData(); $applicant->personId = $applicantPersonId; $booking = new Booking(); $booking->applicant = $applicant; $dto->booking = $booking; $dto->participants[0]->personId = $firstParticipantPersonId; return $dto; } private function createParticipantAged(int $years): ParticipantDto { $participant = new ParticipantDto(); $participant->dateOfBirth = (new \DateTimeImmutable('2025-06-01'))->modify(sprintf('-%d years', $years)); return $participant; } public function testApplicantRulesDoNotLeakOntoTravellers(): void { $dto = $this->createCreateModeDto(); $dto->applicantTravels = false; $dto->participants = [$this->createParticipantAged(15)]; $dto->participants[0]->index = 0; $applicant = new ParticipantDto(); $applicant->firstName = 'Probe'; $applicant->lastName = 'Elternteil'; $applicant->gender = 'W'; $applicant->nationality = 'D'; $applicant->email = 'elternteil@example.com'; $applicant->mobile = '0171/222222'; $applicant->dateOfBirth = new \DateTimeImmutable('1981-03-16'); $applicant->address = $this->createAddress(); $dto->applicant = $applicant; $validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator(); $violations = $validator->validate($dto, null, ['booking_applicant']); $paths = []; foreach ($violations as $violation) { $paths[] = $violation->getPropertyPath(); } $this->assertSame( [], $paths, 'A 15-year-old traveller must not be held to the applicant rules through the group cascade' ); } public function testIncompleteApplicantIsReportedUnderTheApplicantPathOnlyOnce(): void { $dto = $this->createCreateModeDto(); $dto->applicantTravels = false; $dto->participants[0]->index = 0; $applicant = new ParticipantDto(); $applicant->dateOfBirth = new \DateTimeImmutable('1981-03-16'); $dto->applicant = $applicant; $validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator(); $violations = $validator->validate($dto, null, ['booking_applicant']); $paths = []; foreach ($violations as $violation) { $paths[] = $violation->getPropertyPath(); } $this->assertContains('applicant.firstName', $paths); $this->assertContains( 'applicant.address.street', $paths, 'A fresh ParticipantDto carries an empty Address, so the gaps surface per subfield' ); $this->assertSame( array_unique($paths), $paths, 'Explicit delegation plus a cascade would report each gap twice' ); } public function testMinorApplicantIsRejected(): void { $dto = $this->createCreateModeDto(); $dto->applicantTravels = false; $dto->participants[0]->index = 0; $applicant = new ParticipantDto(); $applicant->firstName = 'Probe'; $applicant->lastName = 'Elternteil'; $applicant->gender = 'W'; $applicant->nationality = 'D'; $applicant->email = 'elternteil@example.com'; $applicant->mobile = '0171/222222'; $applicant->dateOfBirth = (new \DateTimeImmutable('today'))->modify('-17 years'); $applicant->address = $this->createAddress(); $dto->applicant = $applicant; $validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator(); $violations = $validator->validate($dto, null, ['booking_applicant']); $messages = []; foreach ($violations as $violation) { $messages[] = $violation->getMessage(); } $this->assertContains('Die anmeldende Person muss volljährig sein.', $messages); } public function testEditModeDoesNotAskASplitBookingsFirstParticipantForAnAddress(): void { $dto = $this->createEditModeDto(100, 200); $dto->participants[0]->index = 0; $dto->participants[0]->address = null; $this->assertSame([], $this->collectFirstParticipantAddressPaths($dto)); } public function testEditModeStillAsksTheApplicantForAnAddress(): void { $dto = $this->createEditModeDto(100, 100); $dto->participants[0]->index = 0; $dto->participants[0]->address = null; $this->assertSame(['participants[0].address'], $this->collectFirstParticipantAddressPaths($dto)); } public function testChildApplicantWithoutEmailIsReportedOnce(): void { $dto = $this->createCreateModeDto(); $dto->participants[0] = $this->createParticipantWithoutEmail(10); $this->assertSame( ['participants[0].email'], $this->collectFirstParticipantEmailPaths($dto), 'The applicant is the booking contact and needs an email at any age' ); } public function testChildAtIndexZeroOfASplitBookingMayLeaveTheEmailBlank(): void { $dto = $this->createCreateModeDto(); $dto->applicantTravels = false; $dto->participants[0] = $this->createParticipantWithoutEmail(10); $this->assertSame([], $this->collectFirstParticipantEmailPaths($dto)); } public function testAdultAtIndexZeroOfASplitBookingStillNeedsAnEmail(): void { $dto = $this->createCreateModeDto(); $dto->applicantTravels = false; $dto->participants[0] = $this->createParticipantWithoutEmail(30); $this->assertSame(['participants[0].email'], $this->collectFirstParticipantEmailPaths($dto)); } public function testApplicantEmailComesFromParticipantZeroWhenTheApplicantTravels(): void { $dto = $this->createCreateModeDto(); $dto->participants[0]->email = ' tn1@example.com '; $dto->applicant = new ParticipantDto(); $dto->applicant->email = 'stale@example.com'; $this->assertSame('tn1@example.com', $dto->getApplicantEmail()); } public function testApplicantEmailComesFromTheApplicantBlockOnACreateModeSplit(): void { $dto = $this->createCreateModeDto(); $dto->participants[0]->email = 'kind@example.com'; $dto->applicantTravels = false; $dto->applicant = new ParticipantDto(); $dto->applicant->email = 'parent@example.com'; $this->assertSame('parent@example.com', $dto->getApplicantEmail()); } public function testApplicantEmailComesFromTheAnmelderOnAnEditModeSplit(): void { $dto = $this->createEditModeDto(100, 200); $dto->participants[0]->email = 'kind@example.com'; $dto->booking->applicant->communication->email = 'parent@example.com'; $this->assertSame('parent@example.com', $dto->getApplicantEmail()); } /** * Ages relative to today, since isChild() measures against the current date. */ private function createParticipantWithoutEmail(int $years): ParticipantDto { $participant = new ParticipantDto(); $participant->index = 0; $participant->dateOfBirth = (new \DateTimeImmutable('today'))->modify(sprintf('-%d years', $years)); $participant->address = $this->createAddress(); return $participant; } /** * @return list */ private function collectFirstParticipantEmailPaths(BookingDto $dto): array { $validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator(); $violations = $validator->validate($dto, null, ['booking_create', 'strict_required']); $paths = []; foreach ($violations as $violation) { if ('participants[0].email' === $violation->getPropertyPath()) { $paths[] = $violation->getPropertyPath(); } } return $paths; } /** * @return list */ private function collectFirstParticipantAddressPaths(BookingDto $dto): array { $validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator(); $violations = $validator->validate($dto, null, ['booking_edit', 'strict_required']); $paths = []; foreach ($violations as $violation) { if (str_starts_with($violation->getPropertyPath(), 'participants[0].address')) { $paths[] = $violation->getPropertyPath(); } } return $paths; } private function createAddress(): Address { $address = new Address(); $address->street = 'Probeweg 7'; $address->postCode = '99999'; $address->city = 'MusterOrt'; $address->country = 'D'; return $address; } }