diff --git a/config/services.yaml b/config/services.yaml
index cb49a4e1..d00df647 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -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'
diff --git a/docs/bpn-xml-api-reference.md b/docs/bpn-xml-api-reference.md
index 1b26a3e2..4506aa88 100644
--- a/docs/bpn-xml-api-reference.md
+++ b/docs/bpn-xml-api-reference.md
@@ -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 ``, 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 `` that matches no `teilnehmer` (a parent booking
for their children) returns `möglich` with the same price as the control where the Anmelder is
diff --git a/src/BusProNet/DataProcessor/BookingPayloadBuilder.php b/src/BusProNet/DataProcessor/BookingPayloadBuilder.php
index 43107814..4d33ee7f 100644
--- a/src/BusProNet/DataProcessor/BookingPayloadBuilder.php
+++ b/src/BusProNet/DataProcessor/BookingPayloadBuilder.php
@@ -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
*/
- 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'] = [];
diff --git a/src/Form/BookingParticipantType.php b/src/Form/BookingParticipantType.php
index 17c7f034..994953e4 100644
--- a/src/Form/BookingParticipantType.php
+++ b/src/Form/BookingParticipantType.php
@@ -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',
diff --git a/src/Form/Model/BookingDto.php b/src/Form/Model/BookingDto.php
index 1af5a117..2c52eafa 100644
--- a/src/Form/Model/BookingDto.php
+++ b/src/Form/Model/BookingDto.php
@@ -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].');
}
diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php
index 0e845cb1..0ac178c6 100644
--- a/src/Form/Model/ParticipantDto.php
+++ b/src/Form/Model/ParticipantDto.php
@@ -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.
*
diff --git a/src/Form/Model/ParticipantEditDto.php b/src/Form/Model/ParticipantEditDto.php
index fedff1ef..54ce34ba 100644
--- a/src/Form/Model/ParticipantEditDto.php
+++ b/src/Form/Model/ParticipantEditDto.php
@@ -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();
+ }
}
}
diff --git a/src/Form/Service/Condition/RequiresOwnEmailCondition.php b/src/Form/Service/Condition/RequiresOwnEmailCondition.php
new file mode 100644
index 00000000..da2057fb
--- /dev/null
+++ b/src/Form/Service/Condition/RequiresOwnEmailCondition.php
@@ -0,0 +1,47 @@
+ $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';
+ }
+}
diff --git a/src/Form/Service/CreateFieldStateProvider.php b/src/Form/Service/CreateFieldStateProvider.php
index 8c6ea302..cfa60080 100644
--- a/src/Form/Service/CreateFieldStateProvider.php
+++ b/src/Form/Service/CreateFieldStateProvider.php
@@ -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
diff --git a/src/Form/Service/EditFieldStateProvider.php b/src/Form/Service/EditFieldStateProvider.php
index 479d0a9a..21c621c6 100644
--- a/src/Form/Service/EditFieldStateProvider.php
+++ b/src/Form/Service/EditFieldStateProvider.php
@@ -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
diff --git a/src/Form/Service/ParticipantEmailFieldHandler.php b/src/Form/Service/ParticipantEmailFieldHandler.php
deleted file mode 100644
index e19f48b0..00000000
--- a/src/Form/Service/ParticipantEmailFieldHandler.php
+++ /dev/null
@@ -1,110 +0,0 @@
- $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);
- }
-}
diff --git a/src/Form/Service/ParticipantFieldHandlerRegistry.php b/src/Form/Service/ParticipantFieldHandlerRegistry.php
index 53f2a046..88a561fa 100644
--- a/src/Form/Service/ParticipantFieldHandlerRegistry.php
+++ b/src/Form/Service/ParticipantFieldHandlerRegistry.php
@@ -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',
diff --git a/tests/BusProNet/DataProcessor/BookingPayloadBuilderTest.php b/tests/BusProNet/DataProcessor/BookingPayloadBuilderTest.php
index cc0a0098..26c809db 100644
--- a/tests/BusProNet/DataProcessor/BookingPayloadBuilderTest.php
+++ b/tests/BusProNet/DataProcessor/BookingPayloadBuilderTest.php
@@ -13,7 +13,7 @@ use App\Form\Model\ParticipantDto;
use PHPUnit\Framework\TestCase;
/**
- * Covers who the block is built from, and the participant email fallback.
+ * Covers who the 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(
- 'elternteil@example.com',
- $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'
);
}
diff --git a/tests/Form/Model/BookingDtoTest.php b/tests/Form/Model/BookingDtoTest.php
index 50dc2fcf..4621d31a 100644
--- a/tests/Form/Model/BookingDtoTest.php
+++ b/tests/Form/Model/BookingDtoTest.php
@@ -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 = ' tn1@example.com ';
+ $dto->applicant = new ParticipantDto();
+ $dto->applicant->email = 'stale@example.com';
+
+ $this->assertSame('tn1@example.com', $dto->getApplicantEmail());
+ }
+
+ public function testApplicantEmailComesFromTheApplicantBlockOnACreateModeSplit(): void
+ {
+ $dto = $this->createCreateModeDto();
+ $dto->participants[0]->email = 'kind@example.com';
+ $dto->applicantTravels = false;
+ $dto->applicant = new ParticipantDto();
+ $dto->applicant->email = 'parent@example.com';
+
+ $this->assertSame('parent@example.com', $dto->getApplicantEmail());
+ }
+
+ public function testApplicantEmailComesFromTheAnmelderOnAnEditModeSplit(): void
+ {
+ $dto = $this->createEditModeDto(100, 200);
+ $dto->participants[0]->email = 'kind@example.com';
+ $dto->booking->applicant->communication->email = 'parent@example.com';
+
+ $this->assertSame('parent@example.com', $dto->getApplicantEmail());
+ }
+
+ /**
+ * Ages relative to today, since isChild() measures against the current date.
+ */
+ private function createParticipantWithoutEmail(int $years): ParticipantDto
+ {
+ $participant = new ParticipantDto();
+ $participant->index = 0;
+ $participant->dateOfBirth = (new \DateTimeImmutable('today'))->modify(sprintf('-%d years', $years));
+ $participant->address = $this->createAddress();
+
+ return $participant;
+ }
+
+ /**
+ * @return list
+ */
+ private function collectFirstParticipantEmailPaths(BookingDto $dto): array
+ {
+ $validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
+ $violations = $validator->validate($dto, null, ['booking_create', 'strict_required']);
+
+ $paths = [];
+ foreach ($violations as $violation) {
+ if ('participants[0].email' === $violation->getPropertyPath()) {
+ $paths[] = $violation->getPropertyPath();
+ }
+ }
+
+ return $paths;
+ }
+
/**
* @return list
*/
diff --git a/tests/Form/Model/ParticipantEditDtoTest.php b/tests/Form/Model/ParticipantEditDtoTest.php
index a1c06e43..8fac5e84 100644
--- a/tests/Form/Model/ParticipantEditDtoTest.php
+++ b/tests/Form/Model/ParticipantEditDtoTest.php
@@ -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('applicant@example.com'),
@@ -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('Parent@Example.com'),
+ ]);
+ $bookingDto->applicantTravels = false;
+ $bookingDto->applicant = $this->createValidParticipant(new \DateTimeImmutable('1980-01-01'), 'parent@example.com');
+
+ $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('parent@example.com'),
+ ]);
+ $bookingDto->applicantTravels = false;
+ $bookingDto->applicant = $this->createValidParticipant(new \DateTimeImmutable('1980-01-01'), 'parent@example.com');
+
+ $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 = 'parent@example.com';
+
+ $bookingDto = $this->createBookingDtoWithParticipants([
+ $this->createAdultParticipant('parent@example.com'),
+ ]);
+ $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));
}
/**
diff --git a/tests/Form/Service/CreateFieldStateProviderTest.php b/tests/Form/Service/CreateFieldStateProviderTest.php
index cb0c65b2..b38dccc8 100644
--- a/tests/Form/Service/CreateFieldStateProviderTest.php
+++ b/tests/Form/Service/CreateFieldStateProviderTest.php
@@ -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'
);
}
diff --git a/tests/Form/Service/EditFieldStateProviderTest.php b/tests/Form/Service/EditFieldStateProviderTest.php
index 985ea66f..3336c871 100644
--- a/tests/Form/Service/EditFieldStateProviderTest.php
+++ b/tests/Form/Service/EditFieldStateProviderTest.php
@@ -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
diff --git a/tests/Form/Service/ParticipantEmailFieldHandlerTest.php b/tests/Form/Service/ParticipantEmailFieldHandlerTest.php
deleted file mode 100644
index 20a4cf63..00000000
--- a/tests/Form/Service/ParticipantEmailFieldHandlerTest.php
+++ /dev/null
@@ -1,162 +0,0 @@
-handler = new ParticipantEmailFieldHandler();
- }
-
- public function testChildWithoutEmailInheritsTheApplicants(): void
- {
- $bookingDto = $this->createBookingDto();
-
- $this->handler->processField(['email' => ''], $bookingDto, 1);
-
- $this->assertSame('applicant@example.com', $bookingDto->participants[1]->email);
- }
-
- public function testChildKeepsAnEmailThatWasTypedIn(): void
- {
- $bookingDto = $this->createBookingDto();
-
- $this->handler->processField(['email' => 'kind@example.com'], $bookingDto, 1);
-
- $this->assertSame(
- 'kind@example.com',
- $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('elternteil@example.com', '1981-03-16');
- $bookingDto->participants[0]->dateOfBirth = new \DateTimeImmutable('2015-01-01');
-
- $this->handler->processField(['email' => ''], $bookingDto, 0);
-
- $this->assertSame('elternteil@example.com', $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('applicant@example.com', $bookingDto->participants[1]->email);
- $this->assertSame(
- 'applicant@example.com',
- $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('applicant@example.com', '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;
- }
-}