feat: split booking between participants and applicant

addresses #869f2q1vu
This commit is contained in:
fromme committed 2026-10-19 09:18:22 +02:00
1 parent 2d49212b08
commit 1ff293f41f
44 files changed
+2024 -316

No files matched your search

+241 -1
View File
@@ -4,11 +4,15 @@ declare(strict_types=1);
namespace App\Tests\Form\Model;
use App\BusProNet\Model\Travel;
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.
@@ -128,4 +132,240 @@ class BookingDtoTest extends TestCase
$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 testInternalAgencyBookingNeverHasTheFirstParticipantAsApplicant(): void
{
$dto = $this->createCreateModeDto();
$dto->agencyCode = AgencyLoader::INTERNAL_AGENCY_CODE;
$this->assertFalse(
$dto->isFirstParticipantApplicant(),
'Their applicant is a staff member who never travels'
);
}
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);
}
private function createAddress(): Address
{
$address = new Address();
$address->street = 'Probeweg 7';
$address->postCode = '99999';
$address->city = 'MusterOrt';
$address->country = 'D';
return $address;
}
}
+9 -21
View File
@@ -205,46 +205,34 @@ class ParticipantDtoTest extends TestCase
return $insurance;
}
public function testRequiresOwnEmailIsFalseForNonApplicantChild(): void
public function testIsChildIsTrueBelowTheAgeThreshold(): void
{
$participant = new ParticipantDto();
$participant->index = 1;
$participant->dateOfBirth = new \DateTimeImmutable('2015-01-01');
$this->assertFalse($participant->requiresOwnEmail());
$this->assertTrue($participant->isChild());
}
public function testRequiresOwnEmailIsTrueForNonApplicantAdult(): void
public function testIsChildIsFalseForAnAdult(): void
{
$participant = new ParticipantDto();
$participant->index = 1;
$participant->dateOfBirth = new \DateTimeImmutable('1990-01-01');
$this->assertTrue($participant->requiresOwnEmail());
$this->assertFalse($participant->isChild());
}
public function testRequiresOwnEmailIsTrueForTheApplicantEvenAsAChild(): void
{
$participant = new ParticipantDto();
$participant->index = 0;
$participant->dateOfBirth = new \DateTimeImmutable('2015-01-01');
$this->assertTrue(
$participant->requiresOwnEmail(),
'The applicant is the booking contact and always has to be reachable'
);
}
public function testRequiresOwnEmailTreatsUnknownAgeAsAdult(): void
public function testIsChildTreatsUnknownAgeAsAdult(): void
{
$participant = new ParticipantDto();
$participant->index = 1;
$participant->dateOfBirth = null;
$this->assertTrue($participant->requiresOwnEmail());
$this->assertFalse($participant->isChild(), 'An unknown date of birth counts as an adult');
}
public function testRequiresOwnEmailBoundaryAtChildAgeThreshold(): void
public function testIsChildBoundaryAtAgeThreshold(): void
{
$exactlyThreshold = new ParticipantDto();
$exactlyThreshold->index = 1;
@@ -257,7 +245,7 @@ class ParticipantDtoTest extends TestCase
->modify(sprintf('-%d years', ParticipantDto::CHILD_AGE_THRESHOLD))
->modify('+1 day');
$this->assertTrue($exactlyThreshold->requiresOwnEmail());
$this->assertFalse($oneDayShort->requiresOwnEmail());
$this->assertFalse($exactlyThreshold->isChild());
$this->assertTrue($oneDayShort->isChild());
}
}
+26 -6
View File
@@ -877,7 +877,7 @@ class ParticipantEditDtoTest extends TestCase
* 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
public function testChildWithoutEmailFailsStrictValidation(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createAdultParticipant('[email protected]'),
@@ -886,10 +886,14 @@ class ParticipantEditDtoTest extends TestCase
$violations = $this->validateStrict($bookingDto, 1);
$this->assertSame([], $this->emailRequiredMessages($violations));
$this->assertSame(
['Bitte angeben'],
$this->emailRequiredMessages($violations),
'BusPro requires a Mail-Adresse for every participant, children included'
);
}
public function testChildWithNullEmailPassesStrictValidation(): void
public function testChildWithNullEmailFailsStrictValidation(): void
{
$child = $this->createChildParticipant('');
$child->email = null;
@@ -901,7 +905,23 @@ class ParticipantEditDtoTest extends TestCase
$violations = $this->validateStrict($bookingDto, 1);
$this->assertSame([], $this->emailRequiredMessages($violations));
$this->assertSame(['Bitte angeben'], $this->emailRequiredMessages($violations));
}
public function testChildSharingTheApplicantsEmailPassesStrictValidation(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createAdultParticipant('[email protected]'),
$this->createChildParticipant('[email protected]'),
]);
$violations = $this->validateStrict($bookingDto, 1);
$this->assertSame(
[],
$this->emailRequiredMessages($violations),
'A child inherits the applicant address, which BusPro accepts'
);
}
public function testChildWithMalformedEmailStillFailsEmailFormatValidation(): void
@@ -984,7 +1004,7 @@ class ParticipantEditDtoTest extends TestCase
$this->assertSame(['Bitte angeben'], $this->emailRequiredMessages($violations));
}
public function testDependentOneDayShortOfSixteenDoesNotRequireEmail(): void
public function testDependentOneDayShortOfSixteenStillRequiresEmail(): void
{
$participant = $this->createAdultParticipant('');
$participant->dateOfBirth = (new \DateTimeImmutable('today'))->modify('-16 years')->modify('+1 day');
@@ -996,7 +1016,7 @@ class ParticipantEditDtoTest extends TestCase
$violations = $this->validateStrict($bookingDto, 1);
$this->assertSame([], $this->emailRequiredMessages($violations));
$this->assertSame(['Bitte angeben'], $this->emailRequiredMessages($violations));
}
/**