feat: unique email addresses of participants unless child, cleanup
addresses #869axbn21
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,7 +26,7 @@ use App\Service\ParticipantEligibilityService;
|
||||
class BookingEligibilityCondition implements FieldConditionInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ParticipantEligibilityService $participantEligibilityService
|
||||
private readonly ParticipantEligibilityService $participantEligibilityService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -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,7 +85,6 @@ 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 int $participantIndex The index of the participant being processed
|
||||
*/
|
||||
|
||||
@@ -78,7 +78,6 @@ 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 int $participantIndex The index of the participant being processed
|
||||
*/
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,6 @@ 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 int $participantIndex The index of the participant being processed
|
||||
*/
|
||||
|
||||
@@ -117,5 +117,4 @@ class BookingFingerprintService
|
||||
|
||||
return $bookingDto->originalFingerprint !== $currentFingerprint;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
{# 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 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 }}
|
||||
@@ -25,6 +27,20 @@
|
||||
{% 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>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="font-medium {{ isCanceled ? 'text-gray-500' : '' }}" {{ qa_attribute('participant-price', index) }}>{{ cardData.price }}</span>
|
||||
@@ -58,3 +74,4 @@
|
||||
{% 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>
|
||||
|
||||
|
||||
@@ -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,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 %}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user