Les administrateurs ont besoin d'un moyen simple pour inviter les parents à rejoindre la plateforme. Cette fonctionnalité permet de générer des codes d'invitation uniques (8 caractères alphanumériques) avec une validité de 48h, de les envoyer par email, et de les activer via une page publique dédiée qui crée automatiquement le compte parent. L'interface d'administration offre l'envoi unitaire et en masse, le renvoi, le filtrage par statut, ainsi que la visualisation de l'état de chaque invitation (en attente, activée, expirée).
207 lines
7.5 KiB
PHP
207 lines
7.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Administration\Infrastructure\Messaging;
|
|
|
|
use App\Administration\Domain\Event\InvitationParentEnvoyee;
|
|
use App\Administration\Domain\Model\Invitation\InvitationCode;
|
|
use App\Administration\Domain\Model\Invitation\ParentInvitation;
|
|
use App\Administration\Domain\Model\User\Email;
|
|
use App\Administration\Domain\Model\User\Role;
|
|
use App\Administration\Domain\Model\User\User;
|
|
use App\Administration\Domain\Model\User\UserId;
|
|
use App\Administration\Infrastructure\Messaging\SendParentInvitationEmailHandler;
|
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryParentInvitationRepository;
|
|
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
|
use App\Shared\Domain\Tenant\TenantId;
|
|
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
|
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
|
|
use App\Shared\Infrastructure\Tenant\TenantRegistry;
|
|
use App\Shared\Infrastructure\Tenant\TenantUrlBuilder;
|
|
use DateTimeImmutable;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Symfony\Component\Mailer\MailerInterface;
|
|
use Symfony\Component\Mime\Email as MimeEmail;
|
|
use Twig\Environment;
|
|
|
|
final class SendParentInvitationEmailHandlerTest extends TestCase
|
|
{
|
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
|
private const string FROM_EMAIL = 'noreply@classeo.fr';
|
|
|
|
private InMemoryParentInvitationRepository $invitationRepository;
|
|
private InMemoryUserRepository $userRepository;
|
|
private TenantUrlBuilder $tenantUrlBuilder;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->invitationRepository = new InMemoryParentInvitationRepository();
|
|
$this->userRepository = new InMemoryUserRepository();
|
|
|
|
$tenantConfig = new TenantConfig(
|
|
tenantId: InfraTenantId::fromString(self::TENANT_ID),
|
|
subdomain: 'ecole-alpha',
|
|
databaseUrl: 'sqlite:///:memory:',
|
|
);
|
|
|
|
$tenantRegistry = $this->createMock(TenantRegistry::class);
|
|
$tenantRegistry->method('getConfig')->willReturn($tenantConfig);
|
|
|
|
$this->tenantUrlBuilder = new TenantUrlBuilder(
|
|
$tenantRegistry,
|
|
'https://classeo.fr',
|
|
'classeo.fr',
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function itSendsParentInvitationEmailWithStudentName(): void
|
|
{
|
|
$student = $this->createAndSaveStudent('Alice', 'Dupont');
|
|
$invitation = $this->createAndSaveInvitation($student->id, 'parent@example.com');
|
|
|
|
$mailer = $this->createMock(MailerInterface::class);
|
|
$twig = $this->createMock(Environment::class);
|
|
|
|
$twig->expects($this->once())
|
|
->method('render')
|
|
->with('emails/parent_invitation.html.twig', $this->callback(
|
|
static fn (array $params): bool => $params['studentName'] === 'Alice Dupont'
|
|
&& str_contains($params['activationUrl'], 'ecole-alpha.classeo.fr/parent-activate/'),
|
|
))
|
|
->willReturn('<html>parent invitation</html>');
|
|
|
|
$mailer->expects($this->once())
|
|
->method('send')
|
|
->with($this->callback(
|
|
static fn (MimeEmail $email): bool => $email->getTo()[0]->getAddress() === 'parent@example.com'
|
|
&& $email->getSubject() === 'Invitation à rejoindre Classeo'
|
|
&& $email->getHtmlBody() === '<html>parent invitation</html>',
|
|
));
|
|
|
|
$handler = new SendParentInvitationEmailHandler(
|
|
$mailer,
|
|
$twig,
|
|
$this->invitationRepository,
|
|
$this->userRepository,
|
|
$this->tenantUrlBuilder,
|
|
self::FROM_EMAIL,
|
|
);
|
|
|
|
$event = new InvitationParentEnvoyee(
|
|
invitationId: $invitation->id,
|
|
studentId: $student->id,
|
|
parentEmail: $invitation->parentEmail,
|
|
tenantId: $invitation->tenantId,
|
|
occurredOn: new DateTimeImmutable('2026-02-07 10:00:00'),
|
|
);
|
|
|
|
($handler)($event);
|
|
}
|
|
|
|
#[Test]
|
|
public function itSendsFromConfiguredEmailAddress(): void
|
|
{
|
|
$student = $this->createAndSaveStudent('Bob', 'Martin');
|
|
$invitation = $this->createAndSaveInvitation($student->id, 'parent2@example.com');
|
|
|
|
$mailer = $this->createMock(MailerInterface::class);
|
|
$twig = $this->createMock(Environment::class);
|
|
$twig->method('render')->willReturn('<html>invitation</html>');
|
|
|
|
$customFrom = 'custom@school.fr';
|
|
|
|
$mailer->expects($this->once())
|
|
->method('send')
|
|
->with($this->callback(
|
|
static fn (MimeEmail $email): bool => $email->getFrom()[0]->getAddress() === $customFrom,
|
|
));
|
|
|
|
$handler = new SendParentInvitationEmailHandler(
|
|
$mailer,
|
|
$twig,
|
|
$this->invitationRepository,
|
|
$this->userRepository,
|
|
$this->tenantUrlBuilder,
|
|
$customFrom,
|
|
);
|
|
|
|
$event = new InvitationParentEnvoyee(
|
|
invitationId: $invitation->id,
|
|
studentId: $student->id,
|
|
parentEmail: $invitation->parentEmail,
|
|
tenantId: $invitation->tenantId,
|
|
occurredOn: new DateTimeImmutable('2026-02-07 10:00:00'),
|
|
);
|
|
|
|
($handler)($event);
|
|
}
|
|
|
|
#[Test]
|
|
public function itDoesNothingWhenInvitationNotFound(): void
|
|
{
|
|
$student = $this->createAndSaveStudent('Charlie', 'Durand');
|
|
|
|
$mailer = $this->createMock(MailerInterface::class);
|
|
$twig = $this->createMock(Environment::class);
|
|
|
|
$mailer->expects($this->never())->method('send');
|
|
|
|
$handler = new SendParentInvitationEmailHandler(
|
|
$mailer,
|
|
$twig,
|
|
$this->invitationRepository,
|
|
$this->userRepository,
|
|
$this->tenantUrlBuilder,
|
|
self::FROM_EMAIL,
|
|
);
|
|
|
|
// Event with a non-existent invitation ID
|
|
$event = new InvitationParentEnvoyee(
|
|
invitationId: \App\Administration\Domain\Model\Invitation\ParentInvitationId::generate(),
|
|
studentId: $student->id,
|
|
parentEmail: new Email('ghost@example.com'),
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
occurredOn: new DateTimeImmutable('2026-02-07 10:00:00'),
|
|
);
|
|
|
|
($handler)($event);
|
|
}
|
|
|
|
private function createAndSaveStudent(string $firstName, string $lastName): User
|
|
{
|
|
$student = User::inviter(
|
|
email: new Email($firstName . '@example.com'),
|
|
role: Role::ELEVE,
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
schoolName: 'École Alpha',
|
|
firstName: $firstName,
|
|
lastName: $lastName,
|
|
invitedAt: new DateTimeImmutable('2026-02-07 10:00:00'),
|
|
);
|
|
$student->pullDomainEvents();
|
|
$this->userRepository->save($student);
|
|
|
|
return $student;
|
|
}
|
|
|
|
private function createAndSaveInvitation(UserId $studentId, string $parentEmail): ParentInvitation
|
|
{
|
|
$invitation = ParentInvitation::creer(
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
studentId: $studentId,
|
|
parentEmail: new Email($parentEmail),
|
|
code: new InvitationCode(str_repeat('a', 32)),
|
|
createdAt: new DateTimeImmutable('2026-02-07 10:00:00'),
|
|
createdBy: UserId::generate(),
|
|
);
|
|
$invitation->envoyer(new DateTimeImmutable('2026-02-07 10:00:00'));
|
|
$invitation->pullDomainEvents();
|
|
$this->invitationRepository->save($invitation);
|
|
|
|
return $invitation;
|
|
}
|
|
}
|