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:
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user