From 1d9c852113e8a56e88a9b82ad1ebaac79b540925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 15 Oct 2026 10:30:27 +0200 Subject: [PATCH] feat: make email optional for under-16 participants except applicant --- config/services.yaml | 1 - docs/bpn-xml-api-reference.md | 18 +- .../DataProcessor/BookingPayloadBuilder.php | 14 +- src/Form/BookingParticipantType.php | 4 +- src/Form/Model/BookingDto.php | 53 +++++- src/Form/Model/ParticipantDto.php | 36 +++- src/Form/Model/ParticipantEditDto.php | 27 ++- .../Condition/RequiresOwnEmailCondition.php | 47 +++++ src/Form/Service/CreateFieldStateProvider.php | 4 +- src/Form/Service/EditFieldStateProvider.php | 6 +- .../Service/ParticipantEmailFieldHandler.php | 110 ------------ .../ParticipantFieldHandlerRegistry.php | 3 - .../BookingPayloadBuilderTest.php | 12 +- tests/Form/Model/BookingDtoTest.php | 91 ++++++++++ tests/Form/Model/ParticipantEditDtoTest.php | 111 ++++++++++-- .../Service/CreateFieldStateProviderTest.php | 65 ++++++- .../Service/EditFieldStateProviderTest.php | 7 +- .../ParticipantEmailFieldHandlerTest.php | 162 ------------------ 18 files changed, 435 insertions(+), 336 deletions(-) create mode 100644 src/Form/Service/Condition/RequiresOwnEmailCondition.php delete mode 100644 src/Form/Service/ParticipantEmailFieldHandler.php delete mode 100644 tests/Form/Service/ParticipantEmailFieldHandlerTest.php 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; - } -}