From f595183c053d2250f4a58277e377034c878d1d3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 1 Sep 2026 15:15:58 +0200 Subject: [PATCH] fix: don't require email address from participants under 16 --- src/Form/BookingParticipantType.php | 2 +- src/Form/Model/ParticipantDto.php | 37 ++++- .../Abstract/AbstractFieldStateProvider.php | 42 +++-- .../InternalAgencyBookingCondition.php | 50 ++++++ .../Condition/RequiresOwnEmailCondition.php | 52 ++++++ src/Form/Service/CreateFieldStateProvider.php | 2 + src/Form/Service/EditFieldStateProvider.php | 33 ++-- tests/Form/Model/ParticipantDtoTest.php | 56 +++++++ tests/Form/Model/ParticipantEditDtoTest.php | 156 ++++++++++++++++++ .../Service/CreateFieldStateProviderTest.php | 91 ++++++++++ .../Service/EditFieldStateProviderTest.php | 81 +++++++++ 11 files changed, 566 insertions(+), 36 deletions(-) create mode 100644 src/Form/Service/Condition/InternalAgencyBookingCondition.php create mode 100644 src/Form/Service/Condition/RequiresOwnEmailCondition.php create mode 100644 tests/Form/Service/EditFieldStateProviderTest.php diff --git a/src/Form/BookingParticipantType.php b/src/Form/BookingParticipantType.php index e21a354..6b16bdc 100644 --- a/src/Form/BookingParticipantType.php +++ b/src/Form/BookingParticipantType.php @@ -209,7 +209,7 @@ class BookingParticipantType extends AbstractType if ($this->fieldStateProvider->shouldIncludeField('email', $bookingDto, $participantIndex)) { $form->add('email', EmailType::class, $this->mergeFieldState([ 'label' => 'E-Mail', - 'required' => !$personalDataOptional, + 'required' => false, 'property_path' => 'participant.email', 'attr' => [ 'autocomplete' => 'leave-me-alone-chrome', diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index d815035..92ddbbb 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -12,11 +12,20 @@ use App\BusProNet\Model\Service; use App\Validator\Constraints as AppAssert; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; + use function Symfony\Component\String\u; #[AppAssert\Participant(groups: ['booking_edit', 'booking_create'])] class ParticipantDto { + /** + * Age below which a participant counts as a child. + * + * Children may share an email address with an adult and are not required to have one of + * their own; both rules go through isChild(), so this is the only place the age lives. + */ + public const CHILD_AGE_THRESHOLD = 16; + /** * List of dynamic participant fields that can be conditionally hidden/shown. */ @@ -70,8 +79,14 @@ class ParticipantDto #[Assert\NotNull(message: 'Bitte angeben', groups: ['strict_required'])] public ?\DateTimeImmutable $dateOfBirth = null; + // Requiredness is delegated to requiresOwnEmail() so the constraint and the field state + // conditions cannot state the rule differently. #[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: 'this.requiresOwnEmail()', + constraints: [new Assert\NotBlank(message: 'Bitte angeben')], + groups: ['strict_required'] + )] public ?string $email = null; public ?string $mobile = null; @@ -345,11 +360,25 @@ class ParticipantDto * A child is defined as someone under the specified age threshold (default: 16 years). * This classification is used for email uniqueness validation (children can share emails with adults). * - * @param int $ageThreshold The age threshold for child classification (default: 16) - * * @return bool True if participant is under the age threshold, false otherwise or if age unknown */ - public function isChild(int $ageThreshold = 16): bool + /** + * Decides whether this participant has to supply an email address of their own. + * + * The applicant is the booking's contact and always has to be reachable. Everyone else only + * needs an address once they are old enough to have one, so children are exempt. An unknown + * date of birth counts as an adult, following isChild(). + * + * Single source for the rule: the Assert\When on $email enforces it, and the 'required' + * field state conditions in CreateFieldStateProvider/EditFieldStateProvider render the + * matching mandatory marker. + */ + public function requiresOwnEmail(): bool + { + return $this->isApplicant() || false === $this->isChild(); + } + + public function isChild(int $ageThreshold = self::CHILD_AGE_THRESHOLD): bool { $age = $this->getAge(); diff --git a/src/Form/Service/Abstract/AbstractFieldStateProvider.php b/src/Form/Service/Abstract/AbstractFieldStateProvider.php index 90bbc74..76c79d6 100644 --- a/src/Form/Service/Abstract/AbstractFieldStateProvider.php +++ b/src/Form/Service/Abstract/AbstractFieldStateProvider.php @@ -113,7 +113,9 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface * Supported state types: * - 'readonly': Adds readonly HTML attribute to the field * - 'disabled': Disables the field completely - * - 'required': Makes the field mandatory + * - 'required': Determines whether the field is mandatory. Unlike the states above this is + * authoritative in both directions: the condition result is assigned as-is, so it can also + * clear a 'required' option set by the form type. * - 'static_text': Field should be rendered as static text (checked separately via shouldRenderAsStaticText()) * - 'hidden': Field excluded from form entirely (checked via shouldIncludeField()) * @@ -137,20 +139,30 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface $attributes = []; foreach ($this->fieldStateConditions[$fieldName] as $stateType => $condition) { - if ($condition->evaluate($bookingDto, $participantIndex, $formData)) { - switch ($stateType) { - case 'readonly': - $attributes['readonly'] = true; - break; - case 'disabled': - $stateModifications['disabled'] = true; - break; - case 'required': - $stateModifications['required'] = true; - break; - // Note: 'static_text' and 'hidden' are intentionally not handled here - // They are checked via shouldRenderAsStaticText() and shouldIncludeField() - } + $isMet = $condition->evaluate($bookingDto, $participantIndex, $formData); + + // 'required' is a determination the provider owns outright, so it is assigned either + // way and can clear the form type's base option. 'readonly' and 'disabled' are + // escalations: a condition can only ever switch them on, never hand back control of a + // field the form type deliberately locked down. + if ('required' === $stateType) { + $stateModifications['required'] = $isMet; + continue; + } + + if (false === $isMet) { + continue; + } + + switch ($stateType) { + case 'readonly': + $attributes['readonly'] = true; + break; + case 'disabled': + $stateModifications['disabled'] = true; + break; + // Note: 'static_text' and 'hidden' are intentionally not handled here + // They are checked via shouldRenderAsStaticText() and shouldIncludeField() } } diff --git a/src/Form/Service/Condition/InternalAgencyBookingCondition.php b/src/Form/Service/Condition/InternalAgencyBookingCondition.php new file mode 100644 index 0000000..75b5523 --- /dev/null +++ b/src/Form/Service/Condition/InternalAgencyBookingCondition.php @@ -0,0 +1,50 @@ + $formData Current form data (unused) + * + * @return bool True if the booking was made by an internal agency + */ + public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool + { + return $bookingDto->isInternalAgencyBooking(); + } + + /** + * The agency code is a property of the booking, not of any form field. + * + * @return string[] Always empty + */ + public function getDependentFields(): array + { + return []; + } + + public function getDescription(): string + { + return 'Checks if the booking is an internal agency booking'; + } +} diff --git a/src/Form/Service/Condition/RequiresOwnEmailCondition.php b/src/Form/Service/Condition/RequiresOwnEmailCondition.php new file mode 100644 index 0000000..2f9ca21 --- /dev/null +++ b/src/Form/Service/Condition/RequiresOwnEmailCondition.php @@ -0,0 +1,52 @@ + $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 + { + $participant = $bookingDto->getParticipant($participantIndex); + + // A participant that does not exist yet is treated as needing an address, matching the + // "unknown counts as an adult" stance of ParticipantDto::isChild(). + return null === $participant || $participant->requiresOwnEmail(); + } + + /** + * 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 31cc9e4..29f4fbc 100644 --- a/src/Form/Service/CreateFieldStateProvider.php +++ b/src/Form/Service/CreateFieldStateProvider.php @@ -20,6 +20,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; @@ -113,6 +114,7 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider $this->fieldStateConditions['email'] = [ 'static_text' => $firstParticipantReadOnlyCondition, + 'required' => new RequiresOwnEmailCondition(), ]; // Mobile field: static text for first participant (when read-only), required for first participant (guest bookings) diff --git a/src/Form/Service/EditFieldStateProvider.php b/src/Form/Service/EditFieldStateProvider.php index 8ecce29..21c621c 100644 --- a/src/Form/Service/EditFieldStateProvider.php +++ b/src/Form/Service/EditFieldStateProvider.php @@ -7,7 +7,6 @@ namespace App\Form\Service; use App\BusProNet\Utility\DirectionMapper; use App\Form\Model\BookingDto; use App\Form\Service\Abstract\AbstractFieldStateProvider; -use App\Form\Service\Contract\FieldConditionInterface; use App\Form\Service\Condition\AdditionalServicesMutabilityCondition; use App\Form\Service\Condition\AgeRangeCondition; use App\Form\Service\Condition\BookingModeCondition; @@ -16,10 +15,12 @@ use App\Form\Service\Condition\DateOfBirthProvidedCondition; use App\Form\Service\Condition\FieldValueCondition; use App\Form\Service\Condition\FirstParticipantCondition; use App\Form\Service\Condition\FirstParticipantReadOnlyCondition; +use App\Form\Service\Condition\InternalAgencyBookingCondition; 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; @@ -69,16 +70,8 @@ class EditFieldStateProvider extends AbstractFieldStateProvider // TODO: Remove the block below and move 'firstName', 'lastName', 'dateOfBirth' into the // 'gender'/'nationality' loop once BPN reliably handles name and date-of-birth changes for participants. // Until then, name and DOB are always read-only for non-internal agency regardless of the BPN flag. - $corePersonalDataReadOnlyCondition = new class implements FieldConditionInterface { - public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool - { - return !$bookingDto->isInternalAgencyBooking(); - } - - public function getDependentFields(): array { return []; } - - public function getDescription(): string { return 'Name and DOB always read-only for non-internal agency (temporary)'; } - }; + $internalAgencyBookingCondition = new InternalAgencyBookingCondition(); + $corePersonalDataReadOnlyCondition = CompositeCondition::not($internalAgencyBookingCondition); foreach (['firstName', 'lastName', 'dateOfBirth'] as $field) { $this->fieldStateConditions[$field] = ['static_text' => $corePersonalDataReadOnlyCondition]; } @@ -92,11 +85,19 @@ class EditFieldStateProvider extends AbstractFieldStateProvider } // Contact fields: only the applicant (index 0) is read-only — participants can always update email/phone - foreach (['email', 'mobile'] as $field) { - $this->fieldStateConditions[$field] = [ - 'static_text' => $firstParticipantReadOnlyCondition, - ]; - } + $this->fieldStateConditions['mobile'] = [ + 'static_text' => $firstParticipantReadOnlyCondition, + ]; + + // Same rule as the create flow, except that internal agency bookings leave personal + // data optional throughout. + $this->fieldStateConditions['email'] = [ + 'static_text' => $firstParticipantReadOnlyCondition, + '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 $this->fieldStateConditions['address'] = [ diff --git a/tests/Form/Model/ParticipantDtoTest.php b/tests/Form/Model/ParticipantDtoTest.php index e7dd3e5..e8b8ddc 100644 --- a/tests/Form/Model/ParticipantDtoTest.php +++ b/tests/Form/Model/ParticipantDtoTest.php @@ -204,4 +204,60 @@ class ParticipantDtoTest extends TestCase return $insurance; } + + public function testRequiresOwnEmailIsFalseForNonApplicantChild(): void + { + $participant = new ParticipantDto(); + $participant->index = 1; + $participant->dateOfBirth = new \DateTimeImmutable('2015-01-01'); + + $this->assertFalse($participant->requiresOwnEmail()); + } + + public function testRequiresOwnEmailIsTrueForNonApplicantAdult(): void + { + $participant = new ParticipantDto(); + $participant->index = 1; + $participant->dateOfBirth = new \DateTimeImmutable('1990-01-01'); + + $this->assertTrue($participant->requiresOwnEmail()); + } + + public function testRequiresOwnEmailIsTrueForTheApplicantEvenAsAChild(): void + { + $participant = new ParticipantDto(); + $participant->index = 0; + $participant->dateOfBirth = new \DateTimeImmutable('2015-01-01'); + + $this->assertTrue( + $participant->requiresOwnEmail(), + 'The applicant is the booking contact and always has to be reachable' + ); + } + + public function testRequiresOwnEmailTreatsUnknownAgeAsAdult(): void + { + $participant = new ParticipantDto(); + $participant->index = 1; + $participant->dateOfBirth = null; + + $this->assertTrue($participant->requiresOwnEmail()); + } + + public function testRequiresOwnEmailBoundaryAtChildAgeThreshold(): void + { + $exactlyThreshold = new ParticipantDto(); + $exactlyThreshold->index = 1; + $exactlyThreshold->dateOfBirth = (new \DateTimeImmutable('today')) + ->modify(sprintf('-%d years', ParticipantDto::CHILD_AGE_THRESHOLD)); + + $oneDayShort = new ParticipantDto(); + $oneDayShort->index = 1; + $oneDayShort->dateOfBirth = (new \DateTimeImmutable('today')) + ->modify(sprintf('-%d years', ParticipantDto::CHILD_AGE_THRESHOLD)) + ->modify('+1 day'); + + $this->assertTrue($exactlyThreshold->requiresOwnEmail()); + $this->assertFalse($oneDayShort->requiresOwnEmail()); + } } diff --git a/tests/Form/Model/ParticipantEditDtoTest.php b/tests/Form/Model/ParticipantEditDtoTest.php index 7b56c78..48e3034 100644 --- a/tests/Form/Model/ParticipantEditDtoTest.php +++ b/tests/Form/Model/ParticipantEditDtoTest.php @@ -30,6 +30,7 @@ use Symfony\Component\Validator\Constraints\Email; use Symfony\Component\Validator\Constraints\EmailValidator; use Symfony\Component\Validator\ConstraintValidatorFactoryInterface; use Symfony\Component\Validator\ConstraintValidatorInterface; +use Symfony\Component\Validator\ConstraintViolationListInterface; use Symfony\Component\Validator\Validation; use Symfony\Component\Validator\Validator\ValidatorInterface; @@ -856,6 +857,161 @@ class ParticipantEditDtoTest extends TestCase * available ski pass. Ski pass validation depends on the travel actually offering * passes, so a travel without them is a distinct fixture, not the default. */ + public function testChildWithoutEmailPassesStrictValidation(): void + { + $bookingDto = $this->createBookingDtoWithParticipants([ + $this->createAdultParticipant('applicant@example.com'), + $this->createChildParticipant(''), + ]); + + $violations = $this->validateStrict($bookingDto, 1); + + $this->assertSame([], $this->emailRequiredMessages($violations)); + } + + public function testChildWithNullEmailPassesStrictValidation(): void + { + $child = $this->createChildParticipant(''); + $child->email = null; + + $bookingDto = $this->createBookingDtoWithParticipants([ + $this->createAdultParticipant('applicant@example.com'), + $child, + ]); + + $violations = $this->validateStrict($bookingDto, 1); + + $this->assertSame([], $this->emailRequiredMessages($violations)); + } + + public function testChildWithMalformedEmailStillFailsEmailFormatValidation(): void + { + $bookingDto = $this->createBookingDtoWithParticipants([ + $this->createAdultParticipant('applicant@example.com'), + $this->createChildParticipant('not-an-email'), + ]); + + $violations = $this->validator->validate( + new ParticipantEditDto( + participant: $bookingDto->participants[1], + bookingContext: $bookingDto, + ), + null, + ['booking_create', 'strict_required'] + ); + + $messages = []; + foreach ($violations as $violation) { + if ('participant.email' === $violation->getPropertyPath()) { + $messages[] = $violation->getMessage(); + } + } + + $this->assertSame(['Bitte eine gültige E-Mail Adresse angeben'], $messages); + } + + public function testAdultDependentWithoutEmailFailsStrictValidation(): void + { + $bookingDto = $this->createBookingDtoWithParticipants([ + $this->createAdultParticipant('applicant@example.com'), + $this->createAdultParticipant(''), + ]); + + $violations = $this->validateStrict($bookingDto, 1); + + $this->assertSame(['Bitte angeben'], $this->emailRequiredMessages($violations)); + } + + public function testDependentWithUnknownDateOfBirthAndNoEmailFailsStrictValidation(): void + { + $participant = $this->createAdultParticipant(''); + $participant->dateOfBirth = null; + + $bookingDto = $this->createBookingDtoWithParticipants([ + $this->createAdultParticipant('applicant@example.com'), + $participant, + ]); + + $violations = $this->validateStrict($bookingDto, 1); + + $this->assertSame(['Bitte angeben'], $this->emailRequiredMessages($violations)); + } + + public function testApplicantChildWithoutEmailStillFailsStrictValidation(): void + { + $bookingDto = $this->createBookingDtoWithParticipants([ + $this->createChildParticipant(''), + $this->createAdultParticipant('other@example.com'), + ]); + + $violations = $this->validateStrict($bookingDto, 0); + + $this->assertSame(['Bitte angeben'], $this->emailRequiredMessages($violations)); + } + + public function testDependentTurningExactlySixteenRequiresEmail(): void + { + $participant = $this->createAdultParticipant(''); + $participant->dateOfBirth = (new \DateTimeImmutable('today'))->modify('-16 years'); + + $bookingDto = $this->createBookingDtoWithParticipants([ + $this->createAdultParticipant('applicant@example.com'), + $participant, + ]); + + $violations = $this->validateStrict($bookingDto, 1); + + $this->assertSame(['Bitte angeben'], $this->emailRequiredMessages($violations)); + } + + public function testDependentOneDayShortOfSixteenDoesNotRequireEmail(): void + { + $participant = $this->createAdultParticipant(''); + $participant->dateOfBirth = (new \DateTimeImmutable('today'))->modify('-16 years')->modify('+1 day'); + + $bookingDto = $this->createBookingDtoWithParticipants([ + $this->createAdultParticipant('applicant@example.com'), + $participant, + ]); + + $violations = $this->validateStrict($bookingDto, 1); + + $this->assertSame([], $this->emailRequiredMessages($violations)); + } + + /** + * Validates one participant with the strict group active, as the booking create flow does. + */ + private function validateStrict(BookingDto $bookingDto, int $index): ConstraintViolationListInterface + { + return $this->validator->validate( + new ParticipantEditDto( + participant: $bookingDto->participants[$index], + bookingContext: $bookingDto, + ), + null, + ['booking_create', 'strict_required'] + ); + } + + /** + * Narrows a violation list down to the "email is mandatory" messages. + * + * @return list + */ + private function emailRequiredMessages(ConstraintViolationListInterface $violations): array + { + $messages = []; + + foreach ($violations as $violation) { + if ('participant.email' === $violation->getPropertyPath() && 'Bitte angeben' === $violation->getMessage()) { + $messages[] = $violation->getMessage(); + } + } + + return $messages; + } + private function createTravelOfferingSkiPasses(?int $ageFrom = null): Travel { $travel = $this->createTravelWithoutSkiPasses(); diff --git a/tests/Form/Service/CreateFieldStateProviderTest.php b/tests/Form/Service/CreateFieldStateProviderTest.php index 22797f5..d26e3b9 100644 --- a/tests/Form/Service/CreateFieldStateProviderTest.php +++ b/tests/Form/Service/CreateFieldStateProviderTest.php @@ -73,4 +73,95 @@ class CreateFieldStateProviderTest extends TestCase 'Dependent insurance field must reappear once the applicant switches away from family insurance' ); } + + public function testEmailIsNotRequiredForNonApplicantChild(): void + { + $bookingDto = $this->createBookingDtoWithApplicantAndDependent(new \DateTimeImmutable('2015-01-01')); + + $this->assertFalse( + $this->provider->getFieldState('email', $bookingDto, 1)['required'], + 'A child travelling with an applicant does not need an email address of their own' + ); + } + + 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 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' + ); + } + + /** + * Guards the switch from add-only to toggling 'required' state: the two fields that already + * used a 'required' condition must keep behaving exactly as before, now reporting false + * instead of omitting the key. Both declare 'required' => false as their base in the form + * type, so assigning the condition result either way is equivalent to the old add-only pass. + */ + public function testMobileAndAddressRemainRequiredOnlyForTheFirstParticipant(): void + { + $bookingDto = $this->createBookingDtoWithApplicantAndDependent(new \DateTimeImmutable('1990-01-01')); + + $this->assertTrue($this->provider->getFieldState('mobile', $bookingDto, 0)['required']); + $this->assertFalse($this->provider->getFieldState('mobile', $bookingDto, 1)['required']); + + $this->assertTrue($this->provider->getFieldState('address', $bookingDto, 0)['required']); + $this->assertFalse($this->provider->getFieldState('address', $bookingDto, 1)['required']); + } + + private function createBookingDtoWithApplicantAndDependent(\DateTimeImmutable $dependentDateOfBirth): BookingDto + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('+30 days'); + $travel->dateTo = new \DateTimeImmutable('+37 days'); + + $applicant = new ParticipantDto(); + $applicant->index = 0; + $applicant->dateOfBirth = new \DateTimeImmutable('1980-01-01'); + + $dependent = new ParticipantDto(); + $dependent->index = 1; + $dependent->dateOfBirth = $dependentDateOfBirth; + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$applicant, $dependent]; + + return $bookingDto; + } } diff --git a/tests/Form/Service/EditFieldStateProviderTest.php b/tests/Form/Service/EditFieldStateProviderTest.php new file mode 100644 index 0000000..3336c87 --- /dev/null +++ b/tests/Form/Service/EditFieldStateProviderTest.php @@ -0,0 +1,81 @@ +provider = new EditFieldStateProvider(); + } + + public function testEmailIsRequiredForNonApplicantAdult(): void + { + $bookingDto = $this->createBookingDto(new \DateTimeImmutable('1990-01-01'), false); + + $this->assertTrue($this->provider->getFieldState('email', $bookingDto, 1)['required']); + } + + public function testEmailIsNotRequiredForNonApplicantChild(): void + { + $bookingDto = $this->createBookingDto(new \DateTimeImmutable('2015-01-01'), false); + + $this->assertFalse($this->provider->getFieldState('email', $bookingDto, 1)['required']); + } + + public function testEmailIsNotRequiredForInternalAgencyBookingsEvenForAdults(): void + { + $bookingDto = $this->createBookingDto(new \DateTimeImmutable('1990-01-01'), true); + + $this->assertFalse( + $this->provider->getFieldState('email', $bookingDto, 1)['required'], + 'Internal agency bookings leave personal data optional throughout' + ); + } + + public function testEmailIsRequiredForTheApplicantOutsideInternalAgencyBookings(): void + { + $bookingDto = $this->createBookingDto(new \DateTimeImmutable('1990-01-01'), false); + + $this->assertTrue($this->provider->getFieldState('email', $bookingDto, 0)['required']); + } + + private function createBookingDto(\DateTimeImmutable $dependentDateOfBirth, bool $internalAgency): BookingDto + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('+30 days'); + $travel->dateTo = new \DateTimeImmutable('+37 days'); + + $applicant = new ParticipantDto(); + $applicant->index = 0; + $applicant->dateOfBirth = new \DateTimeImmutable('1980-01-01'); + + $dependent = new ParticipantDto(); + $dependent->index = 1; + $dependent->dateOfBirth = $dependentDateOfBirth; + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$applicant, $dependent]; + + if (true === $internalAgency) { + $bookingDto->agencyCode = AgencyLoader::INTERNAL_AGENCY_CODE; + } + + return $bookingDto; + } +}