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

@@ -155,6 +155,79 @@ class BookingDataProcessorTest extends TestCase
$this->assertSame(194672, $dto->participants[0]->veg?->id);
}
public function testApplicantAddressIsCopiedOntoFirstParticipantOnAnOrdinaryBooking(): void
{
$booking = $this->createMockBooking();
$travel = $this->createMockTravel();
$booking->applicant->personId = 4711;
$booking->participants[0]->personId = 4711;
$booking->applicant->address = $this->createFilledAddress('Elternweg 7');
$booking->participants[0]->address = new Address();
$booking->participants[0]->height = null;
$dto = $this->processor->createBookingDtoFromBooking($booking, $travel);
$this->assertSame(
'Elternweg 7',
$dto->participants[0]->address?->street,
'BPN returns the full record only in <anmelder>, so participant[0] is filled from it'
);
$this->assertSame('175', $dto->participants[0]->height);
}
public function testApplicantAddressIsNotCopiedOntoFirstParticipantOnASplitBooking(): void
{
$booking = $this->createMockBooking();
$travel = $this->createMockTravel();
// The applicant is a different person: a parent who booked for their child.
$booking->applicant->personId = 4711;
$booking->participants[0]->personId = 4712;
$booking->applicant->address = $this->createFilledAddress('Elternweg 7');
$booking->participants[0]->address = new Address();
$booking->participants[0]->height = null;
$dto = $this->processor->createBookingDtoFromBooking($booking, $travel);
$this->assertNull(
$dto->participants[0]->address?->street,
"The parent's address must not be written onto the child at the next edit submit"
);
$this->assertNull(
$dto->participants[0]->height,
"The parent's body dimensions must not be written onto the child either"
);
}
public function testApplicantAddressIsCopiedWhenPersonIdsAreUnknown(): void
{
$booking = $this->createMockBooking();
$travel = $this->createMockTravel();
$booking->applicant->address = $this->createFilledAddress('Elternweg 7');
$booking->participants[0]->address = new Address();
$dto = $this->processor->createBookingDtoFromBooking($booking, $travel);
$this->assertSame(
'Elternweg 7',
$dto->participants[0]->address?->street,
'Incomplete BPN data falls back to the established, stricter behaviour'
);
}
private function createFilledAddress(string $street): Address
{
$address = new Address();
$address->street = $street;
$address->postCode = '99999';
$address->city = 'MusterOrt';
$address->country = 'D';
return $address;
}
public function testTransportationServicesProcessing(): void
{
$formData = $this->createFormDataWithTransportation();
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Tests\BusProNet\DataProcessor;
use App\BusProNet\DataProcessor\BookingPayloadBuilder;
use App\BusProNet\DataProcessor\ServiceMappingCollector;
use App\BusProNet\Model\Address;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use PHPUnit\Framework\TestCase;
/**
* Covers who the <anmelder> block is built from, and the participant email fallback.
*/
class BookingPayloadBuilderTest extends TestCase
{
private BookingPayloadBuilder $builder;
protected function setUp(): void
{
$mappingCollector = $this->createStub(ServiceMappingCollector::class);
$mappingCollector->method('getPromoVoucherCodeForParticipant')->willReturn(null);
$this->builder = new BookingPayloadBuilder($mappingCollector);
}
public function testAnmelderComesFromFirstParticipantWhenTheApplicantTravels(): void
{
$bookingDto = $this->createBookingDto();
$payload = $this->builder->buildCreatePayload($bookingDto, 'BUCHUNG');
$this->assertSame('Kind', $payload['anmelder']['vorname']);
$this->assertSame('Kind', $payload['teilnehmerliste']['teilnehmer'][0]['vorname']);
}
public function testAnmelderComesFromTheApplicantRecordWhenTheyDoNotTravel(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->applicantTravels = false;
$bookingDto->applicant = $this->createPerson('Elternteil', '[email protected]');
$payload = $this->builder->buildCreatePayload($bookingDto, 'BUCHUNG');
$this->assertSame('Elternteil', $payload['anmelder']['vorname']);
$this->assertSame('[email protected]', $payload['anmelder']['kommunikation']['email']);
$this->assertSame(
'Kind',
$payload['teilnehmerliste']['teilnehmer'][0]['vorname'],
'participant[0] stays an ordinary traveller'
);
}
public function testAnmelderStillCarriesNoIdsOnCreate(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->applicantTravels = false;
$applicant = $this->createPerson('Elternteil', '[email protected]');
$applicant->personId = 4711;
$applicant->addressId = 1234;
$bookingDto->applicant = $applicant;
$payload = $this->builder->buildCreatePayload($bookingDto, 'BUCHUNG');
$this->assertArrayNotHasKey('idadresseperson', $payload['anmelder']);
$this->assertArrayNotHasKey('idadresse', $payload['anmelder']);
}
public function testParticipantWithoutEmailInheritsTheApplicants(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->applicantTravels = false;
$bookingDto->applicant = $this->createPerson('Elternteil', '[email protected]');
$bookingDto->participants[0]->email = null;
$payload = $this->builder->buildCreatePayload($bookingDto, 'BUCHUNG');
$this->assertSame(
'[email protected]',
$payload['teilnehmerliste']['teilnehmer'][0]['kommunikation']['email'],
'BusPro rejects a participant without a Mail-Adresse with 650, but accepts a shared one'
);
}
public function testParticipantKeepsTheirOwnEmail(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->applicantTravels = false;
$bookingDto->applicant = $this->createPerson('Elternteil', '[email protected]');
$payload = $this->builder->buildCreatePayload($bookingDto, 'BUCHUNG');
$this->assertSame(
'[email protected]',
$payload['teilnehmerliste']['teilnehmer'][0]['kommunikation']['email']
);
}
public function testOrdinaryBookingPayloadIsUnchangedByTheApplicantSwitch(): void
{
$withApplicantTravelling = $this->builder->buildCreatePayload($this->createBookingDto(), 'BUCHUNG');
$bookingDto = $this->createBookingDto();
$bookingDto->applicant = $this->createPerson('Elternteil', '[email protected]');
$this->assertSame(
$withApplicantTravelling,
$this->builder->buildCreatePayload($bookingDto, 'BUCHUNG'),
'A stale applicant record must not leak into a booking where the applicant travels'
);
}
private function createBookingDto(): BookingDto
{
$travel = new Travel();
$travel->id = 12500;
$travel->hotelId = 223055;
$bookingDto = new BookingDto($travel, 223055);
$bookingDto->agencyId = 77883;
$participant = $this->createPerson('Kind', '[email protected]');
$participant->index = 0;
$bookingDto->participants = [$participant];
return $bookingDto;
}
private function createPerson(string $firstName, string $email): ParticipantDto
{
$person = new ParticipantDto();
$person->firstName = $firstName;
$person->lastName = 'Probe';
$person->gender = 'W';
$person->nationality = 'D';
$person->dateOfBirth = new \DateTimeImmutable('2014-09-16');
$person->email = $email;
$person->mobile = '0171/222222';
$address = new Address();
$address->street = 'Probeweg 7';
$address->postCode = '99999';
$address->city = 'MusterOrt';
$address->country = 'D';
$person->address = $address;
return $person;
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form;
use App\BusProNet\Model\Travel;
use App\Form\BookingApplicantTravelsType;
use App\Form\Model\BookingDto;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\Forms;
class BookingApplicantTravelsTypeTest extends TestCase
{
public function testBooleanChoicesRenderAsRadioValues(): void
{
$form = $this->createForm($this->createBookingDto());
$values = [];
foreach ($form->get('applicantTravels')->createView()->vars['choices'] as $choice) {
$values[] = $choice->value;
}
// Symfony derives these from the booleans on its own, so the type needs no choice_value.
// Pinned because the whole toggle silently stops working if that ever changes.
$this->assertSame(['1', '0'], $values);
}
public function testSubmittingTheSplitChoiceFlipsTheFlag(): void
{
$bookingDto = $this->createBookingDto();
$form = $this->createForm($bookingDto);
$form->submit(['applicantTravels' => '0']);
$this->assertTrue($form->isSynchronized());
$this->assertTrue($form->isValid());
$this->assertFalse($bookingDto->applicantTravels);
}
public function testSubmittingTheTravellingChoiceRestoresTheFlag(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->applicantTravels = false;
$form = $this->createForm($bookingDto);
$form->submit(['applicantTravels' => '1']);
$this->assertTrue($form->isValid());
$this->assertTrue($bookingDto->applicantTravels);
}
public function testCurrentChoiceIsPreselected(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->applicantTravels = false;
$view = $this->createForm($bookingDto)->get('applicantTravels')->createView();
$this->assertSame('0', $view->vars['value']);
}
public function testNoValidationRunsOnTheToggle(): void
{
$bookingDto = $this->createBookingDto();
// Deliberately incomplete: flipping the radio must not report participant gaps.
$bookingDto->participants = [];
$form = $this->createForm($bookingDto);
$form->submit(['applicantTravels' => '0']);
$this->assertCount(0, $form->getErrors(true));
}
private function createBookingDto(): BookingDto
{
$travel = new Travel();
$travel->dateFrom = new \DateTimeImmutable('2026-06-01');
return new BookingDto($travel, 1);
}
/** @return \Symfony\Component\Form\FormInterface<BookingDto> */
private function createForm(BookingDto $bookingDto): \Symfony\Component\Form\FormInterface
{
return Forms::createFormFactory()->create(BookingApplicantTravelsType::class, $bookingDto);
}
}
+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));
}
/**
@@ -74,57 +74,55 @@ class CreateFieldStateProviderTest extends TestCase
);
}
public function testEmailIsNotRequiredForNonApplicantChild(): void
public function testEmailIsRequiredForEveryParticipantIncludingChildren(): void
{
$bookingDto = $this->createBookingDtoWithApplicantAndDependent(new \DateTimeImmutable('2015-01-01'));
$this->assertFalse(
$this->provider->getFieldState('email', $bookingDto, 1)['required'],
'A child travelling with an applicant does not need an email address of their own'
$this->assertArrayNotHasKey(
'required',
$this->provider->getFieldState('email', $bookingDto, 1),
'Requiredness is the form type base option now that it is unconditional'
);
}
public function testEmailIsRequiredForNonApplicantAdult(): void
public function testMobileIsRequiredForTheApplicantOnly(): void
{
$bookingDto = $this->createBookingDtoWithApplicantAndDependent(new \DateTimeImmutable('1990-01-01'));
$this->assertTrue($this->provider->getFieldState('email', $bookingDto, 1)['required']);
$this->assertTrue($this->provider->getFieldState('mobile', $bookingDto, 0)['required']);
$this->assertFalse($this->provider->getFieldState('mobile', $bookingDto, 1)['required']);
}
public function testEmailIsRequiredForTheApplicantEvenAsAChild(): void
public function testMobileAndAddressAreNotRequiredOfParticipantZeroWhenTheApplicantDoesNotTravel(): void
{
$bookingDto = $this->createBookingDtoWithApplicantAndDependent(new \DateTimeImmutable('2015-01-01'));
$bookingDto->participants[0]->dateOfBirth = new \DateTimeImmutable('2015-01-01');
$bookingDto->applicantTravels = false;
$this->assertTrue(
$this->provider->getFieldState('email', $bookingDto, 0)['required'],
'The applicant is the booking contact and always has to be reachable'
$this->assertFalse(
$this->provider->getFieldState('mobile', $bookingDto, 0)['required'],
'BusPro does not ask a telefonmobil of an ordinary participant'
);
$this->assertFalse(
$this->provider->getFieldState('address', $bookingDto, 0)['required'],
'BusPro accepts an empty anschrift for an ordinary participant'
);
}
public function testEmailIsRequiredWhenDateOfBirthIsUnknown(): void
public function testParticipantZeroIsEditableWhenTheApplicantDoesNotTravel(): void
{
$bookingDto = $this->createBookingDtoWithApplicantAndDependent(new \DateTimeImmutable('2015-01-01'));
$bookingDto->participants[1]->dateOfBirth = null;
$bookingDto->participants[0]->personId = 4711;
$this->assertTrue(
$this->provider->getFieldState('email', $bookingDto, 1)['required'],
'An unknown age counts as an adult'
$this->provider->shouldRenderAsStaticText('firstName', $bookingDto, 0),
'A logged-in applicant who travels keeps their prefilled data locked'
);
}
public function testEmailRequirementFlipsWhenDateOfBirthChanges(): void
{
$bookingDto = $this->createBookingDtoWithApplicantAndDependent(new \DateTimeImmutable('2015-01-01'));
$dependent = $bookingDto->participants[1];
$bookingDto->applicantTravels = false;
$this->assertFalse($this->provider->getFieldState('email', $bookingDto, 1)['required']);
$dependent->dateOfBirth = new \DateTimeImmutable('1990-01-01');
$this->assertTrue(
$this->provider->getFieldState('email', $bookingDto, 1)['required'],
'Correcting a child date of birth to an adult one must reinstate the requirement'
$this->assertFalse(
$this->provider->shouldRenderAsStaticText('firstName', $bookingDto, 0),
'Once the applicant does not travel, participant[0] is an ordinary traveller'
);
}
@@ -31,11 +31,14 @@ class EditFieldStateProviderTest extends TestCase
$this->assertTrue($this->provider->getFieldState('email', $bookingDto, 1)['required']);
}
public function testEmailIsNotRequiredForNonApplicantChild(): void
public function testEmailIsRequiredForNonApplicantChild(): void
{
$bookingDto = $this->createBookingDto(new \DateTimeImmutable('2015-01-01'), false);
$this->assertFalse($this->provider->getFieldState('email', $bookingDto, 1)['required']);
$this->assertTrue(
$this->provider->getFieldState('email', $bookingDto, 1)['required'],
'BusPro requires a Mail-Adresse for every participant, children included'
);
}
public function testEmailIsNotRequiredForInternalAgencyBookingsEvenForAdults(): void
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form\Service;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\ParticipantDateOfBirthFieldHandler;
use App\Form\Service\ParticipantEmailFieldHandler;
use App\Form\Service\ParticipantFieldHandlerRegistry;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
class ParticipantEmailFieldHandlerTest extends TestCase
{
private ParticipantEmailFieldHandler $handler;
protected function setUp(): void
{
$this->handler = new ParticipantEmailFieldHandler();
}
public function testChildWithoutEmailInheritsTheApplicants(): void
{
$bookingDto = $this->createBookingDto();
$this->handler->processField(['email' => ''], $bookingDto, 1);
$this->assertSame('[email protected]', $bookingDto->participants[1]->email);
}
public function testChildKeepsAnEmailThatWasTypedIn(): void
{
$bookingDto = $this->createBookingDto();
$this->handler->processField(['email' => '[email protected]'], $bookingDto, 1);
$this->assertSame(
'[email protected]',
$bookingDto->participants[1]->email,
'The pre-filled address stays overridable'
);
}
public function testAdultWithoutEmailDoesNotInherit(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->participants[1]->dateOfBirth = new \DateTimeImmutable('1990-01-01');
$this->handler->processField(['email' => ''], $bookingDto, 1);
$this->assertNull(
$bookingDto->participants[1]->email,
'An adult supplies their own address; NotBlank reports the gap'
);
}
public function testApplicantDoesNotInheritFromThemselves(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->participants[0]->dateOfBirth = new \DateTimeImmutable('2015-01-01');
$this->handler->processField(['email' => ''], $bookingDto, 0);
$this->assertNull($bookingDto->participants[0]->email);
}
public function testChildAtIndexZeroInheritsWhenTheApplicantDoesNotTravel(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->applicantTravels = false;
$bookingDto->applicant = $this->createParticipant('[email protected]', '1981-03-16');
$bookingDto->participants[0]->dateOfBirth = new \DateTimeImmutable('2015-01-01');
$this->handler->processField(['email' => ''], $bookingDto, 0);
$this->assertSame('[email protected]', $bookingDto->participants[0]->email);
}
public function testHandlerRunsAfterDateOfBirth(): void
{
$this->assertSame(
['dateOfBirth'],
$this->handler->getDependencies(),
'Whether someone counts as a child is only known once their DOB is processed'
);
}
/**
* The handler runs in PRE_SUBMIT and writes to the DTO, but the form then binds the submitted
* data over it. Only fields the registry syncs back survive, so the pre-filled address has to
* reappear in the returned data - otherwise the blank wins and the field renders empty.
*/
public function testPreFilledEmailIsSyncedBackIntoTheSubmittedData(): void
{
$bookingDto = $this->createBookingDto();
$registry = new ParticipantFieldHandlerRegistry([
new ParticipantDateOfBirthFieldHandler(new NullLogger()),
new ParticipantEmailFieldHandler(),
]);
$synced = $registry->processFieldsForParticipantAndSync(['email' => ''], $bookingDto, 1);
$this->assertSame('[email protected]', $bookingDto->participants[1]->email);
$this->assertSame(
'[email protected]',
$synced['email'],
'Without the sync the form binds the blank back over the pre-filled address'
);
}
private function createBookingDto(): BookingDto
{
$bookingDto = new BookingDto(new Travel(), 1);
$bookingDto->participants = [
$this->createParticipant('[email protected]', '1980-01-01'),
$this->createParticipant('', '2015-01-01'),
];
$bookingDto->participants[0]->index = 0;
$bookingDto->participants[1]->index = 1;
$bookingDto->participants[1]->email = null;
return $bookingDto;
}
private function createParticipant(string $email, string $dateOfBirth): ParticipantDto
{
$participant = new ParticipantDto();
$participant->email = '' !== $email ? $email : null;
$participant->dateOfBirth = new \DateTimeImmutable($dateOfBirth);
return $participant;
}
}
@@ -172,6 +172,59 @@ class BookingNewsletterOptInTest extends TestCase
);
}
public function testApplicantIsTheSeparateRecordWhenTheyDoNotTravel(): void
{
$bookingDto = $this->createBookingDto([['[email protected]', 12]]);
$bookingDto->applicantTravels = false;
$applicant = new ParticipantDto();
$applicant->firstName = 'Probe';
$applicant->lastName = 'Elternteil';
$applicant->email = '[email protected]';
$bookingDto->applicant = $applicant;
$service = $this->createService($this->createStub(NewsletterManager::class));
$this->assertSame('[email protected]', $service->resolveApplicantEmail($bookingDto, null));
$this->assertSame($applicant, $service->resolveApplicantParticipant($bookingDto));
$this->assertSame('Probe Elternteil', $service->resolveRequestedByName($bookingDto));
}
public function testAdultAtIndexZeroIsOfferedACompanionOptInOnASplitBooking(): void
{
$bookingDto = $this->createBookingDto([['[email protected]', 25]]);
$bookingDto->applicantTravels = false;
$applicant = new ParticipantDto();
$applicant->firstName = 'Probe';
$applicant->lastName = 'Elternteil';
$applicant->email = '[email protected]';
$bookingDto->applicant = $applicant;
$service = $this->createService($this->createStub(NewsletterManager::class));
$companions = $service->resolveCompanions($bookingDto, '[email protected]');
$this->assertCount(
1,
$companions,
'participant[0] is an ordinary companion once the applicant stays home'
);
$this->assertSame('[email protected]', $companions[0]->email);
}
public function testChildCarryingTheApplicantAddressIsNotOfferedACompanionOptIn(): void
{
$bookingDto = $this->createBookingDto([
['[email protected]', 40],
['[email protected]', 12],
]);
$service = $this->createService($this->createStub(NewsletterManager::class));
$companions = $service->resolveCompanions($bookingDto, '[email protected]');
$this->assertSame([], $companions);
}
private function createService(NewsletterManager $newsletterManager): BookingNewsletterOptIn
{
return new BookingNewsletterOptIn($newsletterManager, new NullLogger());
+27 -1
View File
@@ -300,7 +300,33 @@ class ParticipantDataPrefillerTest extends TestCase
$this->assertSame('[email protected]', $participant->email);
$this->assertSame('2006-01-15', $participant->dateOfBirth?->format('Y-m-d'));
$this->assertSame('Musterstr. 123', $participant->address?->street);
$this->assertSame('DE', $participant->address?->country);
$this->assertSame(
'D',
$participant->address?->country,
"BusPro's country tokens are not ISO codes: 'DE' matches no choice and leaves the select empty"
);
CarbonImmutable::setTestNow();
}
public function testFillDummyApplicantSetsACompleteAdult(): void
{
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 15, 14, 30));
$applicant = new ParticipantDto();
$this->service()->fillDummyApplicant($applicant);
$this->assertSame('Probeelternteil', $applicant->firstName);
$this->assertSame('Elternteil 14:30', $applicant->lastName);
$this->assertSame('[email protected]', $applicant->email);
$this->assertSame('0171/222222', $applicant->mobile);
$this->assertSame('Probeweg 7', $applicant->address?->street);
// The applicant block requires these, unlike a participant.
$this->assertSame('W', $applicant->gender);
$this->assertSame('D', $applicant->nationality);
$this->assertSame('D', $applicant->address?->country);
$this->assertGreaterThanOrEqual(18, $applicant->getAge());
CarbonImmutable::setTestNow();
}
+47 -1
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Service;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Travel;
use App\Exception\ParticipantNotFoundException;
use App\Form\Model\BookingDto;
@@ -80,8 +81,53 @@ class ParticipantFormSupportTest extends TestCase
}
/**
* array<string, int>|null $bodyDimensionRanges
* array<string, int>|null $bodyDimensionRanges.
*/
public function testBlankTravellerKeepsTheRoomAssignmentAndDropsTheRest(): void
{
$previous = new ParticipantDto();
$previous->index = 0;
$previous->assignedRoomId = 4711;
$previous->firstName = 'Elternteil';
$previous->email = '[email protected]';
$previous->dateOfBirth = new \DateTimeImmutable('1981-03-16');
$traveller = $this->createService()->createBlankTravellerFrom($previous);
$this->assertSame(0, $traveller->index);
$this->assertSame(4711, $traveller->assignedRoomId);
$this->assertNull($traveller->firstName);
$this->assertNull($traveller->email);
$this->assertNull(
$traveller->dateOfBirth,
'The services were derived from an age that no longer applies to this slot'
);
}
public function testClearFamilyInsuranceRemovesOnlyFamilyPolicies(): void
{
$bookingDto = $this->createBookingDto();
$familyInsurance = new Insurance();
$familyInsurance->familyInsurance = true;
$bookingDto->participants[0]->insurance = $familyInsurance;
$bookingDto->participants[0]->bulkInsuranceBooking = true;
$ordinaryInsurance = new Insurance();
$ordinaryInsurance->familyInsurance = false;
$bookingDto->participants[1]->insurance = $ordinaryInsurance;
$this->createService()->clearFamilyInsurance($bookingDto);
$this->assertNull($bookingDto->participants[0]->insurance);
$this->assertFalse($bookingDto->participants[0]->bulkInsuranceBooking);
$this->assertSame(
$ordinaryInsurance,
$bookingDto->participants[1]->insurance,
'Only the family policy goes; an ordinary one is still valid on a split booking'
);
}
private function createService(?array $bodyDimensionRanges = null): ParticipantFormSupport
{
$bodyDimensionRanges ??= [