Files
myep/tests/Form/Model/BookingDtoTest.php
T

552 lines
19 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Form\Model;
use App\BusProNet\Model\Address;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\Travel;
use App\BusProNet\XmlLoader\AgencyLoader;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Validation;
/**
* Tests for BookingDto validation and business logic.
*
* Email uniqueness validation tests have been moved to ParticipantEditDtoTest
* as part of the refactoring to use the wrapper DTO pattern.
*/
class BookingDtoTest extends TestCase
{
public function testBookingDtoCanBeInstantiated(): void
{
$travel = new Travel();
$travel->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 = ' [email protected] ';
$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('[email protected]', $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 = '[email protected]';
$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 = '[email protected]';
$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 = ' [email protected] ';
$dto->applicant = new ParticipantDto();
$dto->applicant->email = '[email protected]';
$this->assertSame('[email protected]', $dto->getApplicantEmail());
}
public function testApplicantEmailComesFromTheApplicantBlockOnACreateModeSplit(): void
{
$dto = $this->createCreateModeDto();
$dto->participants[0]->email = '[email protected]';
$dto->applicantTravels = false;
$dto->applicant = new ParticipantDto();
$dto->applicant->email = '[email protected]';
$this->assertSame('[email protected]', $dto->getApplicantEmail());
}
public function testApplicantEmailComesFromTheAnmelderOnAnEditModeSplit(): void
{
$dto = $this->createEditModeDto(100, 200);
$dto->participants[0]->email = '[email protected]';
$dto->booking->applicant->communication->email = '[email protected]';
$this->assertSame('[email protected]', $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<string>
*/
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<string>
*/
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;
}
}