feat: Permettre la génération et l'envoi de codes d'invitation aux parents

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).
This commit is contained in:
2026-02-28 00:08:56 +01:00
parent de5880e25e
commit be1b0b60a6
68 changed files with 8787 additions and 1 deletions

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Console;
use App\Administration\Domain\Model\Invitation\InvitationCode;
use App\Administration\Domain\Model\Invitation\InvitationStatus;
use App\Administration\Domain\Model\Invitation\ParentInvitation;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Console\ExpireInvitationsCommand;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryParentInvitationRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Console\Tester\CommandTester;
final class ExpireInvitationsCommandTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string CREATED_BY_ID = '550e8400-e29b-41d4-a716-446655440003';
private InMemoryParentInvitationRepository $repository;
private Clock $clock;
protected function setUp(): void
{
$this->repository = new InMemoryParentInvitationRepository();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-02-28 10:00:00');
}
};
}
#[Test]
public function itExpiresInvitationsPastExpirationDate(): void
{
$invitation = $this->creerInvitation(
createdAt: new DateTimeImmutable('2026-02-01 10:00:00'),
code: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
);
$invitation->envoyer(new DateTimeImmutable('2026-02-01 11:00:00'));
$this->repository->save($invitation);
$tester = $this->executeCommand();
self::assertSame(0, $tester->getStatusCode());
self::assertStringContainsString('1 invitation(s) expirée(s) trouvée(s)', $tester->getDisplay());
self::assertStringContainsString('1 invitation(s) marquée(s) comme expirée(s)', $tester->getDisplay());
self::assertSame(InvitationStatus::EXPIRED, $invitation->status);
}
#[Test]
public function itHandlesNoExpiredInvitations(): void
{
$tester = $this->executeCommand();
self::assertSame(0, $tester->getStatusCode());
self::assertStringContainsString('Aucune invitation expirée à traiter.', $tester->getDisplay());
}
#[Test]
public function itDoesNotExpirePendingInvitations(): void
{
$invitation = $this->creerInvitation(
createdAt: new DateTimeImmutable('2026-02-01 10:00:00'),
code: 'b1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
);
$this->repository->save($invitation);
$tester = $this->executeCommand();
self::assertSame(0, $tester->getStatusCode());
self::assertStringContainsString('Aucune invitation expirée à traiter.', $tester->getDisplay());
self::assertSame(InvitationStatus::PENDING, $invitation->status);
}
#[Test]
public function itDoesNotExpireNonExpiredSentInvitations(): void
{
$invitation = $this->creerInvitation(
createdAt: new DateTimeImmutable('2026-02-25 10:00:00'),
code: 'c1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
);
$invitation->envoyer(new DateTimeImmutable('2026-02-25 11:00:00'));
$this->repository->save($invitation);
$tester = $this->executeCommand();
self::assertSame(0, $tester->getStatusCode());
self::assertStringContainsString('Aucune invitation expirée à traiter.', $tester->getDisplay());
self::assertSame(InvitationStatus::SENT, $invitation->status);
}
private function executeCommand(): CommandTester
{
$command = new ExpireInvitationsCommand(
$this->repository,
$this->clock,
new NullLogger(),
);
$tester = new CommandTester($command);
$tester->execute([]);
return $tester;
}
private function creerInvitation(
DateTimeImmutable $createdAt,
string $code,
): ParentInvitation {
return ParentInvitation::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
studentId: UserId::fromString(self::STUDENT_ID),
parentEmail: new Email('parent@example.com'),
code: new InvitationCode($code),
createdAt: $createdAt,
createdBy: UserId::fromString(self::CREATED_BY_ID),
);
}
}

View File

@@ -0,0 +1,206 @@
<?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;
}
}

View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Exception\ParentInvitationNotFoundException;
use App\Administration\Domain\Model\Invitation\InvitationCode;
use App\Administration\Domain\Model\Invitation\InvitationStatus;
use App\Administration\Domain\Model\Invitation\ParentInvitation;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryParentInvitationRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class InMemoryParentInvitationRepositoryTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string OTHER_TENANT_ID = '550e8400-e29b-41d4-a716-446655440099';
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string CREATED_BY_ID = '550e8400-e29b-41d4-a716-446655440003';
private const string CODE = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4';
private InMemoryParentInvitationRepository $repository;
protected function setUp(): void
{
$this->repository = new InMemoryParentInvitationRepository();
}
#[Test]
public function saveAndGetReturnsInvitation(): void
{
$invitation = $this->creerInvitation();
$this->repository->save($invitation);
$found = $this->repository->get($invitation->id, TenantId::fromString(self::TENANT_ID));
self::assertTrue($found->id->equals($invitation->id));
}
#[Test]
public function getThrowsExceptionWhenNotFound(): void
{
$this->expectException(ParentInvitationNotFoundException::class);
$invitation = $this->creerInvitation();
$this->repository->get($invitation->id, TenantId::fromString(self::TENANT_ID));
}
#[Test]
public function getThrowsExceptionForWrongTenant(): void
{
$invitation = $this->creerInvitation();
$this->repository->save($invitation);
$this->expectException(ParentInvitationNotFoundException::class);
$this->repository->get($invitation->id, TenantId::fromString(self::OTHER_TENANT_ID));
}
#[Test]
public function findByIdReturnsNullWhenNotFound(): void
{
$invitation = $this->creerInvitation();
self::assertNull($this->repository->findById($invitation->id, TenantId::fromString(self::TENANT_ID)));
}
#[Test]
public function findByCodeReturnsInvitation(): void
{
$invitation = $this->creerInvitation();
$this->repository->save($invitation);
$found = $this->repository->findByCode(new InvitationCode(self::CODE));
self::assertNotNull($found);
self::assertTrue($found->id->equals($invitation->id));
}
#[Test]
public function findByCodeReturnsNullWhenNotFound(): void
{
self::assertNull($this->repository->findByCode(new InvitationCode('11111111111111111111111111111111')));
}
#[Test]
public function findAllByTenantReturnsOnlyMatchingTenant(): void
{
$invitation1 = $this->creerInvitation();
$invitation2 = $this->creerInvitation(email: 'parent2@example.com', code: '22222222222222222222222222222222');
$this->repository->save($invitation1);
$this->repository->save($invitation2);
$results = $this->repository->findAllByTenant(TenantId::fromString(self::TENANT_ID));
self::assertCount(2, $results);
}
#[Test]
public function findByStudentReturnsInvitationsForStudent(): void
{
$invitation = $this->creerInvitation();
$this->repository->save($invitation);
$results = $this->repository->findByStudent(
UserId::fromString(self::STUDENT_ID),
TenantId::fromString(self::TENANT_ID),
);
self::assertCount(1, $results);
}
#[Test]
public function findByStatusReturnsMatchingInvitations(): void
{
$invitation = $this->creerInvitation();
$this->repository->save($invitation);
$pending = $this->repository->findByStatus(InvitationStatus::PENDING, TenantId::fromString(self::TENANT_ID));
$sent = $this->repository->findByStatus(InvitationStatus::SENT, TenantId::fromString(self::TENANT_ID));
self::assertCount(1, $pending);
self::assertCount(0, $sent);
}
#[Test]
public function findExpiredSentReturnsOnlySentAndExpired(): void
{
$invitation = $this->creerInvitation(createdAt: new DateTimeImmutable('2026-01-01 10:00:00'));
$invitation->envoyer(new DateTimeImmutable('2026-01-01 11:00:00'));
$this->repository->save($invitation);
$results = $this->repository->findExpiredSent(new DateTimeImmutable('2026-02-01 10:00:00'));
self::assertCount(1, $results);
}
#[Test]
public function findExpiredSentDoesNotReturnNonExpired(): void
{
$invitation = $this->creerInvitation();
$invitation->envoyer(new DateTimeImmutable('2026-02-20 11:00:00'));
$this->repository->save($invitation);
$results = $this->repository->findExpiredSent(new DateTimeImmutable('2026-02-21 10:00:00'));
self::assertCount(0, $results);
}
#[Test]
public function deleteRemovesInvitation(): void
{
$invitation = $this->creerInvitation();
$this->repository->save($invitation);
$this->repository->delete($invitation->id, TenantId::fromString(self::TENANT_ID));
self::assertNull($this->repository->findById($invitation->id, TenantId::fromString(self::TENANT_ID)));
self::assertNull($this->repository->findByCode(new InvitationCode(self::CODE)));
}
#[Test]
public function saveUpdatesCodeIndexOnResend(): void
{
$invitation = $this->creerInvitation();
$invitation->envoyer(new DateTimeImmutable('2026-02-20 11:00:00'));
$this->repository->save($invitation);
$newCode = new InvitationCode('33333333333333333333333333333333');
$invitation->renvoyer($newCode, new DateTimeImmutable('2026-02-25 10:00:00'));
$this->repository->save($invitation);
self::assertNull($this->repository->findByCode(new InvitationCode(self::CODE)));
self::assertNotNull($this->repository->findByCode($newCode));
}
private function creerInvitation(
string $email = 'parent@example.com',
string $code = self::CODE,
?DateTimeImmutable $createdAt = null,
): ParentInvitation {
return ParentInvitation::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
studentId: UserId::fromString(self::STUDENT_ID),
parentEmail: new Email($email),
code: new InvitationCode($code),
createdAt: $createdAt ?? new DateTimeImmutable('2026-02-20 10:00:00'),
createdBy: UserId::fromString(self::CREATED_BY_ID),
);
}
}