feat: unique email addresses of participants unless child, cleanup

addresses #869axbn21
This commit is contained in:
Björn Fromme
2026-03-16 11:59:11 +01:00
parent f50aa55e8b
commit 7aea149b6c
57 changed files with 1036 additions and 153 deletions
+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;
}
}