feat: unique email addresses of participants unless child, cleanup

addresses #869axbn21
This commit is contained in:
Björn Fromme
2025-10-22 17:42:17 +02:00
parent be2fffad23
commit 9c716477aa
57 changed files with 1036 additions and 153 deletions
+1
View File
@@ -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'
+1 -1
View File
@@ -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;
@@ -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
@@ -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
@@ -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<int> Array of participant indices with errors
* @return array{errorIndices: array<int>, errorMessages: array<int, array<string>>}
*/
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);
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ class BankAccountType extends AbstractType
],
])
->add('sepaMandateAccepted', CheckboxType::class, [
'label' => '<span class="text-gray-700 font-normal">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.</span><br>' .
'label' => '<span class="text-gray-700 font-normal">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.</span><br>'.
'<span class="text-red-600 font-semibold">Bei kurzfristigen Buchungen (ab 2 Wochen vor Reisebeginn) ist Lastschrift NICHT mehr möglich.</span>',
'required' => true,
'label_html' => true,
+1 -1
View File
@@ -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<string, mixed> $submittedData Submitted form data for state calculation
* @param array<string, mixed> $submittedData Submitted form data for state calculation
*/
private function removeExcludedFields(FormInterface $form, BookingDto $bookingDto, int $participantIndex, array &$submittedData = []): void
{
+40
View File
@@ -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();
}
}
}
}
}
+22
View File
@@ -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;
}
}
@@ -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<string, mixed> Symfony form field options, or empty array if field not supported
*/
@@ -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<string, mixed> $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<string, mixed> $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<string, mixed> $formData Current form data for condition evaluation
*
@@ -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<string, mixed> $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<string, array<string, mixed>> Empty array (no state modifications by default)
@@ -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<string, mixed> $formData Current form data (unused for age conditions)
*
@@ -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<string, mixed> $formData Current form data (unused)
*
@@ -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<string, mixed> $formData Current form data for condition evaluation
*
@@ -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<string, mixed> $formData Current form data for condition evaluation
*
@@ -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<string, mixed> $formData Current form data for condition evaluation
*
@@ -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<string, mixed> $formData Current form data (unused for this condition)
*
@@ -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<string, mixed> $formData Current form data for condition evaluation
*
@@ -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<string, mixed> $formData Current form data (unused)
*
@@ -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<string, mixed> $formData Current form data for condition evaluation
*
@@ -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<string, mixed> $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
@@ -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<string, mixed> $formData Current form data for condition evaluation
*
@@ -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<string, mixed> $formData Current form data for condition evaluation
*
@@ -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<string, mixed> Symfony form field options, or empty array if field not supported
*/
@@ -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<string, mixed> $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<string, mixed> $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<string, mixed> $formData Current form data for condition evaluation
*
@@ -32,7 +32,7 @@ interface ParticipantFieldHandlerInterface
* Processes the participant field data from submitted form data and updates the DTO.
*
* @param array<string, mixed> $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<string, mixed> $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<string, array<string, mixed>> Field state modifications indexed by field name
@@ -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;
@@ -85,8 +85,7 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField
* 5. Updates participant with filtered valid selections
*
* @param array<string, mixed> $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
*/
@@ -56,7 +56,7 @@ class ParticipantBulkInsuranceFieldHandler extends AbstractParticipantFieldHandl
* to dependent participants is handled by BookingDataProcessor during API submission.
*
* @param array<string, mixed> $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
@@ -78,8 +78,7 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler
* no longer appropriate for the participant's age are automatically removed.
*
* @param array<string, mixed> $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
*/
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
* Handles email uniqueness validation for booking participants.
*
* This handler provides real-time feedback during form refresh when an adult
* participant enters an email address that is already used by another adult
* participant in the same booking. Children (under 16) are exempt from this
* validation and can share email addresses with adults or other children.
*
* The handler adds warning notifications during the HTMX refresh cycle,
* providing immediate user feedback without blocking form submission.
* Hard validation is enforced via BookingDto::validateEmailUniqueness()
* when the user attempts to proceed to the next step.
*/
class ParticipantEmailFieldHandler extends AbstractParticipantFieldHandler
{
/**
* Returns the form field name this handler processes.
*
* @return string The field name 'email'
*/
public function getFieldName(): string
{
return 'email';
}
/**
* Returns the field dependencies for proper processing order.
*
* Email validation has no dependencies on other fields.
*
* @return string[] Empty array (no dependencies)
*/
public function getDependencies(): array
{
return [];
}
/**
* Determines if this handler should process the field based on submitted data.
*
* Email validation should always run when the email field is present in
* the submitted data, regardless of booking mode or participant index.
*
* @param array<string, mixed> $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<string, mixed> $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'
);
}
}
}
@@ -70,7 +70,7 @@ class ParticipantLicensePlateFieldHandler extends AbstractParticipantFieldHandle
* the license plate is automatically cleared to maintain data consistency.
*
* @param array<string, mixed> $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
@@ -41,7 +41,7 @@ class ParticipantRemarksRoomFieldHandler extends AbstractParticipantFieldHandler
* the normalization of empty strings to null values.
*
* @param array<string, mixed> $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
@@ -75,8 +75,7 @@ class ParticipantRentalInsuranceFieldHandler extends AbstractParticipantFieldHan
* is no longer appropriate for the participant's age, it is automatically cleared.
*
* @param array<string, mixed> $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
@@ -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<string, mixed> $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<string, mixed> $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
*/
+2 -2
View File
@@ -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
*/
@@ -117,5 +117,4 @@ class BookingFingerprintService
return $bookingDto->originalFingerprint !== $currentFingerprint;
}
}
+2 -2
View File
@@ -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
+9 -10
View File
@@ -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<Insurance> $availableInsurances All available insurances
* @param Insurance $selectedInsurance The insurance selected by the applicant
* @param BookingDto $booking The booking with all participants
* @param array<int, float> $participantPrices Map of participant index to travel price (excluding insurance)
* @param array<Insurance> $availableInsurances All available insurances
* @param Insurance $selectedInsurance The insurance selected by the applicant
* @param BookingDto $booking The booking with all participants
* @param array<int, float> $participantPrices Map of participant index to travel price (excluding insurance)
*
* @return array<int, Insurance|null> 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;
}
}
@@ -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)
*/
+61 -44
View File
@@ -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') %}
<div id="participant-card-{{ index }}"
class="border rounded p-4 flex justify-between items-center
class="border rounded p-4
{{ isCanceled ? 'border-gray-400 bg-gray-50' : (hasErrors ? 'border-red-500 bg-red-50' : '') }}">
<div>
<div class="flex items-center gap-2">
<h3 class="font-semibold {{ hasErrors ? 'text-red-800' : (isCanceled ? 'text-gray-600' : '') }}" {{ qa_attribute('participant-name', index) }}>
{{ cardData.name }}
</h3>
{% if isCanceled %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-700 text-white" {{ qa_attribute('participant-canceled', index) }}>
storniert
</span>
{% elseif hasErrors %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800" {{ qa_attribute('participant-incomplete', index) }}>
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
</svg>
Unvollständig
</span>
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="flex items-center gap-2">
<h3 class="font-semibold {{ hasErrors ? 'text-red-800' : (isCanceled ? 'text-gray-600' : '') }}" {{ qa_attribute('participant-name', index) }}>
{{ cardData.name }}
</h3>
{% if isCanceled %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-700 text-white" {{ qa_attribute('participant-canceled', index) }}>
storniert
</span>
{% elseif hasErrors %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800" {{ qa_attribute('participant-incomplete', index) }}>
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
</svg>
Unvollständig
</span>
{% endif %}
</div>
<p class="text-sm text-gray-600" {{ qa_attribute('participant-room-name', index) }}>{{ cardData.roomName }}</p>
{# Display specific error messages #}
{% if hasErrors and errorMessages|length > 0 %}
<div class="mt-2 space-y-1" {{ qa_attribute('participant-errors', index) }}>
{% for errorMessage in errorMessages %}
<p class="text-sm text-red-700">
<svg class="w-4 h-4 inline mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
</svg>
{{ errorMessage }}
</p>
{% endfor %}
</div>
{% endif %}
</div>
<p class="text-sm text-gray-600" {{ qa_attribute('participant-room-name', index) }}>{{ cardData.roomName }}</p>
</div>
<div class="flex items-center gap-4">
<span class="font-medium {{ isCanceled ? 'text-gray-500' : '' }}" {{ qa_attribute('participant-price', index) }}>{{ cardData.price }}</span>
{% if isCanceled %}
<button type="button"
class="button bg-button bg-button--secondary opacity-50 cursor-not-allowed"
disabled
title="Stornierte Teilnehmer können nicht bearbeitet werden">
Bearbeiten
</button>
{% else %}
{% if mode == 'edit' %}
<div class="flex items-center gap-4">
<span class="font-medium {{ isCanceled ? 'text-gray-500' : '' }}" {{ qa_attribute('participant-price', index) }}>{{ cardData.price }}</span>
{% if isCanceled %}
<button type="button"
class="button bg-button {{ hasErrors ? 'bg-button--primary' : 'bg-button--secondary' }}"
hx-get="{{ path('app_booking_edit_participant', {id: bookingId, index: index}) }}"
hx-target="#main-content"
hx-swap="innerHTML"
{{ qa_attribute('btn-edit-participant', index) }}>
class="button bg-button bg-button--secondary opacity-50 cursor-not-allowed"
disabled
title="Stornierte Teilnehmer können nicht bearbeitet werden">
Bearbeiten
</button>
{% else %}
<button type="button"
class="button bg-button {{ hasErrors ? 'bg-button--primary' : 'bg-button--secondary' }}"
hx-get="{{ path('app_booking_create_step_2_participant', {index: index}) }}"
hx-target="#main-content"
hx-swap="innerHTML"
{{ qa_attribute('btn-edit-participant', index) }}>
Bearbeiten
</button>
{% if mode == 'edit' %}
<button type="button"
class="button bg-button {{ hasErrors ? 'bg-button--primary' : 'bg-button--secondary' }}"
hx-get="{{ path('app_booking_edit_participant', {id: bookingId, index: index}) }}"
hx-target="#main-content"
hx-swap="innerHTML"
{{ qa_attribute('btn-edit-participant', index) }}>
Bearbeiten
</button>
{% else %}
<button type="button"
class="button bg-button {{ hasErrors ? 'bg-button--primary' : 'bg-button--secondary' }}"
hx-get="{{ path('app_booking_create_step_2_participant', {index: index}) }}"
hx-target="#main-content"
hx-swap="innerHTML"
{{ qa_attribute('btn-edit-participant', index) }}>
Bearbeiten
</button>
{% endif %}
{% endif %}
{% endif %}
</div>
</div>
</div>
@@ -60,7 +60,14 @@
{# Contact information #}
<div class="grid grid-cols-2 gap-4">
{{ 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) }}
</div>
+3 -2
View File
@@ -2,7 +2,6 @@
{% block content %}
{% include '_partials/_flashes.html.twig' %}
<div {{ stimulus_controller('toast') }}></div>
<h1>Neue Buchung</h1>
<div class="grid grid-cols-3 gap-8">
@@ -37,11 +36,13 @@
<div id="participant-cards-grid" class="space-y-4">
{% 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 %}
</div>
+2 -1
View File
@@ -2,7 +2,6 @@
{% block content %}
{% include '_partials/_flashes.html.twig' %}
<div {{ stimulus_controller('toast') }}></div>
{# Header with reload button #}
<div class="flex justify-between items-center pb-8">
@@ -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 %}
+3
View File
@@ -1,6 +1,9 @@
{% extends 'base.html.twig' %}
{% block body %}
{# Global toast controller - persists across all HTMX swaps and page navigations #}
<div {{ stimulus_controller('toast') }}></div>
<div class="{{ html_classes('pb-8', { 'pt-24': is_granted('ROLE_USER') }) }}" {{ stimulus_controller('loading', [], { 'hidden': 'invisible' }) }}>
<div class="fixed inset-x-0 top-0 z-20">
<div class="max-w-screen-xl mx-auto flex items-center justify-between bg-primary-dark text-white">
+314
View File
@@ -0,0 +1,314 @@
<?php
declare(strict_types=1);
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;
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('[email protected]'),
$this->createAdultParticipant('[email protected]'),
$this->createAdultParticipant('[email protected]'),
]);
$violations = $this->validator->validate($bookingDto, null, ['booking_create_step_2']);
$this->assertCount(0, $violations);
}
public function testEmailUniquenessValidationWithTwoAdultsSameEmailFails(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createAdultParticipant('[email protected]'),
$this->createAdultParticipant('[email protected]'),
]);
$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('[email protected]'),
$this->createAdultParticipant('[email protected]'),
$this->createAdultParticipant('[email protected]'),
]);
$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('[email protected]'),
$this->createChildParticipant('[email protected]'),
]);
$violations = $this->validator->validate($bookingDto, null, ['booking_create_step_2']);
$this->assertCount(0, $violations);
}
public function testEmailUniquenessValidationWithTwoChildrenSameEmailPasses(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createChildParticipant('[email protected]'),
$this->createChildParticipant('[email protected]'),
]);
$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 = '[email protected]';
$participant2 = $this->createAdultParticipant('[email protected]');
$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'), '[email protected]');
$participant1->email = null; // Set to null explicitly
$participant2 = $this->createValidParticipant(new \DateTimeImmutable('1990-01-01'), '[email protected]');
$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('[email protected]'),
$this->createAdultParticipant('[email protected]'),
]);
$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'), '[email protected]');
$participant1->email = ' [email protected] '; // Set whitespace email explicitly after creation
$participant2 = $this->createAdultParticipant('[email protected]');
$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('[email protected]'),
$this->createAdultParticipant('[email protected]'),
]);
$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('[email protected]'), // Unique - OK
$this->createAdultParticipant('[email protected]'), // Duplicate - ERROR
$this->createChildParticipant('[email protected]'), // Child with duplicate - OK
$this->createAdultParticipant('[email protected]'), // Duplicate - ERROR
$this->createChildParticipant('[email protected]'), // Unique child - OK
$this->createAdultParticipant('[email protected]'), // 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 ?? '[email protected]';
// 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;
}
}
@@ -0,0 +1,282 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form\Service;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\ParticipantEmailFieldHandler;
use PHPUnit\Framework\TestCase;
class ParticipantEmailFieldHandlerTest extends TestCase
{
private ParticipantEmailFieldHandler $handler;
protected function setUp(): void
{
$this->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' => '[email protected]'], 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('[email protected]');
$participant2 = $this->createAdultParticipant('[email protected]');
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant1, $participant2];
$submittedData = ['email' => '[email protected]'];
$this->handler->processField($submittedData, $bookingDto, 0);
$this->assertEmpty($participant1->notifications);
}
public function testProcessFieldAdultsWithDuplicateEmailsAddsWarningNotification(): void
{
$participant1 = $this->createAdultParticipant('[email protected]');
$participant2 = $this->createAdultParticipant('[email protected]');
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant1, $participant2];
$submittedData = ['email' => '[email protected]'];
$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('[email protected]');
$child = $this->createChildParticipant('[email protected]');
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$adult, $child];
$submittedData = ['email' => '[email protected]'];
$this->handler->processField($submittedData, $bookingDto, 0);
$this->assertEmpty($adult->notifications);
}
public function testProcessFieldChildWithDuplicateEmailNoNotification(): void
{
$child1 = $this->createChildParticipant('[email protected]');
$child2 = $this->createChildParticipant('[email protected]');
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$child1, $child2];
$submittedData = ['email' => '[email protected]'];
$this->handler->processField($submittedData, $bookingDto, 0);
$this->assertEmpty($child1->notifications);
}
public function testProcessFieldChildWithSameEmailAsAdultNoNotification(): void
{
$adult = $this->createAdultParticipant('[email protected]');
$child = $this->createChildParticipant('[email protected]');
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$adult, $child];
$submittedData = ['email' => '[email protected]'];
// Process child participant (index 1)
$this->handler->processField($submittedData, $bookingDto, 1);
$this->assertEmpty($child->notifications);
}
public function testProcessFieldThreeAdultsWithSameEmailAddsNotification(): void
{
$participant1 = $this->createAdultParticipant('[email protected]');
$participant2 = $this->createAdultParticipant('[email protected]');
$participant3 = $this->createAdultParticipant('[email protected]');
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant1, $participant2, $participant3];
$submittedData = ['email' => '[email protected]'];
$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 = '[email protected]';
$participant2 = $this->createAdultParticipant('[email protected]');
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant1, $participant2];
$submittedData = ['email' => '[email protected]'];
$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('[email protected]');
$participant2 = $this->createAdultParticipant('[email protected]');
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant1, $participant2];
$submittedData = ['email' => '[email protected]'];
$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' => '[email protected]'];
$this->handler->processField($submittedData, $bookingDto, 0);
// Should handle gracefully when participant doesn't exist
$this->expectNotToPerformAssertions();
}
public function testProcessFieldWithDifferentParticipantIndex(): void
{
$participant1 = $this->createAdultParticipant('[email protected]');
$participant2 = $this->createAdultParticipant('[email protected]');
$participant3 = $this->createAdultParticipant('[email protected]');
$travel = new Travel();
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$participant1, $participant2, $participant3];
$submittedData = ['email' => '[email protected]'];
// 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;
}
}