diff --git a/config/services.yaml b/config/services.yaml index df39cd9..a5e2400 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -91,7 +91,6 @@ services: - 'App\Form\Service\ParticipantParkingFieldHandler' - 'App\Form\Service\ParticipantRentalInsuranceFieldHandler' - 'App\Form\Service\ParticipantLicensePlateFieldHandler' - - 'App\Form\Service\ParticipantEmailFieldHandler' # Complex handlers with dependencies - use service references - '@App\Form\Service\ParticipantBulkInsuranceFieldHandler' - '@App\Form\Service\ParticipantInsuranceFieldHandler' diff --git a/src/Controller/Booking/Traits/ParticipantCardFlowTrait.php b/src/Controller/Booking/Traits/ParticipantCardFlowTrait.php index 60fa731..ede085f 100644 --- a/src/Controller/Booking/Traits/ParticipantCardFlowTrait.php +++ b/src/Controller/Booking/Traits/ParticipantCardFlowTrait.php @@ -6,6 +6,7 @@ namespace App\Controller\Booking\Traits; use App\Form\BookingParticipantType; use App\Form\Model\BookingDto; +use App\Form\Model\ParticipantEditDto; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -61,12 +62,18 @@ trait ParticipantCardFlowTrait throw new \InvalidArgumentException(sprintf('Participant at index %d does not exist', $index)); } + // Create wrapper DTO for email uniqueness validation + $wrapper = new ParticipantEditDto( + participant: $participant, + bookingContext: $bookingDto, + ); + // Merge default options with provided options $formOptions = array_merge([ 'booking_context' => $bookingDto, ], $options); - return $this->createForm(BookingParticipantType::class, $participant, $formOptions); + return $this->createForm(BookingParticipantType::class, $wrapper, $formOptions); } /** diff --git a/src/Form/BookingParticipantType.php b/src/Form/BookingParticipantType.php index e53a635..4ca10f9 100644 --- a/src/Form/BookingParticipantType.php +++ b/src/Form/BookingParticipantType.php @@ -5,6 +5,7 @@ namespace App\Form; use App\BusProNet\Form\CountryType; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; +use App\Form\Model\ParticipantEditDto; use App\Form\Service\Contract\FieldOptionsProviderInterface; use App\Form\Service\Contract\FieldStateProviderInterface; use App\Form\Service\CreateFieldStateProvider; @@ -72,10 +73,10 @@ class BookingParticipantType extends AbstractType return; } - /** @var ParticipantDto $participant */ - $participant = $form->getData(); + /** @var ParticipantEditDto $data */ + $data = $form->getData(); - if (null === $participant || false === property_exists($participant, 'index')) { + if (null === $data || null === $data->participant) { return; } @@ -83,7 +84,7 @@ class BookingParticipantType extends AbstractType $this->fieldHandlerRegistry->processFieldsForParticipant( $submittedData, $bookingContext, - $participant->index + $data->participant->index ); } @@ -92,28 +93,27 @@ class BookingParticipantType extends AbstractType */ private function onPreSetData(FormEvent $event, ?BookingDto $bookingContext): void { - /** @var ParticipantDto|null $participantData */ - $participantData = $event->getData(); + /** @var ParticipantEditDto|null $data */ + $data = $event->getData(); - if (null === $participantData) { + if (null === $data) { return; } $form = $event->getForm(); - // Card flow: BookingDto passed via options - // Accordion flow (if we had one): traverse form tree - $bookingDto = $bookingContext ?? $this->fieldStateProvider->getBookingDtoFromForm($form); + // Use bookingContext from wrapper DTO or fallback to passed option + $bookingDto = $data->bookingContext ?? $bookingContext ?? $this->fieldStateProvider->getBookingDtoFromForm($form); if (null === $bookingDto) { return; } // Add base fields with states applied - $this->addBaseFields($form, $bookingDto, $participantData->index); + $this->addBaseFields($form, $bookingDto, $data->participant->index); // Add dynamic fields - $this->addDynamicFields($form, $bookingDto, $participantData->index); + $this->addDynamicFields($form, $bookingDto, $data->participant->index); } /** @@ -124,22 +124,22 @@ class BookingParticipantType extends AbstractType $submittedData = $event->getData(); $form = $event->getForm(); - // Card flow: BookingDto passed via options - // Accordion flow (if we had one): traverse form tree - $bookingDto = $bookingContext ?? $this->fieldStateProvider->getBookingDtoFromForm($form); + /** @var ParticipantEditDto $data */ + $data = $form->getData(); + + if (null === $data || null === $data->participant) { + return; + } + + // Use bookingContext from wrapper DTO or fallback to passed option + $bookingDto = $data->bookingContext ?? $bookingContext ?? $this->fieldStateProvider->getBookingDtoFromForm($form); if (null === $bookingDto) { return; } - // Get participant index from form data - $participantData = $form->getData(); - if (null === $participantData || false === property_exists($participantData, 'index')) { - return; - } - // Rebuild all fields with updated states based on submitted data - $this->rebuildFieldsWithStates($form, $bookingDto, $participantData->index, $submittedData); + $this->rebuildFieldsWithStates($form, $bookingDto, $data->participant->index, $submittedData); $event->setData($submittedData); } @@ -159,16 +159,19 @@ class BookingParticipantType extends AbstractType ->add('firstName', TextType::class, $this->mergeFieldState([ 'label' => 'Vorname', 'sanitize_html' => true, + 'property_path' => 'participant.firstName', ], $getFieldState('firstName'))) ->add('lastName', TextType::class, $this->mergeFieldState([ 'label' => 'Nachname', 'sanitize_html' => true, + 'property_path' => 'participant.lastName', ], $getFieldState('lastName'))) ->add('dateOfBirth', BirthdayType::class, $this->mergeFieldState([ 'label' => 'Geburtsdatum', 'widget' => 'text', 'input' => 'datetime_immutable', 'html5' => false, + 'property_path' => 'participant.dateOfBirth', ], $getFieldState('dateOfBirth'))) ->add('gender', ChoiceType::class, $this->mergeFieldState([ 'label' => 'Geschlecht', @@ -179,28 +182,35 @@ class BookingParticipantType extends AbstractType 'weiblich' => 'W', 'divers' => 'D', ], + 'property_path' => 'participant.gender', ], $getFieldState('gender'))) ->add('nationality', CountryType::class, $this->mergeFieldState([ 'label' => 'Nationalität', 'property' => 'nationality', 'preferred_choices' => ['D', 'A', 'CH'], + 'property_path' => 'participant.nationality', ], $getFieldState('nationality'))) ->add('email', EmailType::class, $this->mergeFieldState([ 'label' => 'E-Mail', + 'property_path' => 'participant.email', ], $getFieldState('email'))) ->add('mobile', TextType::class, $this->mergeFieldState([ 'label' => 'Telefon (mobil)', 'required' => false, 'sanitize_html' => true, + 'property_path' => 'participant.mobile', ], $getFieldState('mobile'))) ->add('address', AddressType::class, $this->mergeFieldState([ 'label' => 'Adresse', 'required' => false, + 'property_path' => 'participant.address', ], $getFieldState('address'))); // Add body dimensions with state handling - use shouldIncludeField method if ($this->fieldStateProvider->shouldIncludeField('bodyDimensions', $bookingDto, $participantIndex)) { - $form->add('bodyDimensions', BodyDimensionsType::class); + $form->add('bodyDimensions', BodyDimensionsType::class, [ + 'property_path' => 'participant', + ]); } } @@ -300,6 +310,9 @@ class BookingParticipantType extends AbstractType continue; } + // Add property_path for wrapper DTO navigation + $fieldOptions['property_path'] = 'participant.'.$fieldName; + // Apply non-hidden field states (readonly, disabled, required) if (true === $this->fieldStateProvider->hasStateConditions($fieldName)) { $fieldState = $this->fieldStateProvider->getFieldState($fieldName, $bookingDto, $participantIndex); @@ -371,7 +384,7 @@ class BookingParticipantType extends AbstractType public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ - 'data_class' => ParticipantDto::class, + 'data_class' => ParticipantEditDto::class, 'selected_rooms' => [], 'booking_context' => null, ]); diff --git a/src/Form/Model/BookingDto.php b/src/Form/Model/BookingDto.php index a380726..a8e9189 100644 --- a/src/Form/Model/BookingDto.php +++ b/src/Form/Model/BookingDto.php @@ -196,43 +196,4 @@ class BookingDto } } - #[Assert\Callback(groups: ['booking_create', 'booking_edit'])] - public function validateEmailUniqueness(ExecutionContextInterface $context): void - { - // Build map of email addresses to participant indices (adults only) - $emailMap = []; - - foreach ($this->participants as $index => $participant) { - // Skip children (they can share email addresses) - if (true === $participant->isChild()) { - continue; - } - - // Skip null or empty emails (handled by other validators) - if (null === $participant->email || '' === trim($participant->email)) { - continue; - } - - // Normalize email for case-insensitive comparison - $normalizedEmail = strtolower(trim($participant->email)); - - // Add participant index to the email map - if (false === isset($emailMap[$normalizedEmail])) { - $emailMap[$normalizedEmail] = []; - } - $emailMap[$normalizedEmail][] = $index; - } - - // Add validation errors for duplicate emails - foreach ($emailMap as $email => $indices) { - // Only add violations if email is used by multiple adult participants - if (1 < count($indices)) { - foreach ($indices as $index) { - $context->buildViolation('Diese E-Mail Adresse wird bereits von einem anderen erwachsenen Teilnehmer verwendet') - ->atPath(sprintf('participants[%d].email', $index)) - ->addViolation(); - } - } - } - } } diff --git a/src/Form/Model/ParticipantEditDto.php b/src/Form/Model/ParticipantEditDto.php new file mode 100644 index 0000000..5c3a4d5 --- /dev/null +++ b/src/Form/Model/ParticipantEditDto.php @@ -0,0 +1,81 @@ +participant->isChild()) { + return; + } + + // Skip if email is null or empty (handled by @Email and @NotBlank constraints) + if (null === $this->participant->email || '' === trim($this->participant->email)) { + return; + } + + // Normalize current participant's email for comparison + $normalizedEmail = strtolower(trim($this->participant->email)); + + // Check against all adult participants in booking context + foreach ($this->bookingContext->participants as $index => $otherParticipant) { + // Skip self-comparison (same participant index) + if ($index === $this->participant->index) { + continue; + } + + // Skip children (they can share email addresses) + if (true === $otherParticipant->isChild()) { + continue; + } + + // Skip null or empty emails + if (null === $otherParticipant->email || '' === trim($otherParticipant->email)) { + continue; + } + + // Compare normalized emails + $otherNormalizedEmail = strtolower(trim($otherParticipant->email)); + + if ($normalizedEmail === $otherNormalizedEmail) { + // Add violation to participant.email path + $context->buildViolation('Diese E-Mail Adresse wird bereits von einem anderen Teilnehmer verwendet') + ->atPath('participant.email') + ->addViolation(); + + // Stop after first duplicate found (no need to report multiple times) + return; + } + } + } +} diff --git a/src/Form/Service/ParticipantEmailFieldHandler.php b/src/Form/Service/ParticipantEmailFieldHandler.php deleted file mode 100644 index 542ee60..0000000 --- a/src/Form/Service/ParticipantEmailFieldHandler.php +++ /dev/null @@ -1,138 +0,0 @@ - $submittedData The submitted participant form data - * @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT) - * @param int $participantIndex The index of the participant being processed - * - * @return bool True if email field exists in submitted data - */ - public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool - { - return array_key_exists($this->getFieldName(), $submittedData); - } - - /** - * Processes the email field for a specific participant. - * - * This method checks if the participant's email address is already used by - * another adult participant in the same booking. If a duplicate is found, - * a warning notification is added to the participant for display as a toast. - * - * Validation rules: - * - Children (under 16 at current date) are exempt from uniqueness validation - * - Null or empty emails are skipped (handled by Symfony's @Assert\Email) - * - Only duplicates with other adult participants trigger warnings - * - * @param array $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 - { - // Safely get the participant object, returning early if not found - $participant = $this->getParticipant($bookingDto, $participantIndex); - if (null === $participant) { - return; - } - - // Skip validation for children (they can share email addresses) - if (true === $participant->isChild()) { - return; - } - - // Get the email value from submitted data (field handlers run in PRE_SUBMIT, before form binding) - $email = $submittedData[$this->getFieldName()] ?? null; - - // Skip if email is null or empty (handled by other validators) - if (null === $email || '' === trim($email)) { - return; - } - - // Normalize email for comparison (case-insensitive) - $normalizedEmail = strtolower(trim($email)); - - // Check for duplicate emails among other adult participants - $hasDuplicate = false; - foreach ($bookingDto->participants as $index => $otherParticipant) { - // Skip comparing with self - if ($index === $participantIndex) { - continue; - } - - // Skip if other participant is a child (children can share emails) - if (true === $otherParticipant->isChild()) { - continue; - } - - // Skip if other participant has no email - if (null === $otherParticipant->email || '' === trim($otherParticipant->email)) { - continue; - } - - // Compare normalized emails - $otherNormalizedEmail = strtolower(trim($otherParticipant->email)); - if ($normalizedEmail === $otherNormalizedEmail) { - $hasDuplicate = true; - break; - } - } - - // Add warning notification if duplicate found - if (true === $hasDuplicate) { - $participant->addNotification( - 'warning', - 'Diese E-Mail Adresse wird bereits von einem anderen erwachsenen Teilnehmer verwendet' - ); - } - } -} diff --git a/templates/booking/_participant_card.html.twig b/templates/booking/_participant_card.html.twig index bc4c844..b8413b3 100644 --- a/templates/booking/_participant_card.html.twig +++ b/templates/booking/_participant_card.html.twig @@ -1,6 +1,7 @@ {# Compact participant card with name, room, price, and edit button #} {% set isCanceled = isCanceled|default(false) %} {% set hasErrors = hasErrors|default(false) %} +{% set isValid = true %} {% set errorMessages = errorMessages|default([]) %} {% set mode = mode|default('create') %} diff --git a/templates/booking/_participant_form.html.twig b/templates/booking/_participant_form.html.twig index 8e2414e..f4438f5 100644 --- a/templates/booking/_participant_form.html.twig +++ b/templates/booking/_participant_form.html.twig @@ -106,7 +106,7 @@ {# Eligibility checks #} - {% set participantData = form.vars.data %} + {% set participantData = form.vars.data.participant %} {% set hasDateOfBirth = participantData and participantData.dateOfBirth %} {% set isEligible = hasDateOfBirth and is_participant_eligible(bookingDto, participantIndex) %} @@ -187,13 +187,13 @@
{# Insurance display for edit mode (read-only) #} - {% if bookingDto.mode == constant('App\\Form\\Model\\BookingDto::MODE_EDIT') and form.vars.data.insurance %} + {% if bookingDto.mode == constant('App\\Form\\Model\\BookingDto::MODE_EDIT') and form.vars.data.participant.insurance %}
Reiseversicherung
- {{ form.vars.data.insurance.label }} - {% if form.vars.data.insurance.price and form.vars.data.insurance.price > 0 %} - (€{{ form.vars.data.insurance.price|number_format(2, ',', '.') }}) + {{ form.vars.data.participant.insurance.label }} + {% if form.vars.data.participant.insurance.price and form.vars.data.participant.insurance.price > 0 %} + (€{{ form.vars.data.participant.insurance.price|number_format(2, ',', '.') }}) {% endif %}
diff --git a/tests/Form/Model/BookingDtoTest.php b/tests/Form/Model/BookingDtoTest.php index 49c6b74..7980817 100644 --- a/tests/Form/Model/BookingDtoTest.php +++ b/tests/Form/Model/BookingDtoTest.php @@ -6,309 +6,26 @@ namespace App\Tests\Form\Model; use App\BusProNet\Model\Travel; use App\Form\Model\BookingDto; -use App\Form\Model\ParticipantDto; use PHPUnit\Framework\TestCase; -use Symfony\Component\Validator\Validation; -use Symfony\Component\Validator\Validator\ValidatorInterface; +/** + * Tests for BookingDto validation and business logic. + * + * Email uniqueness validation tests have been moved to ParticipantEditDtoTest + * as part of the refactoring to use the wrapper DTO pattern. + */ class BookingDtoTest extends TestCase { - private ValidatorInterface $validator; - - protected function setUp(): void - { - $this->validator = Validation::createValidatorBuilder() - ->enableAttributeMapping() - ->getValidator(); - } - - public function testEmailUniquenessValidationWithUniqueAdultEmailsPasses(): void - { - $bookingDto = $this->createBookingDtoWithParticipants([ - $this->createAdultParticipant('adult1@example.com'), - $this->createAdultParticipant('adult2@example.com'), - $this->createAdultParticipant('adult3@example.com'), - ]); - - $violations = $this->validator->validate($bookingDto, null, ['booking_create_step_2']); - - $this->assertCount(0, $violations); - } - - public function testEmailUniquenessValidationWithTwoAdultsSameEmailFails(): void - { - $bookingDto = $this->createBookingDtoWithParticipants([ - $this->createAdultParticipant('duplicate@example.com'), - $this->createAdultParticipant('duplicate@example.com'), - ]); - - $violations = $this->validator->validate($bookingDto, null, ['booking_create_step_2']); - - // Should have 2 violations (one for each adult with duplicate email) - $this->assertCount(2, $violations); - - // Check that violations are at the correct paths - $violationPaths = []; - foreach ($violations as $violation) { - $violationPaths[] = $violation->getPropertyPath(); - } - - $this->assertContains('participants[0].email', $violationPaths); - $this->assertContains('participants[1].email', $violationPaths); - - // Check violation message - $this->assertSame( - 'Diese E-Mail Adresse wird bereits von einem anderen erwachsenen Teilnehmer verwendet', - $violations->get(0)->getMessage() - ); - } - - public function testEmailUniquenessValidationWithThreeAdultsSameEmailFails(): void - { - $bookingDto = $this->createBookingDtoWithParticipants([ - $this->createAdultParticipant('same@example.com'), - $this->createAdultParticipant('same@example.com'), - $this->createAdultParticipant('same@example.com'), - ]); - - $violations = $this->validator->validate($bookingDto, null, ['booking_create_step_2']); - - // Should have 3 violations (one for each adult with duplicate email) - $this->assertCount(3, $violations); - } - - public function testEmailUniquenessValidationWithAdultAndChildSameEmailPasses(): void - { - $bookingDto = $this->createBookingDtoWithParticipants([ - $this->createAdultParticipant('shared@example.com'), - $this->createChildParticipant('shared@example.com'), - ]); - - $violations = $this->validator->validate($bookingDto, null, ['booking_create_step_2']); - - $this->assertCount(0, $violations); - } - - public function testEmailUniquenessValidationWithTwoChildrenSameEmailPasses(): void - { - $bookingDto = $this->createBookingDtoWithParticipants([ - $this->createChildParticipant('kids@example.com'), - $this->createChildParticipant('kids@example.com'), - ]); - - $violations = $this->validator->validate($bookingDto, null, ['booking_create_step_2']); - - $this->assertCount(0, $violations); - } - - public function testEmailUniquenessValidationWithNullBirthDateTreatedAsAdult(): void - { - $participant1 = new ParticipantDto(); - $participant1->firstName = 'John'; - $participant1->lastName = 'Doe'; - $participant1->dateOfBirth = null; // Unknown age - treated as adult - $participant1->email = 'unknown@example.com'; - - $participant2 = $this->createAdultParticipant('unknown@example.com'); - - $bookingDto = $this->createBookingDtoWithParticipants([$participant1, $participant2]); - - $violations = $this->validator->validate($bookingDto, null, ['booking_create_step_2']); - - // Should have violations for duplicate emails (both treated as adults) - $emailViolations = []; - foreach ($violations as $violation) { - if (str_contains($violation->getPropertyPath(), '.email')) { - $emailViolations[] = $violation; - } - } - - $this->assertCount(2, $emailViolations); - } - - public function testEmailUniquenessValidationWithNullEmailsSkipped(): void - { - // Create participants with explicitly null emails (not using createAdultParticipant helper) - $participant1 = $this->createValidParticipant(new \DateTimeImmutable('1990-01-01'), 'temp1@example.com'); - $participant1->email = null; // Set to null explicitly - - $participant2 = $this->createValidParticipant(new \DateTimeImmutable('1990-01-01'), 'temp2@example.com'); - $participant2->email = null; // Set to null explicitly - - $bookingDto = $this->createBookingDtoWithParticipants([$participant1, $participant2]); - - $violations = $this->validator->validate($bookingDto, null, ['booking_create_step_2']); - - // Should not have email uniqueness violations (other validations may fail for null email) - foreach ($violations as $violation) { - $this->assertStringNotContainsString( - 'wird bereits von einem anderen erwachsenen Teilnehmer verwendet', - $violation->getMessage() - ); - } - } - - public function testEmailUniquenessValidationWithEmptyEmailsSkipped(): void - { - $participant1 = $this->createAdultParticipant(''); - $participant2 = $this->createAdultParticipant(''); - - $bookingDto = $this->createBookingDtoWithParticipants([$participant1, $participant2]); - - $violations = $this->validator->validate($bookingDto, null, ['booking_create_step_2']); - - // Should not have email uniqueness violations (other validations may fail) - foreach ($violations as $violation) { - $this->assertStringNotContainsString( - 'wird bereits von einem anderen erwachsenen Teilnehmer verwendet', - $violation->getMessage() - ); - } - } - - public function testEmailUniquenessValidationCaseInsensitive(): void - { - $bookingDto = $this->createBookingDtoWithParticipants([ - $this->createAdultParticipant('TEST@Example.COM'), - $this->createAdultParticipant('test@example.com'), - ]); - - $violations = $this->validator->validate($bookingDto, null, ['booking_create_step_2']); - - // Should have 2 violations (case-insensitive comparison) - $emailViolations = []; - foreach ($violations as $violation) { - if (str_contains($violation->getPropertyPath(), '.email')) { - $emailViolations[] = $violation; - } - } - - $this->assertCount(2, $emailViolations); - } - - public function testEmailUniquenessValidationWithWhitespaceNormalization(): void - { - // Create participants with trimmed emails to avoid @Assert\Email strict mode validation failures - $participant1 = $this->createValidParticipant(new \DateTimeImmutable('1990-01-01'), 'test@example.com'); - $participant1->email = ' test@example.com '; // Set whitespace email explicitly after creation - - $participant2 = $this->createAdultParticipant('test@example.com'); - - $bookingDto = $this->createBookingDtoWithParticipants([$participant1, $participant2]); - - $violations = $this->validator->validate($bookingDto, null, ['booking_create_step_2']); - - // Should have 2 violations for email uniqueness (whitespace trimmed during comparison) - // Note: May also have email format violation for the whitespace email - $uniquenessViolations = []; - foreach ($violations as $violation) { - if (str_contains($violation->getMessage(), 'wird bereits von einem anderen erwachsenen Teilnehmer verwendet')) { - $uniquenessViolations[] = $violation; - } - } - - $this->assertCount(2, $uniquenessViolations); - } - - public function testEmailUniquenessValidationInEditMode(): void - { - $bookingDto = $this->createBookingDtoWithParticipants([ - $this->createAdultParticipant('duplicate@example.com'), - $this->createAdultParticipant('duplicate@example.com'), - ]); - - $violations = $this->validator->validate($bookingDto, null, ['booking_edit']); - - // Should have 2 violations in edit mode as well - $this->assertCount(2, $violations); - } - - public function testEmailUniquenessValidationMixedScenario(): void - { - $bookingDto = $this->createBookingDtoWithParticipants([ - $this->createAdultParticipant('adult1@example.com'), // Unique - OK - $this->createAdultParticipant('duplicate@example.com'), // Duplicate - ERROR - $this->createChildParticipant('duplicate@example.com'), // Child with duplicate - OK - $this->createAdultParticipant('duplicate@example.com'), // Duplicate - ERROR - $this->createChildParticipant('kids@example.com'), // Unique child - OK - $this->createAdultParticipant('adult2@example.com'), // Unique - OK - ]); - - $violations = $this->validator->validate($bookingDto, null, ['booking_create_step_2']); - - // Should have 2 violations (participants at index 1 and 3) - $emailViolations = []; - foreach ($violations as $violation) { - if (str_contains($violation->getPropertyPath(), '.email')) { - $emailViolations[] = $violation; - } - } - - $this->assertCount(2, $emailViolations); - - // Check that violations are at the correct paths - $violationPaths = []; - foreach ($emailViolations as $violation) { - $violationPaths[] = $violation->getPropertyPath(); - } - - $this->assertContains('participants[1].email', $violationPaths); - $this->assertContains('participants[3].email', $violationPaths); - } - - private function createBookingDtoWithParticipants(array $participants): BookingDto + public function testBookingDtoCanBeInstantiated(): void { $travel = new Travel(); $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); $travel->dateTo = new \DateTimeImmutable('2025-06-08'); $bookingDto = new BookingDto($travel, 1); - $bookingDto->participants = $participants; - return $bookingDto; - } - - private function createAdultParticipant(?string $email): ParticipantDto - { - return $this->createValidParticipant( - new \DateTimeImmutable('1990-01-01'), // Adult (over 18) - $email - ); - } - - private function createChildParticipant(?string $email): ParticipantDto - { - return $this->createValidParticipant( - new \DateTimeImmutable('2015-01-01'), // Child (under 16) - $email - ); - } - - private function createValidParticipant(\DateTimeImmutable $dateOfBirth, ?string $email): ParticipantDto - { - $participant = new ParticipantDto(); - $participant->firstName = 'John'; - $participant->lastName = 'Doe'; - $participant->dateOfBirth = $dateOfBirth; - $participant->email = $email ?? 'valid@example.com'; - - // Set required fields for booking_create_step_2 validation group - $participant->assignedRoomId = 1; - $participant->skiPass = $this->createMockService(); - $participant->transportationOutbound = $this->createMockService(); - $participant->transportationInbound = $this->createMockService(); - - return $participant; - } - - private function createMockService(): \App\BusProNet\Model\Service - { - $service = new \App\BusProNet\Model\Service(); - $service->id = 1; - $service->label = 'Test Service'; - $service->subType = 'TEST'; - $service->price = 0.0; - - return $service; + $this->assertInstanceOf(BookingDto::class, $bookingDto); + $this->assertSame($travel, $bookingDto->travel); + $this->assertSame(BookingDto::MODE_CREATE, $bookingDto->getMode()); } } diff --git a/tests/Form/Model/ParticipantEditDtoTest.php b/tests/Form/Model/ParticipantEditDtoTest.php new file mode 100644 index 0000000..6282456 --- /dev/null +++ b/tests/Form/Model/ParticipantEditDtoTest.php @@ -0,0 +1,407 @@ +validator = Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->getValidator(); + } + + public function testAdultWithUniqueEmailPassesValidation(): void + { + $bookingDto = $this->createBookingDtoWithParticipants([ + $this->createValidAdultParticipant('unique1@example.com'), + $this->createValidAdultParticipant('unique2@example.com'), + ]); + + $wrapper = new ParticipantEditDto( + participant: $bookingDto->participants[0], + bookingContext: $bookingDto, + ); + + $violations = $this->validator->validate($wrapper, null, ['booking_create']); + + $this->assertCount(0, $violations); + } + + public function testAdultWithDuplicateEmailFailsValidation(): void + { + $bookingDto = $this->createBookingDtoWithParticipants([ + $this->createAdultParticipant('duplicate@example.com'), + $this->createAdultParticipant('duplicate@example.com'), + ]); + + $wrapper = new ParticipantEditDto( + participant: $bookingDto->participants[0], + bookingContext: $bookingDto, + ); + + $violations = $this->validator->validate($wrapper, null, ['booking_create']); + + $this->assertCount(1, $violations); + $this->assertSame('participant.email', $violations->get(0)->getPropertyPath()); + $this->assertSame( + 'Diese E-Mail Adresse wird bereits von einem anderen Teilnehmer verwendet', + $violations->get(0)->getMessage() + ); + } + + public function testChildWithDuplicateEmailPassesValidation(): void + { + $bookingDto = $this->createBookingDtoWithParticipants([ + $this->createAdultParticipant('shared@example.com'), + $this->createChildParticipant('shared@example.com'), + ]); + + // Validate the child participant (index 1) + $wrapper = new ParticipantEditDto( + participant: $bookingDto->participants[1], + bookingContext: $bookingDto, + ); + + $violations = $this->validator->validate($wrapper, null, ['booking_create']); + + $this->assertCount(0, $violations); + } + + public function testEmptyEmailPassesValidation(): void + { + $bookingDto = $this->createBookingDtoWithParticipants([ + $this->createAdultParticipant(''), + $this->createAdultParticipant(''), + ]); + + $wrapper = new ParticipantEditDto( + participant: $bookingDto->participants[0], + bookingContext: $bookingDto, + ); + + // Should not trigger uniqueness validation (handled by @Email and @NotBlank constraints) + $violations = $this->validator->validate($wrapper, null, ['booking_create']); + + // May have other violations, but not uniqueness + $hasUniquenessViolation = false; + foreach ($violations as $violation) { + if (str_contains($violation->getMessage(), 'wird bereits von einem anderen Teilnehmer verwendet')) { + $hasUniquenessViolation = true; + break; + } + } + + $this->assertFalse($hasUniquenessViolation, 'Should not have uniqueness violation for empty emails'); + } + + public function testNullEmailPassesValidation(): void + { + $participant1 = $this->createAdultParticipant('temp@example.com'); + $participant1->email = null; + + $participant2 = $this->createAdultParticipant('temp@example.com'); + $participant2->email = null; + + $bookingDto = $this->createBookingDtoWithParticipants([$participant1, $participant2]); + + $wrapper = new ParticipantEditDto( + participant: $bookingDto->participants[0], + bookingContext: $bookingDto, + ); + + // Should not trigger uniqueness validation (handled by @Email and @NotBlank constraints) + $violations = $this->validator->validate($wrapper, null, ['booking_create']); + + // May have other violations, but not uniqueness + $hasUniquenessViolation = false; + foreach ($violations as $violation) { + if (str_contains($violation->getMessage(), 'wird bereits von einem anderen Teilnehmer verwendet')) { + $hasUniquenessViolation = true; + break; + } + } + + $this->assertFalse($hasUniquenessViolation, 'Should not have uniqueness violation for null emails'); + } + + public function testCaseInsensitiveEmailComparison(): void + { + $bookingDto = $this->createBookingDtoWithParticipants([ + $this->createAdultParticipant('TEST@Example.COM'), + $this->createAdultParticipant('test@example.com'), + ]); + + $wrapper = new ParticipantEditDto( + participant: $bookingDto->participants[0], + bookingContext: $bookingDto, + ); + + $violations = $this->validator->validate($wrapper, null, ['booking_create']); + + // Should have uniqueness violation (case-insensitive comparison) + $uniquenessViolations = array_filter( + iterator_to_array($violations), + fn ($v) => str_contains($v->getMessage(), 'wird bereits von einem anderen Teilnehmer verwendet') + ); + + $this->assertCount(1, $uniquenessViolations); + } + + public function testWhitespaceNormalizationInEmailComparison(): void + { + $participant1 = $this->createAdultParticipant('test@example.com'); + $participant1->email = ' test@example.com '; + + $participant2 = $this->createAdultParticipant('test@example.com'); + + $bookingDto = $this->createBookingDtoWithParticipants([$participant1, $participant2]); + + $wrapper = new ParticipantEditDto( + participant: $bookingDto->participants[0], + bookingContext: $bookingDto, + ); + + $violations = $this->validator->validate($wrapper, null, ['booking_create']); + + // Should have uniqueness violation (whitespace trimmed during comparison) + $uniquenessViolations = array_filter( + iterator_to_array($violations), + fn ($v) => str_contains($v->getMessage(), 'wird bereits von einem anderen Teilnehmer verwendet') + ); + + $this->assertCount(1, $uniquenessViolations); + } + + public function testMultipleDuplicatesDetectedCorrectly(): void + { + $bookingDto = $this->createBookingDtoWithParticipants([ + $this->createAdultParticipant('same@example.com'), + $this->createAdultParticipant('same@example.com'), + $this->createAdultParticipant('same@example.com'), + ]); + + // Validate first participant - should fail + $wrapper1 = new ParticipantEditDto( + participant: $bookingDto->participants[0], + bookingContext: $bookingDto, + ); + + $violations1 = $this->validator->validate($wrapper1, null, ['booking_create']); + $this->assertCount(1, $violations1); + + // Validate second participant - should fail + $wrapper2 = new ParticipantEditDto( + participant: $bookingDto->participants[1], + bookingContext: $bookingDto, + ); + + $violations2 = $this->validator->validate($wrapper2, null, ['booking_create']); + $this->assertCount(1, $violations2); + + // Validate third participant - should fail + $wrapper3 = new ParticipantEditDto( + participant: $bookingDto->participants[2], + bookingContext: $bookingDto, + ); + + $violations3 = $this->validator->validate($wrapper3, null, ['booking_create']); + $this->assertCount(1, $violations3); + } + + public function testSelfComparisonSkipped(): void + { + $bookingDto = $this->createBookingDtoWithParticipants([ + $this->createAdultParticipant('only@example.com'), + ]); + + // Participant should not validate against itself + $wrapper = new ParticipantEditDto( + participant: $bookingDto->participants[0], + bookingContext: $bookingDto, + ); + + $violations = $this->validator->validate($wrapper, null, ['booking_create']); + + $this->assertCount(0, $violations); + } + + public function testValidationInEditMode(): void + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); + $travel->dateTo = new \DateTimeImmutable('2025-06-08'); + + $bookingDto = new BookingDto($travel, 1); // hotelId must be int + + $participant1 = $this->createAdultParticipant('duplicate@example.com'); + $participant1->index = 0; + + $participant2 = $this->createAdultParticipant('duplicate@example.com'); + $participant2->index = 1; + + $bookingDto->participants = [$participant1, $participant2]; + + $wrapper = new ParticipantEditDto( + participant: $bookingDto->participants[0], + bookingContext: $bookingDto, + ); + + $violations = $this->validator->validate($wrapper, null, ['booking_edit']); + + // Should have uniqueness violation in edit mode as well + $this->assertCount(1, $violations); + } + + public function testMixedScenario(): void + { + $bookingDto = $this->createBookingDtoWithParticipants([ + $this->createAdultParticipant('adult1@example.com'), // Unique - OK + $this->createAdultParticipant('duplicate@example.com'), // Duplicate - ERROR + $this->createChildParticipant('duplicate@example.com'), // Child with duplicate - OK + $this->createAdultParticipant('duplicate@example.com'), // Duplicate - ERROR + $this->createChildParticipant('kids@example.com'), // Unique child - OK + $this->createAdultParticipant('adult2@example.com'), // Unique - OK + ]); + + // Validate participant at index 0 - should pass (unique) + $wrapper0 = new ParticipantEditDto($bookingDto->participants[0], $bookingDto); + $violations0 = $this->validator->validate($wrapper0, null, ['booking_create']); + $this->assertCount(0, $violations0); + + // Validate participant at index 1 - should fail (duplicate) + $wrapper1 = new ParticipantEditDto($bookingDto->participants[1], $bookingDto); + $violations1 = $this->validator->validate($wrapper1, null, ['booking_create']); + $this->assertCount(1, $violations1); + + // Validate participant at index 2 - should pass (child exempt) + $wrapper2 = new ParticipantEditDto($bookingDto->participants[2], $bookingDto); + $violations2 = $this->validator->validate($wrapper2, null, ['booking_create']); + $this->assertCount(0, $violations2); + + // Validate participant at index 3 - should fail (duplicate) + $wrapper3 = new ParticipantEditDto($bookingDto->participants[3], $bookingDto); + $violations3 = $this->validator->validate($wrapper3, null, ['booking_create']); + $this->assertCount(1, $violations3); + + // Validate participant at index 4 - should pass (unique child) + $wrapper4 = new ParticipantEditDto($bookingDto->participants[4], $bookingDto); + $violations4 = $this->validator->validate($wrapper4, null, ['booking_create']); + $this->assertCount(0, $violations4); + + // Validate participant at index 5 - should pass (unique) + $wrapper5 = new ParticipantEditDto($bookingDto->participants[5], $bookingDto); + $violations5 = $this->validator->validate($wrapper5, null, ['booking_create']); + $this->assertCount(0, $violations5); + } + + private function createBookingDtoWithParticipants(array $participants): BookingDto + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); + $travel->dateTo = new \DateTimeImmutable('2025-06-08'); + + $bookingDto = new BookingDto($travel, 1); // hotelId must be int + + // Set index for each participant + foreach ($participants as $index => $participant) { + $participant->index = $index; + + // First participant (applicant) needs address and mobile + if (0 === $index) { + $participant->mobile = '+49 123 456789'; + + if (null === $participant->address) { + $participant->address = new \App\Form\Model\AddressDto(); + } + $participant->address->street = 'Test Street 1'; + $participant->address->postCode = '12345'; + $participant->address->city = 'Test City'; + $participant->address->country = 'DE'; + } + } + + $bookingDto->participants = $participants; + + return $bookingDto; + } + + private function createAdultParticipant(string $email): ParticipantDto + { + $participant = $this->createValidParticipant( + new \DateTimeImmutable('1990-01-01'), // Adult (over 18) + $email + ); + + // Add required fields for full validation + $participant->assignedRoomId = 1; + $participant->skiPass = $this->createMockService(); + $participant->transportationOutbound = $this->createMockService(); + $participant->transportationInbound = $this->createMockService(); + + return $participant; + } + + private function createValidAdultParticipant(string $email): ParticipantDto + { + // Same as createAdultParticipant - all adults need full validation + return $this->createAdultParticipant($email); + } + + private function createChildParticipant(string $email): ParticipantDto + { + $participant = $this->createValidParticipant( + new \DateTimeImmutable('2015-01-01'), // Child (under 16) + $email + ); + + // Children also need transportation (required for all participants) + $participant->assignedRoomId = 1; + $participant->skiPass = $this->createMockService(); + $participant->transportationOutbound = $this->createMockService(); + $participant->transportationInbound = $this->createMockService(); + + return $participant; + } + + private function createValidParticipant(\DateTimeImmutable $dateOfBirth, string $email): ParticipantDto + { + $participant = new ParticipantDto(); + $participant->firstName = 'John'; + $participant->lastName = 'Doe'; + $participant->dateOfBirth = $dateOfBirth; + $participant->email = $email; + + return $participant; + } + + private function createMockService(): \App\BusProNet\Model\Service + { + $service = new \App\BusProNet\Model\Service(); + $service->id = 1; + $service->label = 'Test Service'; + $service->subType = 'TEST'; + $service->price = 0.0; + + return $service; + } +} diff --git a/tests/Form/Service/ParticipantEmailFieldHandlerTest.php b/tests/Form/Service/ParticipantEmailFieldHandlerTest.php deleted file mode 100644 index bd03f07..0000000 --- a/tests/Form/Service/ParticipantEmailFieldHandlerTest.php +++ /dev/null @@ -1,282 +0,0 @@ -handler = new ParticipantEmailFieldHandler(); - } - - public function testGetFieldName(): void - { - $this->assertSame('email', $this->handler->getFieldName()); - } - - public function testGetDependencies(): void - { - $this->assertSame([], $this->handler->getDependencies()); - } - - public function testShouldProcessReturnsTrueWhenEmailFieldExists(): void - { - $this->assertTrue($this->handler->shouldProcess(['email' => 'test@example.com'], BookingDto::MODE_CREATE, 0)); - $this->assertTrue($this->handler->shouldProcess(['email' => null], BookingDto::MODE_EDIT, 5)); - } - - public function testShouldProcessReturnsFalseWhenEmailFieldMissing(): void - { - $this->assertFalse($this->handler->shouldProcess([], BookingDto::MODE_CREATE, 0)); - $this->assertFalse($this->handler->shouldProcess(['firstName' => 'John'], BookingDto::MODE_EDIT, 5)); - } - - public function testProcessFieldAdultsWithUniqueEmailsNoNotification(): void - { - $participant1 = $this->createAdultParticipant('adult1@example.com'); - $participant2 = $this->createAdultParticipant('adult2@example.com'); - - $travel = new Travel(); - $bookingDto = new BookingDto($travel, 1); - $bookingDto->participants = [$participant1, $participant2]; - - $submittedData = ['email' => 'adult1@example.com']; - - $this->handler->processField($submittedData, $bookingDto, 0); - - $this->assertEmpty($participant1->notifications); - } - - public function testProcessFieldAdultsWithDuplicateEmailsAddsWarningNotification(): void - { - $participant1 = $this->createAdultParticipant('duplicate@example.com'); - $participant2 = $this->createAdultParticipant('duplicate@example.com'); - - $travel = new Travel(); - $bookingDto = new BookingDto($travel, 1); - $bookingDto->participants = [$participant1, $participant2]; - - $submittedData = ['email' => 'duplicate@example.com']; - - $this->handler->processField($submittedData, $bookingDto, 0); - - $this->assertCount(1, $participant1->notifications); - $this->assertSame('warning', $participant1->notifications[0]['type']); - $this->assertSame( - 'Diese E-Mail Adresse wird bereits von einem anderen erwachsenen Teilnehmer verwendet', - $participant1->notifications[0]['message'] - ); - } - - public function testProcessFieldAdultWithSameEmailAsChildNoNotification(): void - { - $adult = $this->createAdultParticipant('shared@example.com'); - $child = $this->createChildParticipant('shared@example.com'); - - $travel = new Travel(); - $bookingDto = new BookingDto($travel, 1); - $bookingDto->participants = [$adult, $child]; - - $submittedData = ['email' => 'shared@example.com']; - - $this->handler->processField($submittedData, $bookingDto, 0); - - $this->assertEmpty($adult->notifications); - } - - public function testProcessFieldChildWithDuplicateEmailNoNotification(): void - { - $child1 = $this->createChildParticipant('child@example.com'); - $child2 = $this->createChildParticipant('child@example.com'); - - $travel = new Travel(); - $bookingDto = new BookingDto($travel, 1); - $bookingDto->participants = [$child1, $child2]; - - $submittedData = ['email' => 'child@example.com']; - - $this->handler->processField($submittedData, $bookingDto, 0); - - $this->assertEmpty($child1->notifications); - } - - public function testProcessFieldChildWithSameEmailAsAdultNoNotification(): void - { - $adult = $this->createAdultParticipant('parent@example.com'); - $child = $this->createChildParticipant('parent@example.com'); - - $travel = new Travel(); - $bookingDto = new BookingDto($travel, 1); - $bookingDto->participants = [$adult, $child]; - - $submittedData = ['email' => 'parent@example.com']; - - // Process child participant (index 1) - $this->handler->processField($submittedData, $bookingDto, 1); - - $this->assertEmpty($child->notifications); - } - - public function testProcessFieldThreeAdultsWithSameEmailAddsNotification(): void - { - $participant1 = $this->createAdultParticipant('same@example.com'); - $participant2 = $this->createAdultParticipant('same@example.com'); - $participant3 = $this->createAdultParticipant('same@example.com'); - - $travel = new Travel(); - $bookingDto = new BookingDto($travel, 1); - $bookingDto->participants = [$participant1, $participant2, $participant3]; - - $submittedData = ['email' => 'same@example.com']; - - $this->handler->processField($submittedData, $bookingDto, 0); - - $this->assertCount(1, $participant1->notifications); - $this->assertSame('warning', $participant1->notifications[0]['type']); - } - - public function testProcessFieldParticipantWithoutBirthDateTreatedAsAdult(): void - { - $participant1 = new ParticipantDto(); - $participant1->dateOfBirth = null; // Unknown age - treated as adult - $participant1->email = 'unknown@example.com'; - - $participant2 = $this->createAdultParticipant('unknown@example.com'); - - $travel = new Travel(); - $bookingDto = new BookingDto($travel, 1); - $bookingDto->participants = [$participant1, $participant2]; - - $submittedData = ['email' => 'unknown@example.com']; - - $this->handler->processField($submittedData, $bookingDto, 0); - - $this->assertCount(1, $participant1->notifications); - $this->assertSame('warning', $participant1->notifications[0]['type']); - } - - public function testProcessFieldNullEmailSkipsValidation(): void - { - $participant1 = $this->createAdultParticipant(null); - $participant2 = $this->createAdultParticipant(null); - - $travel = new Travel(); - $bookingDto = new BookingDto($travel, 1); - $bookingDto->participants = [$participant1, $participant2]; - - $submittedData = ['email' => null]; - - $this->handler->processField($submittedData, $bookingDto, 0); - - $this->assertEmpty($participant1->notifications); - } - - public function testProcessFieldEmptyEmailSkipsValidation(): void - { - $participant1 = $this->createAdultParticipant(''); - $participant2 = $this->createAdultParticipant(''); - - $travel = new Travel(); - $bookingDto = new BookingDto($travel, 1); - $bookingDto->participants = [$participant1, $participant2]; - - $submittedData = ['email' => '']; - - $this->handler->processField($submittedData, $bookingDto, 0); - - $this->assertEmpty($participant1->notifications); - } - - public function testProcessFieldWhitespaceOnlyEmailSkipsValidation(): void - { - $participant1 = $this->createAdultParticipant(' '); - $participant2 = $this->createAdultParticipant(' '); - - $travel = new Travel(); - $bookingDto = new BookingDto($travel, 1); - $bookingDto->participants = [$participant1, $participant2]; - - $submittedData = ['email' => ' ']; - - $this->handler->processField($submittedData, $bookingDto, 0); - - $this->assertEmpty($participant1->notifications); - } - - public function testProcessFieldCaseInsensitiveEmailComparison(): void - { - $participant1 = $this->createAdultParticipant('TEST@Example.COM'); - $participant2 = $this->createAdultParticipant('test@example.com'); - - $travel = new Travel(); - $bookingDto = new BookingDto($travel, 1); - $bookingDto->participants = [$participant1, $participant2]; - - $submittedData = ['email' => 'TEST@Example.COM']; - - $this->handler->processField($submittedData, $bookingDto, 0); - - $this->assertCount(1, $participant1->notifications); - $this->assertSame('warning', $participant1->notifications[0]['type']); - } - - public function testProcessFieldWithoutParticipant(): void - { - $travel = new Travel(); - $bookingDto = new BookingDto($travel, 1); - $submittedData = ['email' => 'test@example.com']; - - $this->handler->processField($submittedData, $bookingDto, 0); - - // Should handle gracefully when participant doesn't exist - $this->expectNotToPerformAssertions(); - } - - public function testProcessFieldWithDifferentParticipantIndex(): void - { - $participant1 = $this->createAdultParticipant('user1@example.com'); - $participant2 = $this->createAdultParticipant('duplicate@example.com'); - $participant3 = $this->createAdultParticipant('duplicate@example.com'); - - $travel = new Travel(); - $bookingDto = new BookingDto($travel, 1); - $bookingDto->participants = [$participant1, $participant2, $participant3]; - - $submittedData = ['email' => 'duplicate@example.com']; - - // Process for participant at index 2 - $this->handler->processField($submittedData, $bookingDto, 2); - - $this->assertEmpty($participant1->notifications); // Should not be affected - $this->assertEmpty($participant2->notifications); // Should not be affected - $this->assertCount(1, $participant3->notifications); // Should receive warning - } - - private function createAdultParticipant(?string $email): ParticipantDto - { - $participant = new ParticipantDto(); - $participant->dateOfBirth = new \DateTimeImmutable('1990-01-01'); // Adult (over 18) - $participant->email = $email; - - return $participant; - } - - private function createChildParticipant(?string $email): ParticipantDto - { - $participant = new ParticipantDto(); - $participant->dateOfBirth = new \DateTimeImmutable('2015-01-01'); // Child (under 16) - $participant->email = $email; - - return $participant; - } -}