From 9c716477aa1dd223ed6583a531b12a52d81bc3b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 22 Oct 2025 17:42:17 +0200 Subject: [PATCH] feat: unique email addresses of participants unless child, cleanup addresses #869axbn21 --- config/services.yaml | 1 + src/BusProNet/Model/Insurance.php | 2 +- .../Booking/Create/Step2Controller.php | 12 +- .../Booking/Edit/IndexController.php | 8 +- .../Traits/ParticipantValidationTrait.php | 73 +++- src/Exception/BookingNotPossibleException.php | 2 +- .../BookingSessionNotFoundException.php | 2 +- src/Exception/HotelNotFoundException.php | 2 +- src/Exception/HotelNotInTravelException.php | 2 +- src/Exception/TravelNotFoundException.php | 2 +- src/Form/BankAccountType.php | 2 +- src/Form/BookingParticipantType.php | 2 +- src/Form/Model/BookingDto.php | 40 +++ src/Form/Model/ParticipantDto.php | 22 ++ .../Abstract/AbstractFieldOptionsProvider.php | 6 +- .../Abstract/AbstractFieldStateProvider.php | 6 +- .../AbstractParticipantFieldHandler.php | 8 +- .../Service/Condition/AgeRangeCondition.php | 2 +- .../Service/Condition/ApplicantCondition.php | 2 +- .../Condition/BookingEligibilityCondition.php | 6 +- .../BulkInsuranceBookingCondition.php | 4 +- .../Service/Condition/CompositeCondition.php | 2 +- .../DateOfBirthProvidedCondition.php | 2 +- .../Service/Condition/FieldValueCondition.php | 2 +- .../Service/Condition/MutabilityCondition.php | 2 +- .../Condition/RentalSelectionCondition.php | 2 +- .../Condition/RoomSelectionCondition.php | 4 +- .../Condition/ServiceSubTypeCondition.php | 2 +- .../Condition/SkiPassSelectionCondition.php | 2 +- .../FieldOptionsProviderInterface.php | 6 +- .../Contract/FieldStateProviderInterface.php | 6 +- .../ParticipantFieldHandlerInterface.php | 4 +- src/Form/Service/EditFieldStateProvider.php | 1 - ...ticipantAdditionalServicesFieldHandler.php | 15 +- .../ParticipantBulkInsuranceFieldHandler.php | 2 +- .../ParticipantCoursesFieldHandler.php | 15 +- .../Service/ParticipantEmailFieldHandler.php | 138 ++++++++ .../ParticipantLicensePlateFieldHandler.php | 2 +- .../Service/ParticipantPickupFieldHandler.php | 2 +- .../ParticipantRemarksRoomFieldHandler.php | 2 +- ...ParticipantRentalInsuranceFieldHandler.php | 3 +- .../ParticipantSkiPassFieldHandler.php | 12 +- src/Form/Service/ServiceAgeEvaluator.php | 4 +- src/Service/BookingFingerprintService.php | 1 - src/Service/BookingService.php | 4 +- src/Service/InsuranceService.php | 19 +- src/Service/ParticipantEligibilityService.php | 2 +- .../Constraints/ApplicantAddressValidator.php | 2 +- templates/booking/_participant_card.html.twig | 105 +++--- templates/booking/_participant_form.html.twig | 9 +- templates/booking/create/step_2.html.twig | 5 +- templates/booking/edit/index.html.twig | 3 +- templates/layout.html.twig | 3 + .../BusProNet/XmlParser/AgencyParserTest.php | 2 +- tests/Form/Model/BookingDtoTest.php | 314 ++++++++++++++++++ .../ParticipantEmailFieldHandlerTest.php | 282 ++++++++++++++++ tests/Service/BookingServiceStatusTest.php | 2 +- 57 files changed, 1036 insertions(+), 153 deletions(-) create mode 100644 src/Form/Service/ParticipantEmailFieldHandler.php create mode 100644 tests/Form/Model/BookingDtoTest.php create mode 100644 tests/Form/Service/ParticipantEmailFieldHandlerTest.php diff --git a/config/services.yaml b/config/services.yaml index a5e2400..df39cd9 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -91,6 +91,7 @@ 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/BusProNet/Model/Insurance.php b/src/BusProNet/Model/Insurance.php index f1b1aa3..2ea57a7 100644 --- a/src/BusProNet/Model/Insurance.php +++ b/src/BusProNet/Model/Insurance.php @@ -17,7 +17,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; class Insurance { #[Groups(['api:single', 'api:list'])] - public string|null $id = null; + public ?string $id = null; #[Groups(['api:single', 'api:list'])] public ?string $code = null; diff --git a/src/Controller/Booking/Create/Step2Controller.php b/src/Controller/Booking/Create/Step2Controller.php index 02a89bb..ee5db7c 100644 --- a/src/Controller/Booking/Create/Step2Controller.php +++ b/src/Controller/Booking/Create/Step2Controller.php @@ -96,10 +96,13 @@ class Step2Controller extends AbstractController return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_3')); } - // Extract participant indices with validation errors - $participantErrors = []; + // Extract participant validation errors + $participantErrorIndices = []; + $participantErrorMessages = []; if (true === $form->isSubmitted() && false === $form->isValid()) { - $participantErrors = $this->extractParticipantErrorIndices($form); + $extractedErrors = $this->extractParticipantValidationErrors($form); + $participantErrorIndices = $extractedErrors['errorIndices']; + $participantErrorMessages = $extractedErrors['errorMessages']; } // Generate cards data @@ -117,7 +120,8 @@ class Step2Controller extends AbstractController 'cardsData' => $cardsData, 'summaryData' => $summaryData, 'pricingData' => $summary['pricing'], - 'participantErrors' => $participantErrors, + 'participantErrors' => $participantErrorIndices, + 'participantErrorMessages' => $participantErrorMessages, ]; // HTMX request: render blocks only diff --git a/src/Controller/Booking/Edit/IndexController.php b/src/Controller/Booking/Edit/IndexController.php index da4ab10..3749439 100644 --- a/src/Controller/Booking/Edit/IndexController.php +++ b/src/Controller/Booking/Edit/IndexController.php @@ -156,10 +156,13 @@ class IndexController extends AbstractController return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id])); } - // Extract participant indices with validation errors + // Extract participant validation errors $participantErrors = []; + $participantErrorMessages = []; if ($form->isSubmitted() && false === $form->isValid()) { - $participantErrors = $this->extractParticipantErrorIndices($form); + $extractedErrors = $this->extractParticipantValidationErrors($form); + $participantErrors = $extractedErrors['errorIndices']; + $participantErrorMessages = $extractedErrors['errorMessages']; } // Generate card data for all participants @@ -189,6 +192,7 @@ class IndexController extends AbstractController 'isDirty' => $this->fingerprintService->isDirty($bookingDto), 'hasValidationErrors' => count($participantErrors) > 0, 'participantErrors' => $participantErrors, + 'participantErrorMessages' => $participantErrorMessages, ]; // If HTMX request, render only blocks to avoid layout duplication diff --git a/src/Controller/Booking/Traits/ParticipantValidationTrait.php b/src/Controller/Booking/Traits/ParticipantValidationTrait.php index d3811a0..09a2d7f 100644 --- a/src/Controller/Booking/Traits/ParticipantValidationTrait.php +++ b/src/Controller/Booking/Traits/ParticipantValidationTrait.php @@ -13,16 +13,18 @@ namespace App\Controller\Booking\Traits; trait ParticipantValidationTrait { /** - * Extracts participant indices that have validation errors. + * Extracts participant validation errors from form. * - * Parses form errors to identify which participants have validation issues. - * Returns an array of participant indices (e.g., [0, 2, 5]). + * Returns two arrays: + * - errorIndices: Array of participant indices with errors (e.g., [0, 2, 5]) + * - errorMessages: Map of participant index to error messages (e.g., [0 => ['E-Mail: ...', 'Vorname: ...']]) * - * @return array Array of participant indices with errors + * @return array{errorIndices: array, errorMessages: array>} */ - private function extractParticipantErrorIndices($form): array + private function extractParticipantValidationErrors($form): array { $errorIndices = []; + $errorMessages = []; $errors = $form->getErrors(true); // Get all errors recursively foreach ($errors as $error) { @@ -31,13 +33,66 @@ trait ParticipantValidationTrait continue; } - // Property paths look like "participants[0].firstName" or "participants[1].email" - if (preg_match('/participants\[(\d+)]/', $propertyPath, $matches)) { + // Property paths from BookingDto validation look like "participants[0].email" + // Note: Using more flexible regex to capture field path after index + if (preg_match('/participants\[(\d+)\]\.?(.*)/', (string) $propertyPath, $matches)) { $index = (int) $matches[1]; - $errorIndices[$index] = true; // Use array key to avoid duplicates + $fieldPath = $matches[2] ?? ''; + $message = $error->getMessage(); + + // Mark this participant as having errors + $errorIndices[$index] = true; + + // Initialize error messages array for this participant if needed + if (false === isset($errorMessages[$index])) { + $errorMessages[$index] = []; + } + + // Add formatted error message + $errorMessages[$index][] = $this->formatErrorMessage($fieldPath, $message); } } - return array_keys($errorIndices); + return [ + 'errorIndices' => array_keys($errorIndices), + 'errorMessages' => $errorMessages, + ]; + } + + /** + * Formats an error message with field context. + * + * @param string $fieldPath The field path that has the error (may be empty or nested) + * @param string $message The error message + * + * @return string Formatted error message + */ + private function formatErrorMessage(string $fieldPath, string $message): string + { + // If no field path, return just the message (error is on participant level) + if ('' === trim($fieldPath)) { + return $message; + } + + // Extract the first part of the path for nested fields (e.g., "address.street" -> "address") + $fieldName = explode('.', $fieldPath)[0]; + + // Field name translations for better user understanding + $fieldLabels = [ + 'email' => 'E-Mail', + 'firstName' => 'Vorname', + 'lastName' => 'Nachname', + 'dateOfBirth' => 'Geburtsdatum', + 'assignedRoomId' => 'Zimmer', + 'skiPass' => 'Skipass', + 'transportationOutbound' => 'Anreise', + 'transportationInbound' => 'Rückreise', + 'mobile' => 'Mobilnummer', + 'address' => 'Adresse', + ]; + + $fieldLabel = $fieldLabels[$fieldName] ?? $fieldName; + + return sprintf('%s: %s', $fieldLabel, $message); } } diff --git a/src/Exception/BookingNotPossibleException.php b/src/Exception/BookingNotPossibleException.php index 14892ab..e4ae8f1 100644 --- a/src/Exception/BookingNotPossibleException.php +++ b/src/Exception/BookingNotPossibleException.php @@ -21,4 +21,4 @@ class BookingNotPossibleException extends HttpException parent::__construct(400, $message, $previous); } -} \ No newline at end of file +} diff --git a/src/Exception/BookingSessionNotFoundException.php b/src/Exception/BookingSessionNotFoundException.php index e0ab5f6..8ff694b 100644 --- a/src/Exception/BookingSessionNotFoundException.php +++ b/src/Exception/BookingSessionNotFoundException.php @@ -20,4 +20,4 @@ class BookingSessionNotFoundException extends HttpException parent::__construct(404, $message, $previous); } -} \ No newline at end of file +} diff --git a/src/Exception/HotelNotFoundException.php b/src/Exception/HotelNotFoundException.php index 6d32785..8530ae1 100644 --- a/src/Exception/HotelNotFoundException.php +++ b/src/Exception/HotelNotFoundException.php @@ -23,4 +23,4 @@ class HotelNotFoundException extends HttpException parent::__construct(404, $message, $previous); } -} \ No newline at end of file +} diff --git a/src/Exception/HotelNotInTravelException.php b/src/Exception/HotelNotInTravelException.php index b871d9c..d802073 100644 --- a/src/Exception/HotelNotInTravelException.php +++ b/src/Exception/HotelNotInTravelException.php @@ -24,4 +24,4 @@ class HotelNotInTravelException extends HttpException parent::__construct(404, $message, $previous); } -} \ No newline at end of file +} diff --git a/src/Exception/TravelNotFoundException.php b/src/Exception/TravelNotFoundException.php index acda097..ca45e12 100644 --- a/src/Exception/TravelNotFoundException.php +++ b/src/Exception/TravelNotFoundException.php @@ -23,4 +23,4 @@ class TravelNotFoundException extends HttpException parent::__construct(404, $message, $previous); } -} \ No newline at end of file +} diff --git a/src/Form/BankAccountType.php b/src/Form/BankAccountType.php index 8a60431..efdab67 100644 --- a/src/Form/BankAccountType.php +++ b/src/Form/BankAccountType.php @@ -36,7 +36,7 @@ class BankAccountType extends AbstractType ], ]) ->add('sepaMandateAccepted', CheckboxType::class, [ - 'label' => 'Hiermit ermächtige/n ich/wir Sie widerruflich, die von mir/uns zu entrichtende Zahlung bei Fälligkeit zu Lasten meines/unseres Girokontos durch Lastschrift einzuziehen.
' . + 'label' => 'Hiermit ermächtige/n ich/wir Sie widerruflich, die von mir/uns zu entrichtende Zahlung bei Fälligkeit zu Lasten meines/unseres Girokontos durch Lastschrift einzuziehen.
'. 'Bei kurzfristigen Buchungen (ab 2 Wochen vor Reisebeginn) ist Lastschrift NICHT mehr möglich.', 'required' => true, 'label_html' => true, diff --git a/src/Form/BookingParticipantType.php b/src/Form/BookingParticipantType.php index ebed6e3..e53a635 100644 --- a/src/Form/BookingParticipantType.php +++ b/src/Form/BookingParticipantType.php @@ -213,7 +213,7 @@ class BookingParticipantType extends AbstractType * @param FormInterface $form The form to modify * @param BookingDto $bookingDto The booking data for context * @param int $participantIndex The participant index - * @param array $submittedData Submitted form data for state calculation + * @param array $submittedData Submitted form data for state calculation */ private function removeExcludedFields(FormInterface $form, BookingDto $bookingDto, int $participantIndex, array &$submittedData = []): void { diff --git a/src/Form/Model/BookingDto.php b/src/Form/Model/BookingDto.php index 6c32365..ceba0d8 100644 --- a/src/Form/Model/BookingDto.php +++ b/src/Form/Model/BookingDto.php @@ -195,4 +195,44 @@ class BookingDto ->addViolation(); } } + + #[Assert\Callback(groups: ['booking_create_step_2', '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/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index 4eadb91..59c0b3a 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -224,4 +224,26 @@ class ParticipantDto 'message' => $message, ]; } + + /** + * Determines if the participant is a child based on age at current date. + * + * 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 + { + $age = $this->getAge(); + + // Treat unknown age as adult for safety (requires email uniqueness) + if (null === $age) { + return false; + } + + return $ageThreshold > $age; + } } diff --git a/src/Form/Service/Abstract/AbstractFieldOptionsProvider.php b/src/Form/Service/Abstract/AbstractFieldOptionsProvider.php index 718fe15..855fe29 100644 --- a/src/Form/Service/Abstract/AbstractFieldOptionsProvider.php +++ b/src/Form/Service/Abstract/AbstractFieldOptionsProvider.php @@ -36,10 +36,10 @@ abstract class AbstractFieldOptionsProvider implements FieldOptionsProviderInter * used when building the form field. These options are merged with any * static options defined in the form type. * - * @param string $fieldName The name of the field to configure + * @param string $fieldName The name of the field to configure * @param BookingDto $bookingDto The current booking data for context (create or edit) - * @param int $participantIndex The index of the participant being configured - * @param array $options Additional options to customize field behavior + * @param int $participantIndex The index of the participant being configured + * @param array $options Additional options to customize field behavior * * @return array Symfony form field options, or empty array if field not supported */ diff --git a/src/Form/Service/Abstract/AbstractFieldStateProvider.php b/src/Form/Service/Abstract/AbstractFieldStateProvider.php index 38b0549..5192a21 100644 --- a/src/Form/Service/Abstract/AbstractFieldStateProvider.php +++ b/src/Form/Service/Abstract/AbstractFieldStateProvider.php @@ -36,7 +36,7 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface * condition independently to allow early field exclusion. * * @param string $fieldName The name of the field to evaluate - * @param BookingDto $bookingDto The current booking data for context (create or edit) + * @param BookingDto $bookingDto The current booking data for context (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * @@ -64,7 +64,7 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface * excluded from the form entirely rather than hidden with CSS. * * @param string $fieldName The name of the field to evaluate - * @param BookingDto $bookingDto The current booking data for context (create or edit) + * @param BookingDto $bookingDto The current booking data for context (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * @@ -139,7 +139,7 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface /** * Calculates field states for all configured fields at once. * - * @param BookingDto $bookingDto The current booking data for context (create or edit) + * @param BookingDto $bookingDto The current booking data for context (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * diff --git a/src/Form/Service/Abstract/AbstractParticipantFieldHandler.php b/src/Form/Service/Abstract/AbstractParticipantFieldHandler.php index b0c5661..a566594 100644 --- a/src/Form/Service/Abstract/AbstractParticipantFieldHandler.php +++ b/src/Form/Service/Abstract/AbstractParticipantFieldHandler.php @@ -57,7 +57,7 @@ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandle * participant doesn't exist, preventing array access errors. * * @param BookingDto $bookingDto The booking DTO containing participants (create or edit) - * @param int $participantIndex The index of the participant to retrieve + * @param int $participantIndex The index of the participant to retrieve * * @return object|null The participant object, or null if not found */ @@ -82,7 +82,7 @@ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandle */ protected function isEditMode(BookingDto $bookingDto): bool { - return $bookingDto->mode === BookingDto::MODE_EDIT; + return BookingDto::MODE_EDIT === $bookingDto->mode; } /** @@ -94,7 +94,7 @@ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandle */ protected function isCreateMode(BookingDto $bookingDto): bool { - return $bookingDto->mode === BookingDto::MODE_CREATE; + return BookingDto::MODE_CREATE === $bookingDto->mode; } /** @@ -159,7 +159,7 @@ abstract class AbstractParticipantFieldHandler implements ParticipantFieldHandle * processing results. * * @param array $submittedData The submitted participant form data - * @param BookingDto $bookingDto The booking DTO (potentially modified by processing) + * @param BookingDto $bookingDto The booking DTO (potentially modified by processing) * @param int $participantIndex The participant index being processed * * @return array> Empty array (no state modifications by default) diff --git a/src/Form/Service/Condition/AgeRangeCondition.php b/src/Form/Service/Condition/AgeRangeCondition.php index 669b902..d2a3d8a 100644 --- a/src/Form/Service/Condition/AgeRangeCondition.php +++ b/src/Form/Service/Condition/AgeRangeCondition.php @@ -54,7 +54,7 @@ class AgeRangeCondition implements FieldConditionInterface * checks if it falls within the configured age range. Returns false if * the participant has no date of birth set. * - * @param BookingDto $bookingDto The current booking data (create or edit) + * @param BookingDto $bookingDto The current booking data (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data (unused for age conditions) * diff --git a/src/Form/Service/Condition/ApplicantCondition.php b/src/Form/Service/Condition/ApplicantCondition.php index 3a9a002..68b8c78 100644 --- a/src/Form/Service/Condition/ApplicantCondition.php +++ b/src/Form/Service/Condition/ApplicantCondition.php @@ -21,7 +21,7 @@ class ApplicantCondition implements FieldConditionInterface /** * Evaluates if the participant is the applicant. * - * @param BookingDto $bookingDto The current booking data (create or edit) + * @param BookingDto $bookingDto The current booking data (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data (unused) * diff --git a/src/Form/Service/Condition/BookingEligibilityCondition.php b/src/Form/Service/Condition/BookingEligibilityCondition.php index 1295b88..a2f9de0 100644 --- a/src/Form/Service/Condition/BookingEligibilityCondition.php +++ b/src/Form/Service/Condition/BookingEligibilityCondition.php @@ -26,7 +26,7 @@ use App\Service\ParticipantEligibilityService; class BookingEligibilityCondition implements FieldConditionInterface { public function __construct( - private readonly ParticipantEligibilityService $participantEligibilityService + private readonly ParticipantEligibilityService $participantEligibilityService, ) { } @@ -36,7 +36,7 @@ class BookingEligibilityCondition implements FieldConditionInterface * Returns true when the participant is INELIGIBLE (should hide service fields). * Returns false when the participant is eligible (show normal form fields). * - * @param BookingDto $bookingDto The current booking data + * @param BookingDto $bookingDto The current booking data * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * @@ -71,4 +71,4 @@ class BookingEligibilityCondition implements FieldConditionInterface { return 'Participant has no available skipasses for their age'; } -} \ No newline at end of file +} diff --git a/src/Form/Service/Condition/BulkInsuranceBookingCondition.php b/src/Form/Service/Condition/BulkInsuranceBookingCondition.php index dbcac06..8c0df43 100644 --- a/src/Form/Service/Condition/BulkInsuranceBookingCondition.php +++ b/src/Form/Service/Condition/BulkInsuranceBookingCondition.php @@ -29,7 +29,7 @@ class BulkInsuranceBookingCondition implements FieldConditionInterface * For dependent participants, returns true if the applicant has bulk insurance booking enabled, * regardless of whether an insurance is selected (applies to "no insurance" as well). * - * @param BookingDto $bookingDto The current booking data + * @param BookingDto $bookingDto The current booking data * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * @@ -93,4 +93,4 @@ class BulkInsuranceBookingCondition implements FieldConditionInterface return false; } -} \ No newline at end of file +} diff --git a/src/Form/Service/Condition/CompositeCondition.php b/src/Form/Service/Condition/CompositeCondition.php index f5baa20..4056c5f 100644 --- a/src/Form/Service/Condition/CompositeCondition.php +++ b/src/Form/Service/Condition/CompositeCondition.php @@ -64,7 +64,7 @@ class CompositeCondition implements FieldConditionInterface * evaluation for optimal performance. The evaluation stops as soon as the * final result can be determined. * - * @param BookingDto $bookingDto The current booking data (create or edit) + * @param BookingDto $bookingDto The current booking data (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * diff --git a/src/Form/Service/Condition/DateOfBirthProvidedCondition.php b/src/Form/Service/Condition/DateOfBirthProvidedCondition.php index 60b820e..eb9356b 100644 --- a/src/Form/Service/Condition/DateOfBirthProvidedCondition.php +++ b/src/Form/Service/Condition/DateOfBirthProvidedCondition.php @@ -27,7 +27,7 @@ class DateOfBirthProvidedCondition implements FieldConditionInterface * Checks if the participant exists and has a non-null dateOfBirth property. * This is a prerequisite for showing age-dependent form fields and services. * - * @param BookingDto $bookingDto The current booking data + * @param BookingDto $bookingDto The current booking data * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data (unused for this condition) * diff --git a/src/Form/Service/Condition/FieldValueCondition.php b/src/Form/Service/Condition/FieldValueCondition.php index e45648c..45a5e57 100644 --- a/src/Form/Service/Condition/FieldValueCondition.php +++ b/src/Form/Service/Condition/FieldValueCondition.php @@ -60,7 +60,7 @@ class FieldValueCondition implements FieldConditionInterface * against the expected value using the configured operator. Supports * both participant-level fields and booking-level fields. * - * @param BookingDto $bookingDto The current booking data (create or edit) + * @param BookingDto $bookingDto The current booking data (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * diff --git a/src/Form/Service/Condition/MutabilityCondition.php b/src/Form/Service/Condition/MutabilityCondition.php index 1a65d52..da3f7f1 100644 --- a/src/Form/Service/Condition/MutabilityCondition.php +++ b/src/Form/Service/Condition/MutabilityCondition.php @@ -21,7 +21,7 @@ class MutabilityCondition implements FieldConditionInterface /** * Evaluates if the participant's personal data is mutable. * - * @param BookingDto $bookingDto The current booking data (create or edit) + * @param BookingDto $bookingDto The current booking data (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data (unused) * diff --git a/src/Form/Service/Condition/RentalSelectionCondition.php b/src/Form/Service/Condition/RentalSelectionCondition.php index 976cbfd..708c317 100644 --- a/src/Form/Service/Condition/RentalSelectionCondition.php +++ b/src/Form/Service/Condition/RentalSelectionCondition.php @@ -28,7 +28,7 @@ class RentalSelectionCondition implements FieldConditionInterface * which would indicate rental services have been selected and body * dimensions should be required for proper equipment sizing. * - * @param BookingDto $bookingDto The current booking data (create or edit) + * @param BookingDto $bookingDto The current booking data (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * diff --git a/src/Form/Service/Condition/RoomSelectionCondition.php b/src/Form/Service/Condition/RoomSelectionCondition.php index d6f0638..cb21b78 100644 --- a/src/Form/Service/Condition/RoomSelectionCondition.php +++ b/src/Form/Service/Condition/RoomSelectionCondition.php @@ -35,7 +35,7 @@ class RoomSelectionCondition implements FieldConditionInterface * Checks both submitted form data and participant DTO data to determine * if the selected room matches any of the configured room codes. * - * @param BookingDto $bookingDto The current booking data (create or edit) + * @param BookingDto $bookingDto The current booking data (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * @@ -94,7 +94,7 @@ class RoomSelectionCondition implements FieldConditionInterface /** * Finds a room by ID in the available travel rooms. * - * @param int $roomId The room ID to find + * @param int $roomId The room ID to find * @param BookingDto $bookingDto The booking DTO containing travel data * * @return Room|null The found room or null if not found diff --git a/src/Form/Service/Condition/ServiceSubTypeCondition.php b/src/Form/Service/Condition/ServiceSubTypeCondition.php index baf6d55..ce23887 100644 --- a/src/Form/Service/Condition/ServiceSubTypeCondition.php +++ b/src/Form/Service/Condition/ServiceSubTypeCondition.php @@ -52,7 +52,7 @@ class ServiceSubTypeCondition implements FieldConditionInterface * property against the expected value(s). Handles both form data and * participant DTO data sources. * - * @param BookingDto $bookingDto The current booking data (create or edit) + * @param BookingDto $bookingDto The current booking data (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * diff --git a/src/Form/Service/Condition/SkiPassSelectionCondition.php b/src/Form/Service/Condition/SkiPassSelectionCondition.php index fbf9970..4bbaa34 100644 --- a/src/Form/Service/Condition/SkiPassSelectionCondition.php +++ b/src/Form/Service/Condition/SkiPassSelectionCondition.php @@ -28,7 +28,7 @@ class SkiPassSelectionCondition implements FieldConditionInterface * which would indicate a skipass has been selected and rental services * should be made available with duration filtering applied. * - * @param BookingDto $bookingDto The current booking data (create or edit) + * @param BookingDto $bookingDto The current booking data (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * diff --git a/src/Form/Service/Contract/FieldOptionsProviderInterface.php b/src/Form/Service/Contract/FieldOptionsProviderInterface.php index 5ba78c9..64924bf 100644 --- a/src/Form/Service/Contract/FieldOptionsProviderInterface.php +++ b/src/Form/Service/Contract/FieldOptionsProviderInterface.php @@ -41,10 +41,10 @@ interface FieldOptionsProviderInterface * used when building the form field. These options are merged with any * static options defined in the form type. * - * @param string $fieldName The name of the field to configure + * @param string $fieldName The name of the field to configure * @param BookingDto $bookingDto The current booking data for context (create or edit) - * @param int $participantIndex The index of the participant being configured - * @param array $options Additional options to customize field behavior + * @param int $participantIndex The index of the participant being configured + * @param array $options Additional options to customize field behavior * * @return array Symfony form field options, or empty array if field not supported */ diff --git a/src/Form/Service/Contract/FieldStateProviderInterface.php b/src/Form/Service/Contract/FieldStateProviderInterface.php index b005dc8..937254a 100644 --- a/src/Form/Service/Contract/FieldStateProviderInterface.php +++ b/src/Form/Service/Contract/FieldStateProviderInterface.php @@ -38,7 +38,7 @@ interface FieldStateProviderInterface * condition independently to allow early field exclusion during form building. * * @param string $fieldName The name of the field to evaluate - * @param BookingDto $bookingDto The current booking data for context (create or edit) + * @param BookingDto $bookingDto The current booking data for context (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * @@ -65,7 +65,7 @@ interface FieldStateProviderInterface * - 'attr' => ['class' => 'conditional-field'] - Add CSS classes * * @param string $fieldName The name of the field to evaluate - * @param BookingDto $bookingDto The current booking data for context (create or edit) + * @param BookingDto $bookingDto The current booking data for context (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data (may include partial submissions) * @@ -110,7 +110,7 @@ interface FieldStateProviderInterface * when multiple field states need to be determined simultaneously. It's * particularly useful during form building and bulk state updates. * - * @param BookingDto $bookingDto The current booking data for context (create or edit) + * @param BookingDto $bookingDto The current booking data for context (create or edit) * @param int $participantIndex The index of the participant being evaluated * @param array $formData Current form data for condition evaluation * diff --git a/src/Form/Service/Contract/ParticipantFieldHandlerInterface.php b/src/Form/Service/Contract/ParticipantFieldHandlerInterface.php index 89403ac..d66a24b 100644 --- a/src/Form/Service/Contract/ParticipantFieldHandlerInterface.php +++ b/src/Form/Service/Contract/ParticipantFieldHandlerInterface.php @@ -32,7 +32,7 @@ interface ParticipantFieldHandlerInterface * Processes the participant field data from submitted form data and updates the DTO. * * @param array $submittedData The submitted participant form data - * @param BookingDto $bookingDto The booking DTO to update (create or edit) + * @param BookingDto $bookingDto The booking DTO to update (create or edit) * @param int $participantIndex The participant index being processed */ public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void; @@ -54,7 +54,7 @@ interface ParticipantFieldHandlerInterface * to enable/disable/hide fields based on the handler's processing results. * * @param array $submittedData The submitted participant form data - * @param BookingDto $bookingDto The booking DTO (potentially modified by processing) + * @param BookingDto $bookingDto The booking DTO (potentially modified by processing) * @param int $participantIndex The participant index being processed * * @return array> Field state modifications indexed by field name diff --git a/src/Form/Service/EditFieldStateProvider.php b/src/Form/Service/EditFieldStateProvider.php index 9f6f665..ef6f353 100644 --- a/src/Form/Service/EditFieldStateProvider.php +++ b/src/Form/Service/EditFieldStateProvider.php @@ -11,7 +11,6 @@ use App\Form\Service\Condition\ApplicantCondition; use App\Form\Service\Condition\CompositeCondition; use App\Form\Service\Condition\DateOfBirthProvidedCondition; use App\Form\Service\Condition\FieldValueCondition; -use App\Form\Service\Condition\InsuranceMutabilityCondition; use App\Form\Service\Condition\MutabilityCondition; use App\Form\Service\Condition\PickupsMutabilityCondition; use App\Form\Service\Condition\RentalSelectionCondition; diff --git a/src/Form/Service/ParticipantAdditionalServicesFieldHandler.php b/src/Form/Service/ParticipantAdditionalServicesFieldHandler.php index 93f709a..3d578cf 100644 --- a/src/Form/Service/ParticipantAdditionalServicesFieldHandler.php +++ b/src/Form/Service/ParticipantAdditionalServicesFieldHandler.php @@ -85,8 +85,7 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField * 5. Updates participant with filtered valid selections * * @param array $submittedData The submitted participant form data - * @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT) - * @param BookingDto $bookingDto The booking DTO to update (create or edit) + * @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 @@ -122,10 +121,10 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField * and the participant's age constraints. Services that are no longer available * or appropriate for the participant's age are filtered out. * - * @param array $selectedServices List of currently selected services - * @param array $availableServices List of all available additional services + * @param array $selectedServices List of currently selected services + * @param array $availableServices List of all available additional services * @param BookingDto $bookingDto The booking DTO for context - * @param int $participantIndex The participant index for age evaluation + * @param int $participantIndex The participant index for age evaluation * * @return array Filtered array of valid service selections */ @@ -156,10 +155,10 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField * This method checks if a selected service exists in the available services * and meets the age constraints for the current participant. * - * @param mixed $selectedService The selected service to validate - * @param array $availableServices Array of available services + * @param mixed $selectedService The selected service to validate + * @param array $availableServices Array of available services * @param BookingDto $bookingDto The booking DTO for context - * @param int $participantIndex The participant index for age evaluation + * @param int $participantIndex The participant index for age evaluation * * @return bool True if the service is valid for the participant, false otherwise */ diff --git a/src/Form/Service/ParticipantBulkInsuranceFieldHandler.php b/src/Form/Service/ParticipantBulkInsuranceFieldHandler.php index 8f48fec..c241fcc 100644 --- a/src/Form/Service/ParticipantBulkInsuranceFieldHandler.php +++ b/src/Form/Service/ParticipantBulkInsuranceFieldHandler.php @@ -56,7 +56,7 @@ class ParticipantBulkInsuranceFieldHandler extends AbstractParticipantFieldHandl * to dependent participants is handled by BookingDataProcessor during API submission. * * @param array $submittedData The submitted participant form data - * @param BookingDto $bookingDto The booking DTO to update + * @param BookingDto $bookingDto The booking DTO to update * @param int $participantIndex The index of the participant (must be 0) */ public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void diff --git a/src/Form/Service/ParticipantCoursesFieldHandler.php b/src/Form/Service/ParticipantCoursesFieldHandler.php index e900934..efd87c1 100644 --- a/src/Form/Service/ParticipantCoursesFieldHandler.php +++ b/src/Form/Service/ParticipantCoursesFieldHandler.php @@ -78,8 +78,7 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler * no longer appropriate for the participant's age are automatically removed. * * @param array $submittedData The submitted participant form data - * @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT) - * @param BookingDto $bookingDto The booking DTO to update (create or edit) + * @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 @@ -111,10 +110,10 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler /** * Filters course selections to keep only those valid for the participant's age. * - * @param array $selectedServices List of currently selected courses - * @param array $availableServices List of all available courses + * @param array $selectedServices List of currently selected courses + * @param array $availableServices List of all available courses * @param BookingDto $bookingDto The booking DTO for context - * @param int $participantIndex The participant index for age evaluation + * @param int $participantIndex The participant index for age evaluation * * @return array Filtered array of valid course selections */ @@ -142,10 +141,10 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler /** * Validates if a selected course is still valid for the participant. * - * @param mixed $selectedService The selected course to validate - * @param array $availableServices Array of available courses + * @param mixed $selectedService The selected course to validate + * @param array $availableServices Array of available courses * @param BookingDto $bookingDto The booking DTO for context - * @param int $participantIndex The participant index for age evaluation + * @param int $participantIndex The participant index for age evaluation * * @return bool True if the course is valid for the participant, false otherwise */ diff --git a/src/Form/Service/ParticipantEmailFieldHandler.php b/src/Form/Service/ParticipantEmailFieldHandler.php new file mode 100644 index 0000000..542ee60 --- /dev/null +++ b/src/Form/Service/ParticipantEmailFieldHandler.php @@ -0,0 +1,138 @@ + $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/src/Form/Service/ParticipantLicensePlateFieldHandler.php b/src/Form/Service/ParticipantLicensePlateFieldHandler.php index 22f3423..b3cb4cd 100644 --- a/src/Form/Service/ParticipantLicensePlateFieldHandler.php +++ b/src/Form/Service/ParticipantLicensePlateFieldHandler.php @@ -70,7 +70,7 @@ class ParticipantLicensePlateFieldHandler extends AbstractParticipantFieldHandle * the license plate is automatically cleared to maintain data consistency. * * @param array $submittedData The submitted participant form data - * @param BookingDto $bookingDto The booking DTO to update (create or edit) + * @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 diff --git a/src/Form/Service/ParticipantPickupFieldHandler.php b/src/Form/Service/ParticipantPickupFieldHandler.php index e90bb19..b2973b7 100644 --- a/src/Form/Service/ParticipantPickupFieldHandler.php +++ b/src/Form/Service/ParticipantPickupFieldHandler.php @@ -90,4 +90,4 @@ class ParticipantPickupFieldHandler extends AbstractParticipantFieldHandler return null; } -} \ No newline at end of file +} diff --git a/src/Form/Service/ParticipantRemarksRoomFieldHandler.php b/src/Form/Service/ParticipantRemarksRoomFieldHandler.php index a45beda..4fe8464 100644 --- a/src/Form/Service/ParticipantRemarksRoomFieldHandler.php +++ b/src/Form/Service/ParticipantRemarksRoomFieldHandler.php @@ -41,7 +41,7 @@ class ParticipantRemarksRoomFieldHandler extends AbstractParticipantFieldHandler * the normalization of empty strings to null values. * * @param array $submittedData The submitted participant form data - * @param BookingDto $bookingDto The booking DTO to update (create or edit) + * @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 diff --git a/src/Form/Service/ParticipantRentalInsuranceFieldHandler.php b/src/Form/Service/ParticipantRentalInsuranceFieldHandler.php index c1438bf..13a1501 100644 --- a/src/Form/Service/ParticipantRentalInsuranceFieldHandler.php +++ b/src/Form/Service/ParticipantRentalInsuranceFieldHandler.php @@ -75,8 +75,7 @@ class ParticipantRentalInsuranceFieldHandler extends AbstractParticipantFieldHan * is no longer appropriate for the participant's age, it is automatically cleared. * * @param array $submittedData The submitted participant form data - * @param string $mode The booking mode (BookingDto::MODE_CREATE or MODE_EDIT) - * @param BookingDto $bookingDto The booking DTO to update (create or edit) + * @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 diff --git a/src/Form/Service/ParticipantSkiPassFieldHandler.php b/src/Form/Service/ParticipantSkiPassFieldHandler.php index 8ee764f..1b1ba6b 100644 --- a/src/Form/Service/ParticipantSkiPassFieldHandler.php +++ b/src/Form/Service/ParticipantSkiPassFieldHandler.php @@ -79,9 +79,9 @@ class ParticipantSkiPassFieldHandler extends AbstractParticipantFieldHandler * selection. If the skipass is no longer appropriate for the participant's * age or exceeds the travel date range, it is automatically cleared. * - * @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 + * @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 { @@ -116,10 +116,10 @@ class ParticipantSkiPassFieldHandler extends AbstractParticipantFieldHandler * This method checks both age constraints (via birth year ranges) and * date constraints (skipass dates must be within travel dates). * - * @param mixed $selectedService The selected skipass to validate - * @param array $availableServices Array of available skipasses + * @param mixed $selectedService The selected skipass to validate + * @param array $availableServices Array of available skipasses * @param BookingDto $bookingDto The booking DTO for context - * @param int $participantIndex The participant index for age evaluation + * @param int $participantIndex The participant index for age evaluation * * @return bool True if the skipass is valid for the participant, false otherwise */ diff --git a/src/Form/Service/ServiceAgeEvaluator.php b/src/Form/Service/ServiceAgeEvaluator.php index 2e48fc7..235e2a2 100644 --- a/src/Form/Service/ServiceAgeEvaluator.php +++ b/src/Form/Service/ServiceAgeEvaluator.php @@ -39,9 +39,9 @@ class ServiceAgeEvaluator * that services are appropriately filtered based on the participant's * age at the time of travel, not their current age. * - * @param Service $service The service to evaluate + * @param Service $service The service to evaluate * @param BookingDto $bookingDto The booking containing participant data - * @param int $participantIndex The index of the participant to evaluate + * @param int $participantIndex The index of the participant to evaluate * * @return bool True if the service is available for the participant */ diff --git a/src/Service/BookingFingerprintService.php b/src/Service/BookingFingerprintService.php index 64e67ba..a9a4e54 100644 --- a/src/Service/BookingFingerprintService.php +++ b/src/Service/BookingFingerprintService.php @@ -117,5 +117,4 @@ class BookingFingerprintService return $bookingDto->originalFingerprint !== $currentFingerprint; } - } diff --git a/src/Service/BookingService.php b/src/Service/BookingService.php index 01f54db..33a76bb 100644 --- a/src/Service/BookingService.php +++ b/src/Service/BookingService.php @@ -149,7 +149,7 @@ class BookingService * Persists the current booking state to the session for retrieval * across multiple HTTP requests during the booking flow. * - * @param Request $request The HTTP request with session + * @param Request $request The HTTP request with session * @param BookingDto $bookingCreateDto The booking DTO to persist */ public function saveBookingCreateDto(Request $request, BookingDto $bookingCreateDto): void @@ -416,7 +416,7 @@ class BookingService * Compares the current room selection state with a baseline snapshot * to detect changes that would require participant reassignment. * - * @param array $oldSnapshot The baseline room selection snapshot + * @param array $oldSnapshot The baseline room selection snapshot * @param BookingDto $newDto The current booking DTO * * @return bool True if room selections have changed, false otherwise diff --git a/src/Service/InsuranceService.php b/src/Service/InsuranceService.php index 3bbbe5d..8fdf79a 100644 --- a/src/Service/InsuranceService.php +++ b/src/Service/InsuranceService.php @@ -59,7 +59,7 @@ class InsuranceService array $insurances, ParticipantDto $participant, BookingDto $booking, - float $travelPrice + float $travelPrice, ): array { $travelStartDate = $booking->travel->dateFrom; $travelEndDate = $booking->travel->dateTo; @@ -108,7 +108,7 @@ class InsuranceService Insurance $currentInsurance, ParticipantDto $participant, BookingDto $booking, - float $travelPrice + float $travelPrice, ): ?Insurance { // Group insurances of the same type $sameTypeInsurances = $this->filterByType($availableInsurances, $currentInsurance); @@ -127,10 +127,10 @@ class InsuranceService * (subType + familyInsurance) to all participants, but selects the appropriate price tier * based on each participant's individual travel price. * - * @param array $availableInsurances All available insurances - * @param Insurance $selectedInsurance The insurance selected by the applicant - * @param BookingDto $booking The booking with all participants - * @param array $participantPrices Map of participant index to travel price (excluding insurance) + * @param array $availableInsurances All available insurances + * @param Insurance $selectedInsurance The insurance selected by the applicant + * @param BookingDto $booking The booking with all participants + * @param array $participantPrices Map of participant index to travel price (excluding insurance) * * @return array Array indexed by participant index with assigned insurances */ @@ -138,7 +138,7 @@ class InsuranceService array $availableInsurances, Insurance $selectedInsurance, BookingDto $booking, - array $participantPrices + array $participantPrices, ): array { $assignments = []; @@ -280,7 +280,7 @@ class InsuranceService private function checkAgeConstraints( Insurance $insurance, ParticipantDto $participant, - \DateTimeImmutable $travelStartDate + \DateTimeImmutable $travelStartDate, ): bool { $participantAge = $participant->getAge($travelStartDate); @@ -305,7 +305,7 @@ class InsuranceService private function checkTravelDateConstraints( Insurance $insurance, \DateTimeImmutable $travelStartDate, - \DateTimeImmutable $travelEndDate + \DateTimeImmutable $travelEndDate, ): bool { // Check travel start date if (null !== $insurance->travelDateFrom && $travelStartDate < $insurance->travelDateFrom) { @@ -368,5 +368,4 @@ class InsuranceService return true; } - } diff --git a/src/Service/ParticipantEligibilityService.php b/src/Service/ParticipantEligibilityService.php index a839096..310497b 100644 --- a/src/Service/ParticipantEligibilityService.php +++ b/src/Service/ParticipantEligibilityService.php @@ -32,7 +32,7 @@ class ParticipantEligibilityService * Results are cached per request to avoid redundant calculations. * * @param BookingDto $bookingDto The current booking data - * @param int $participantIndex The index of the participant being evaluated + * @param int $participantIndex The index of the participant being evaluated * * @return bool True if participant is eligible (has available skipasses) */ diff --git a/src/Validator/Constraints/ApplicantAddressValidator.php b/src/Validator/Constraints/ApplicantAddressValidator.php index eaa71ab..34c7956 100644 --- a/src/Validator/Constraints/ApplicantAddressValidator.php +++ b/src/Validator/Constraints/ApplicantAddressValidator.php @@ -62,4 +62,4 @@ class ApplicantAddressValidator extends ConstraintValidator ; } } -} \ No newline at end of file +} diff --git a/templates/booking/_participant_card.html.twig b/templates/booking/_participant_card.html.twig index fb50d5e..3118daa 100644 --- a/templates/booking/_participant_card.html.twig +++ b/templates/booking/_participant_card.html.twig @@ -1,60 +1,77 @@ {# Compact participant card with name, room, price, and edit button #} {% set isCanceled = isCanceled|default(false) %} {% set hasErrors = hasErrors|default(false) %} +{% set errorMessages = errorMessages|default([]) %} {% set mode = mode|default('create') %}
-
-
-

- {{ cardData.name }} -

- {% if isCanceled %} - - storniert - - {% elseif hasErrors %} - - - - - Unvollständig - +
+
+
+

+ {{ cardData.name }} +

+ {% if isCanceled %} + + storniert + + {% elseif hasErrors %} + + + + + Unvollständig + + {% endif %} +
+

{{ cardData.roomName }}

+ + {# Display specific error messages #} + {% if hasErrors and errorMessages|length > 0 %} +
+ {% for errorMessage in errorMessages %} +

+ + + + {{ errorMessage }} +

+ {% endfor %} +
{% endif %}
-

{{ cardData.roomName }}

-
-
- {{ cardData.price }} - {% if isCanceled %} - - {% else %} - {% if mode == 'edit' %} +
+ {{ cardData.price }} + {% if isCanceled %} {% else %} - + {% if mode == 'edit' %} + + {% else %} + + {% endif %} {% endif %} - {% endif %} +
diff --git a/templates/booking/_participant_form.html.twig b/templates/booking/_participant_form.html.twig index 325d933..8e2414e 100644 --- a/templates/booking/_participant_form.html.twig +++ b/templates/booking/_participant_form.html.twig @@ -60,7 +60,14 @@ {# Contact information #}
- {{ form_row(form.email) }} + {{ form_row(form.email, { + 'attr': { + 'hx-trigger': 'change', + 'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})), + 'hx-target': '#main-content', + 'hx-swap': 'innerHTML' + } + }) }} {{ form_row(form.mobile) }}
diff --git a/templates/booking/create/step_2.html.twig b/templates/booking/create/step_2.html.twig index 1e0db11..5511a77 100644 --- a/templates/booking/create/step_2.html.twig +++ b/templates/booking/create/step_2.html.twig @@ -2,7 +2,6 @@ {% block content %} {% include '_partials/_flashes.html.twig' %} -

Neue Buchung

@@ -37,11 +36,13 @@
{% for cardData in cardsData %} {% set hasErrors = loop.index0 in participantErrors|default([]) %} + {% set errorMessages = participantErrorMessages[loop.index0]|default([]) %} {% include 'booking/_participant_card.html.twig' with { 'cardData': cardData, 'index': loop.index0, 'mode': 'create', - 'hasErrors': hasErrors + 'hasErrors': hasErrors, + 'errorMessages': errorMessages } %} {% endfor %}
diff --git a/templates/booking/edit/index.html.twig b/templates/booking/edit/index.html.twig index 22eb0c1..c34f443 100644 --- a/templates/booking/edit/index.html.twig +++ b/templates/booking/edit/index.html.twig @@ -2,7 +2,6 @@ {% block content %} {% include '_partials/_flashes.html.twig' %} -
{# Header with reload button #}
@@ -62,12 +61,14 @@ {% for participant in bookingDto.participants %} {% set isCanceled = (bookingData.participantsStatus[loop.index0] ?? null) == 'S' %} {% set hasErrors = loop.index0 in participantErrors|default([]) %} + {% set errorMessages = participantErrorMessages[loop.index0]|default([]) %} {% include 'booking/_participant_card.html.twig' with { 'cardData': cardsData[loop.index0], 'index': loop.index0, 'mode': 'edit', 'isCanceled': isCanceled, 'hasErrors': hasErrors, + 'errorMessages': errorMessages, 'bookingId': bookingData.id } %} {% endfor %} diff --git a/templates/layout.html.twig b/templates/layout.html.twig index e7e8d4a..e4b874f 100644 --- a/templates/layout.html.twig +++ b/templates/layout.html.twig @@ -1,6 +1,9 @@ {% extends 'base.html.twig' %} {% block body %} + {# Global toast controller - persists across all HTMX swaps and page navigations #} +
+
diff --git a/tests/BusProNet/XmlParser/AgencyParserTest.php b/tests/BusProNet/XmlParser/AgencyParserTest.php index c9a4038..8353316 100644 --- a/tests/BusProNet/XmlParser/AgencyParserTest.php +++ b/tests/BusProNet/XmlParser/AgencyParserTest.php @@ -108,4 +108,4 @@ class AgencyParserTest extends TestCase $this->assertCount(0, $agencies); } -} \ No newline at end of file +} diff --git a/tests/Form/Model/BookingDtoTest.php b/tests/Form/Model/BookingDtoTest.php new file mode 100644 index 0000000..49c6b74 --- /dev/null +++ b/tests/Form/Model/BookingDtoTest.php @@ -0,0 +1,314 @@ +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 + { + $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; + } +} diff --git a/tests/Form/Service/ParticipantEmailFieldHandlerTest.php b/tests/Form/Service/ParticipantEmailFieldHandlerTest.php new file mode 100644 index 0000000..bd03f07 --- /dev/null +++ b/tests/Form/Service/ParticipantEmailFieldHandlerTest.php @@ -0,0 +1,282 @@ +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; + } +} diff --git a/tests/Service/BookingServiceStatusTest.php b/tests/Service/BookingServiceStatusTest.php index 218f80a..be36d60 100644 --- a/tests/Service/BookingServiceStatusTest.php +++ b/tests/Service/BookingServiceStatusTest.php @@ -161,4 +161,4 @@ class BookingServiceStatusTest extends TestCase return $request; } -} \ No newline at end of file +}