feat: make email optional for under-16 participants except applicant

This commit is contained in:
fromme committed 2026-10-19 09:18:22 +02:00
1 parent b958d1a371
commit 1d9c852113
18 files changed
+435 -336

No files matched your search

-1
View File
@@ -302,7 +302,6 @@ services:
- 'App\Form\Service\ParticipantDropOffFieldHandler'
- 'App\Form\Service\ParticipantRentalInsuranceFieldHandler'
- 'App\Form\Service\ParticipantLicensePlateFieldHandler'
- 'App\Form\Service\ParticipantEmailFieldHandler'
# Complex handlers with dependencies - use service references
- '@App\Form\Service\ParticipantDateOfBirthFieldHandler'
- '@App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler'
+13 -5
View File
@@ -167,11 +167,19 @@ fails on `Telefon-Mobil` and `Nationalität` because its `Vorgang_Details` recor
`telefonprivat` and no `nationalitaet`.
**Participants are held to less than the Anmelder** (measured 2026-09-16 on 2036, travel 12500, from a
real step-3 `Anfrage`). Once a room is present, every `teilnehmer` needs a `Mail-Adresse`, children
included: without `kommunikation` the answer is `650 … 1.Teilnehmer-Mail-Adresse, 2.Teilnehmer-Mail-Adresse`.
`telefonmobil` and an empty `anschrift` pass for participants. An email identical to the Anmelder's is
accepted. The field list above (Strasse, Plz, Ort, Telefon-Mobil for every participant) was measured on
the placeholder records and is stricter than what BusPro applies to ordinary participants.
real step-3 `Anfrage`). `telefonmobil` and an empty `anschrift` pass for participants. The field list
above (Strasse, Plz, Ort, Telefon-Mobil for every participant) was measured on the placeholder records and
is stricter than what BusPro applies to ordinary participants.
**The participant `Mail-Adresse` is BusPro configuration, not a fixed rule.** Until 2026-10-13 every
`teilnehmer` needed one once a room was present, children included: without it the answer was
`650 … 1.Teilnehmer-Mail-Adresse, 2.Teilnehmer-Mail-Adresse`, while an email identical to the Anmelder's
was accepted. On 2026-10-15 the requirement was switched off on the test system, and the same requests
(no `email`, no `kommunikation`, an empty `<email/>`, adults and children, with and without a travelling
Anmelder) all pass with an unchanged price. BusPro now accepts a participant without an email at any age,
so the rule MyE&P applies (required from 16, and for the applicant at any age) is enforced only by
`ParticipantDto` and `BookingDto` validation. Production needs the same configuration before under-16s
without an email can be booked there.
**The Anmelder does not have to travel.** An `<anmelder>` that matches no `teilnehmer` (a parent booking
for their children) returns `möglich` with the same price as the control where the Anmelder is
@@ -285,7 +285,7 @@ class BookingPayloadBuilder
$participantData = [
'@id' => $index + 1,
'status' => self::DEFAULT_PARTICIPANT_STATUS,
] + $this->buildPersonalDataPayload($participant, $applicantSource->email, true);
] + $this->buildPersonalDataPayload($participant, true);
// Add body dimensions
if (null !== $participant->height) {
@@ -387,16 +387,12 @@ class BookingPayloadBuilder
* Maps one person's personal data onto the payload shape shared by anmelder and teilnehmer.
*
* @param ParticipantDto $person The applicant or participant to map
* @param string|null $fallbackEmail Used when the person has none of their own. BusPro
* requires a Mail-Adresse for every participant once
* a room is present and rejects the Anfrage with 650
* otherwise, but accepts one shared with the Anmelder
* @param bool $alwaysIncludeAddress Participants always carry the anschrift structure
* even when empty; the anmelder only when set
*
* @return array<string, mixed>
*/
private function buildPersonalDataPayload(ParticipantDto $person, ?string $fallbackEmail = null, bool $alwaysIncludeAddress = false): array
private function buildPersonalDataPayload(ParticipantDto $person, bool $alwaysIncludeAddress = false): array
{
$data = [
'name' => $person->lastName,
@@ -421,9 +417,9 @@ class BookingPayloadBuilder
];
}
$email = null !== $person->email && '' !== trim($person->email)
? $person->email
: $fallbackEmail;
// A participant may come without an email (under-16s, see ParticipantDto::$email), and
// BusPro accepts that, so a blank is sent as a blank rather than borrowing the applicant's.
$email = null !== $person->email && '' !== trim($person->email) ? $person->email : null;
if (null !== $email || null !== $person->mobile) {
$data['kommunikation'] = [];
+1 -3
View File
@@ -210,9 +210,7 @@ class BookingParticipantType extends AbstractType
if ($this->fieldStateProvider->shouldIncludeField('email', $bookingDto, $participantIndex)) {
$form->add('email', EmailType::class, $this->mergeFieldState([
'label' => 'E-Mail',
// Required for every participant (BPN 650); EditFieldStateProvider clears it
// again for internal agency bookings, where staff may leave data blank.
'required' => true,
'required' => false,
'property_path' => 'participant.email',
'attr' => [
'autocomplete' => 'leave-me-alone-chrome',
+49 -4
View File
@@ -213,6 +213,50 @@ class BookingDto
$this->applicantTravels = true;
}
/**
* Whether the participant at $index has to supply an email address.
*
* Everyone does except an under-16 who is not the applicant; BusPro itself accepts a blank
* for any participant. Validation enforces this in two parts, the Assert\When on
* ParticipantDto::$email (age) and validateFirstParticipantApplicant() (the applicant), and
* this is the same rule in one place for the field state. A participant that does not exist
* yet counts as needing one, matching the "unknown counts as an adult" stance of isChild().
*/
public function requiresParticipantEmail(int $index): bool
{
$participant = $this->getParticipant($index);
if (null === $participant || false === $participant->isChild()) {
return true;
}
return 0 === $index && true === $this->isFirstParticipantApplicant();
}
/**
* The applicant's email address, wherever the applicant is held.
*
* - participant[0] where they are the applicant
* - create, split: the separate applicant block
* - edit, split: the loaded booking's anmelder, since $applicant is only filled in create mode
*/
public function getApplicantEmail(): ?string
{
if (true === $this->isFirstParticipantApplicant()) {
$email = $this->getParticipant(0)?->email;
} elseif (self::MODE_CREATE === $this->getMode()) {
$email = $this->applicant?->email;
} else {
$email = $this->booking?->applicant?->communication->email;
}
if (null === $email || '' === trim($email)) {
return null;
}
return trim($email);
}
/**
* Whether participant[0] is also the applicant.
*
@@ -311,18 +355,19 @@ class BookingDto
}
/**
* Requires a complete address from participant[0] only where they are the applicant.
* Requires a complete address and an email from participant[0] only where they are the applicant.
*
* BusPro does not require an anschrift from an ordinary participant, so on a split booking
* the traveller at index 0 is not asked for one.
* BusPro asks neither of an ordinary participant, so on a split booking the traveller at index 0
* is not asked for an address, and an under-16 there may leave the email blank.
*/
#[Assert\Callback(groups: ['strict_required'])]
public function validateFirstParticipantAddress(ExecutionContextInterface $context): void
public function validateFirstParticipantApplicant(ExecutionContextInterface $context): void
{
if (false === $this->isFirstParticipantApplicant()) {
return;
}
$this->getParticipant(0)?->validateApplicantEmail($context, 'participants[0].');
$this->getParticipant(0)?->validateAddressFields($context, 'participants[0].');
}
+32 -4
View File
@@ -80,11 +80,17 @@ class ParticipantDto
#[Assert\NotNull(message: 'Bitte angeben', groups: ['strict_required'])]
public ?\DateTimeImmutable $dateOfBirth = null;
// Every participant needs an email address: BusPro rejects the step-3 Anfrage with 650
// otherwise, children included. Under-16s have the applicant's address pre-filled by
// ParticipantEmailFieldHandler and may override it, but may not leave it blank.
// BusPro accepts a participant without an email address, so this is the only gate. Under-16s
// may leave it blank, everyone else needs one. An under-16 applicant needs one as well; that
// case depends on the booking and is enforced by BookingDto::validateFirstParticipantApplicant()
// and ParticipantEditDto::validateApplicant(). It stays a property constraint so the missing-field
// labels keep their order (see BookingEditPreFlightChecker).
#[Assert\Email(message: 'Bitte eine gültige E-Mail Adresse angeben', mode: 'strict', groups: ['booking_edit', 'booking_create'])]
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['strict_required'])]
#[Assert\When(
expression: 'not this.isChild()',
constraints: [new Assert\NotBlank(message: 'Bitte angeben')],
groups: ['strict_required'],
)]
public ?string $email = null;
public ?string $mobile = null;
@@ -530,6 +536,28 @@ class ParticipantDto
}
}
/**
* Validates that this person, as the booking's contact, has an email address.
*
* The property constraint on $email already holds everyone aged 16 or older (and anyone
* without a date of birth) to it, so this only adds the under-16 contact and never reports
* the same field twice. Like validateAddressFields(), the callers decide who the contact is.
*/
public function validateApplicantEmail(ExecutionContextInterface $context, string $pathPrefix = ''): void
{
if (false === $this->isChild()) {
return;
}
if (null !== $this->email && false === u($this->email)->trim()->isEmpty()) {
return;
}
$context->buildViolation('Bitte angeben')
->atPath($pathPrefix.'email')
->addViolation();
}
/**
* Validates that this person has a complete address.
*
+22 -5
View File
@@ -93,13 +93,13 @@ class ParticipantEditDto
}
/**
* Requires a complete address from the participant form's applicant only.
* Requires a complete address and an email from the participant form's applicant only.
*
* Mirrors BookingDto::validateFirstParticipantAddress() for the per-participant form, which
* Mirrors BookingDto::validateFirstParticipantApplicant() for the per-participant form, which
* binds this wrapper rather than the booking.
*/
#[Assert\Callback(groups: ['strict_required'])]
public function validateApplicantAddress(ExecutionContextInterface $context): void
public function validateApplicant(ExecutionContextInterface $context): void
{
if (false === $this->participant->isApplicant()) {
return;
@@ -109,6 +109,7 @@ class ParticipantEditDto
return;
}
$this->participant->validateApplicantEmail($context, 'participant.');
$this->participant->validateAddressFields($context, 'participant.');
}
@@ -117,7 +118,11 @@ class ParticipantEditDto
*
* Exempt from validation:
* - The applicant: can share an email with dependents they're booking for
* - Children (under 16): Can share email addresses with adults
* - Children (under 16): Can share email addresses with adults, or have none at all
*
* Where the applicant does not travel they are not among the participants, so their address
* is checked separately: an adult traveller may not reuse it, just as on an ordinary booking
* an adult may not reuse participant[0]'s.
*
* Email comparison is case-insensitive and whitespace is normalized.
*/
@@ -140,7 +145,7 @@ class ParticipantEditDto
return;
}
// Skip if email is null or empty (handled by @Email and @NotBlank constraints)
// Nothing to compare. Whether a blank is allowed is the property constraint's business.
if (null === $this->participant->email || u($this->participant->email)->trim()->isEmpty()) {
return;
}
@@ -178,5 +183,17 @@ class ParticipantEditDto
return;
}
}
if (true === $this->bookingContext->isFirstParticipantApplicant()) {
return;
}
$applicantEmail = $this->bookingContext->getApplicantEmail();
if (null !== $applicantEmail && $normalizedEmail === u($applicantEmail)->lower()->toString()) {
$context->buildViolation('Diese E-Mail Adresse wird bereits von der anmeldenden Person verwendet')
->atPath('participant.email')
->addViolation();
}
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
/**
* Condition that mirrors BookingDto::requiresParticipantEmail() into the field state layer.
*
* It deliberately holds no logic of its own, so the mandatory marker rendered next to the field
* is derived from the same method that describes what validation enforces, rather than from a
* second, independently maintained composition of age and applicant conditions.
*/
class RequiresOwnEmailCondition implements FieldConditionInterface
{
/**
* Evaluates whether the participant has to supply an email address of their own.
*
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data (unused)
*
* @return bool True if the participant needs their own email address
*/
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
return $bookingDto->requiresParticipantEmail($participantIndex);
}
/**
* The rule reads the date of birth, so the state has to be recalculated when it changes.
*
* @return string[] Array containing 'dateOfBirth'
*/
public function getDependentFields(): array
{
return ['dateOfBirth'];
}
public function getDescription(): string
{
return 'Checks if the participant needs an email address of their own';
}
}
@@ -21,6 +21,7 @@ use App\Form\Service\Condition\FirstParticipantReadOnlyCondition;
use App\Form\Service\Condition\MultipleParticipantsCondition;
use App\Form\Service\Condition\RentalInsuranceAvailableCondition;
use App\Form\Service\Condition\RentalSelectionCondition;
use App\Form\Service\Condition\RequiresOwnEmailCondition;
use App\Form\Service\Condition\RoomSelectionCondition;
use App\Form\Service\Condition\ServiceSubTypeCondition;
use App\Form\Service\Condition\SingleRoomTypeCondition;
@@ -112,10 +113,9 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
'static_text' => $firstParticipantReadOnlyCondition,
];
// Every participant needs an email address, children included (BPN 650), so
// requiredness is the form type's base option and not a condition here.
$this->fieldStateConditions['email'] = [
'static_text' => $firstParticipantReadOnlyCondition,
'required' => new RequiresOwnEmailCondition(),
];
// Mobile field: static text for first participant (when read-only). Mandatory only for the
+5 -1
View File
@@ -20,6 +20,7 @@ use App\Form\Service\Condition\PersonalDataMutabilityCondition;
use App\Form\Service\Condition\PickupsMutabilityCondition;
use App\Form\Service\Condition\RentalInsuranceAvailableCondition;
use App\Form\Service\Condition\RentalSelectionCondition;
use App\Form\Service\Condition\RequiresOwnEmailCondition;
use App\Form\Service\Condition\RoomSelectionCondition;
use App\Form\Service\Condition\ServiceSubTypeCondition;
use App\Form\Service\Condition\SkiPassSelectionCondition;
@@ -92,7 +93,10 @@ class EditFieldStateProvider extends AbstractFieldStateProvider
// data optional throughout.
$this->fieldStateConditions['email'] = [
'static_text' => $firstParticipantReadOnlyCondition,
'required' => CompositeCondition::not($internalAgencyBookingCondition),
'required' => CompositeCondition::and(
CompositeCondition::not($internalAgencyBookingCondition),
new RequiresOwnEmailCondition(),
),
];
// Address fields - render as static text if first participant in non-internal agency OR participant not mutable
@@ -1,110 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
* Handles the email field, pre-filling the applicant's address for under-16 participants.
*
* BusPro requires a Mail-Adresse for every participant once a room is present and rejects the
* step-3 Anfrage with 650 otherwise, children included. It does accept an address identical to
* the Anmelder's, so children inherit the applicant's rather than being asked for one they
* typically do not have. The value stays editable and stays mandatory. This happens in the
* create flow only; in edit mode the handler just stores what was submitted.
*
* The copy only ever fills a blank, so an address somebody typed is never overwritten. A later
* correction to the applicant's own address therefore does not propagate to children who already
* inherited the previous one.
*
* Depends on dateOfBirth: whether a participant counts as a child is only known once their date
* of birth has been processed.
*/
class ParticipantEmailFieldHandler extends AbstractParticipantFieldHandler
{
public function getFieldName(): string
{
return 'email';
}
/**
* @return string[]
*/
public function getDependencies(): array
{
return ['dateOfBirth'];
}
/**
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
* @param int $participantIndex The index of the participant being processed
*/
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (false === $participant instanceof ParticipantDto) {
return;
}
$submitted = $this->getFieldValue($submittedData, $this->getFieldName());
if (true === is_string($submitted)) {
$participant->email = '' !== trim($submitted) ? trim($submitted) : null;
}
$this->applyApplicantEmail($bookingDto, $participant);
}
/**
* Copies the applicant's address onto a child who has none of their own.
*/
public function applyApplicantEmail(BookingDto $bookingDto, ParticipantDto $participant): void
{
// Create mode only. An existing booking already carries whatever BusPro accepted, a
// blank may be intended where personal data is optional, and participant[0] is not
// reliably the applicant there.
if (BookingDto::MODE_CREATE !== $bookingDto->getMode()) {
return;
}
if (false === $participant->isChild()) {
return;
}
$applicantEmail = $this->resolveApplicantEmail($bookingDto);
if (null === $applicantEmail) {
return;
}
// The applicant themselves never inherits, and on an ordinary booking that is participant[0].
if (true === $participant->isApplicant() && true === $bookingDto->applicantTravels) {
return;
}
if (null === $participant->email || '' === trim($participant->email)) {
$participant->email = $applicantEmail;
}
}
private function resolveApplicantEmail(BookingDto $bookingDto): ?string
{
$applicant = $bookingDto->applicantTravels
? ($bookingDto->participants[0] ?? null)
: $bookingDto->applicant;
$email = $applicant?->email;
if (null === $email || '' === trim($email)) {
return null;
}
return trim($email);
}
}
@@ -250,9 +250,6 @@ class ParticipantFieldHandlerRegistry
{
// Define fields that may be reassigned by handlers and need syncing
$serviceFields = [
// Not a service: ParticipantEmailFieldHandler pre-fills a child's blank email from
// the applicant, and without syncing it back the form would bind the blank over it.
'email',
'transportationOutbound',
'transportationInbound',
'insurance',
@@ -13,7 +13,7 @@ use App\Form\Model\ParticipantDto;
use PHPUnit\Framework\TestCase;
/**
* Covers who the <anmelder> block is built from, and the participant email fallback.
* Covers who the <anmelder> block is built from, and that a participant's blank email stays blank.
*/
class BookingPayloadBuilderTest extends TestCase
{
@@ -69,7 +69,7 @@ class BookingPayloadBuilderTest extends TestCase
$this->assertArrayNotHasKey('idadresse', $payload['anmelder']);
}
public function testParticipantWithoutEmailInheritsTheApplicants(): void
public function testParticipantWithoutEmailIsSentWithoutOne(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->applicantTravels = false;
@@ -78,10 +78,10 @@ class BookingPayloadBuilderTest extends TestCase
$payload = $this->builder->buildCreatePayload($bookingDto, 'BUCHUNG');
$this->assertSame(
'e[email protected]',
$payload['teilnehmerliste']['teilnehmer'][0]['kommunikation']['email'],
'BusPro rejects a participant without a Mail-Adresse with 650, but accepts a shared one'
$this->assertArrayNotHasKey(
'email',
$payload['teilnehmerliste']['teilnehmer'][0]['kommunikation'],
'BusPro accepts a participant without a Mail-Adresse; the applicant\'s must not be borrowed'
);
}
+91
View File
@@ -429,6 +429,97 @@ class BookingDtoTest extends TestCase
$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>
*/
+100 -11
View File
@@ -909,7 +909,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 testChildWithoutEmailFailsStrictValidation(): void
public function testChildWithoutEmailPassesStrictValidation(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createAdultParticipant('[email protected]'),
@@ -918,14 +918,10 @@ class ParticipantEditDtoTest extends TestCase
$violations = $this->validateStrict($bookingDto, 1);
$this->assertSame(
['Bitte angeben'],
$this->emailRequiredMessages($violations),
'BusPro requires a Mail-Adresse for every participant, children included'
);
$this->assertSame([], $this->emailRequiredMessages($violations));
}
public function testChildWithNullEmailFailsStrictValidation(): void
public function testChildWithNullEmailPassesStrictValidation(): void
{
$child = $this->createChildParticipant('');
$child->email = null;
@@ -937,7 +933,7 @@ class ParticipantEditDtoTest extends TestCase
$violations = $this->validateStrict($bookingDto, 1);
$this->assertSame(['Bitte angeben'], $this->emailRequiredMessages($violations));
$this->assertSame([], $this->emailRequiredMessages($violations));
}
public function testChildSharingTheApplicantsEmailPassesStrictValidation(): void
@@ -952,7 +948,7 @@ class ParticipantEditDtoTest extends TestCase
$this->assertSame(
[],
$this->emailRequiredMessages($violations),
'A child inherits the applicant address, which BusPro accepts'
'A child may use the applicant\'s address instead of leaving it blank'
);
}
@@ -1021,6 +1017,99 @@ class ParticipantEditDtoTest extends TestCase
$this->assertSame(['Bitte angeben'], $this->emailRequiredMessages($violations));
}
public function testChildAtIndexZeroOfASplitBookingMayLeaveTheEmailBlank(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createChildParticipant(''),
]);
$bookingDto->applicantTravels = false;
$violations = $this->validateStrict($bookingDto, 0);
$this->assertSame(
[],
$this->emailRequiredMessages($violations),
'Where the applicant does not travel, participant[0] is an ordinary traveller'
);
}
public function testAdultAtIndexZeroOfASplitBookingStillNeedsAnEmail(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createAdultParticipant(''),
]);
$bookingDto->applicantTravels = false;
$violations = $this->validateStrict($bookingDto, 0);
$this->assertSame(['Bitte angeben'], $this->emailRequiredMessages($violations));
}
public function testAdultTravellerMayNotReuseTheNonTravellingApplicantsEmail(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createAdultParticipant('[email protected]'),
]);
$bookingDto->applicantTravels = false;
$bookingDto->applicant = $this->createValidParticipant(new \DateTimeImmutable('1980-01-01'), '[email protected]');
$violations = $this->validator->validate(
new ParticipantEditDto(participant: $bookingDto->participants[0], bookingContext: $bookingDto),
null,
['booking_create']
);
$this->assertCount(1, $violations);
$this->assertSame('participant.email', $violations->get(0)->getPropertyPath());
$this->assertSame(
'Diese E-Mail Adresse wird bereits von der anmeldenden Person verwendet',
$violations->get(0)->getMessage()
);
}
public function testChildTravellerMayReuseTheNonTravellingApplicantsEmail(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createChildParticipant('[email protected]'),
]);
$bookingDto->applicantTravels = false;
$bookingDto->applicant = $this->createValidParticipant(new \DateTimeImmutable('1980-01-01'), '[email protected]');
$violations = $this->validator->validate(
new ParticipantEditDto(participant: $bookingDto->participants[0], bookingContext: $bookingDto),
null,
['booking_create']
);
$this->assertCount(0, $violations);
}
public function testAdultTravellerMayNotReuseTheAnmelderEmailOfASplitBookingInEditMode(): void
{
$applicant = new PersonalData();
$applicant->personId = 100;
$applicant->communication->email = '[email protected]';
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createAdultParticipant('[email protected]'),
]);
$bookingDto->booking = new Booking();
$bookingDto->booking->applicant = $applicant;
$bookingDto->participants[0]->personId = 200;
$violations = $this->validator->validate(
new ParticipantEditDto(participant: $bookingDto->participants[0], bookingContext: $bookingDto),
null,
['booking_edit']
);
$this->assertCount(1, $violations);
$this->assertSame(
'Diese E-Mail Adresse wird bereits von der anmeldenden Person verwendet',
$violations->get(0)->getMessage()
);
}
public function testDependentTurningExactlySixteenRequiresEmail(): void
{
$participant = $this->createAdultParticipant('');
@@ -1036,7 +1125,7 @@ class ParticipantEditDtoTest extends TestCase
$this->assertSame(['Bitte angeben'], $this->emailRequiredMessages($violations));
}
public function testDependentOneDayShortOfSixteenStillRequiresEmail(): void
public function testDependentOneDayShortOfSixteenDoesNotRequireEmail(): void
{
$participant = $this->createAdultParticipant('');
$participant->dateOfBirth = (new \DateTimeImmutable('today'))->modify('-16 years')->modify('+1 day');
@@ -1048,7 +1137,7 @@ class ParticipantEditDtoTest extends TestCase
$violations = $this->validateStrict($bookingDto, 1);
$this->assertSame(['Bitte angeben'], $this->emailRequiredMessages($violations));
$this->assertSame([], $this->emailRequiredMessages($violations));
}
/**
@@ -74,14 +74,69 @@ class CreateFieldStateProviderTest extends TestCase
);
}
public function testEmailIsRequiredForEveryParticipantIncludingChildren(): void
public function testEmailIsNotRequiredForNonApplicantChild(): void
{
$bookingDto = $this->createBookingDtoWithApplicantAndDependent(new \DateTimeImmutable('2015-01-01'));
$this->assertArrayNotHasKey(
'required',
$this->provider->getFieldState('email', $bookingDto, 1),
'Requiredness is the form type base option now that it is unconditional'
$this->assertFalse(
$this->provider->getFieldState('email', $bookingDto, 1)['required'],
'A child travelling with an applicant does not need an email address of their own'
);
}
public function testEmailIsRequiredForNonApplicantAdult(): void
{
$bookingDto = $this->createBookingDtoWithApplicantAndDependent(new \DateTimeImmutable('1990-01-01'));
$this->assertTrue($this->provider->getFieldState('email', $bookingDto, 1)['required']);
}
public function testEmailIsRequiredForTheApplicantEvenAsAChild(): void
{
$bookingDto = $this->createBookingDtoWithApplicantAndDependent(new \DateTimeImmutable('2015-01-01'));
$bookingDto->participants[0]->dateOfBirth = new \DateTimeImmutable('2015-01-01');
$this->assertTrue(
$this->provider->getFieldState('email', $bookingDto, 0)['required'],
'The applicant is the booking contact and always has to be reachable'
);
}
public function testEmailIsNotRequiredForAChildAtIndexZeroWhenTheApplicantDoesNotTravel(): void
{
$bookingDto = $this->createBookingDtoWithApplicantAndDependent(new \DateTimeImmutable('2015-01-01'));
$bookingDto->participants[0]->dateOfBirth = new \DateTimeImmutable('2015-01-01');
$bookingDto->applicantTravels = false;
$this->assertFalse(
$this->provider->getFieldState('email', $bookingDto, 0)['required'],
'On a split booking participant[0] is an ordinary traveller'
);
}
public function testEmailIsRequiredWhenDateOfBirthIsUnknown(): void
{
$bookingDto = $this->createBookingDtoWithApplicantAndDependent(new \DateTimeImmutable('2015-01-01'));
$bookingDto->participants[1]->dateOfBirth = null;
$this->assertTrue(
$this->provider->getFieldState('email', $bookingDto, 1)['required'],
'An unknown age counts as an adult'
);
}
public function testEmailRequirementFlipsWhenDateOfBirthChanges(): void
{
$bookingDto = $this->createBookingDtoWithApplicantAndDependent(new \DateTimeImmutable('2015-01-01'));
$dependent = $bookingDto->participants[1];
$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'
);
}
@@ -31,14 +31,11 @@ class EditFieldStateProviderTest extends TestCase
$this->assertTrue($this->provider->getFieldState('email', $bookingDto, 1)['required']);
}
public function testEmailIsRequiredForNonApplicantChild(): void
public function testEmailIsNotRequiredForNonApplicantChild(): void
{
$bookingDto = $this->createBookingDto(new \DateTimeImmutable('2015-01-01'), false);
$this->assertTrue(
$this->provider->getFieldState('email', $bookingDto, 1)['required'],
'BusPro requires a Mail-Adresse for every participant, children included'
);
$this->assertFalse($this->provider->getFieldState('email', $bookingDto, 1)['required']);
}
public function testEmailIsNotRequiredForInternalAgencyBookingsEvenForAdults(): void
@@ -1,162 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form\Service;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Travel;
use App\BusProNet\XmlLoader\AgencyLoader;
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 testEditModeNeverPreFills(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->booking = new Booking();
$this->handler->processField(['email' => ''], $bookingDto, 1);
$this->assertNull(
$bookingDto->participants[1]->email,
'participant[0] is not reliably the applicant in edit mode, and a blank may be intended'
);
}
public function testEditModeLeavesAnInternalAgencyChildWithoutEmail(): void
{
$bookingDto = $this->createBookingDto();
$bookingDto->booking = new Booking();
$bookingDto->agencyCode = AgencyLoader::INTERNAL_AGENCY_CODE;
$this->handler->processField(['email' => ''], $bookingDto, 1);
$this->assertNull($bookingDto->participants[1]->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;
}
}