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).
209 lines
7.2 KiB
PHP
209 lines
7.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Administration\Application\Command\ActivateParentInvitation;
|
|
|
|
use App\Administration\Application\Command\ActivateParentInvitation\ActivateParentInvitationCommand;
|
|
use App\Administration\Application\Command\ActivateParentInvitation\ActivateParentInvitationHandler;
|
|
use App\Administration\Application\Port\PasswordHasher;
|
|
use App\Administration\Domain\Exception\InvitationDejaActiveeException;
|
|
use App\Administration\Domain\Exception\InvitationExpireeException;
|
|
use App\Administration\Domain\Exception\InvitationNonEnvoyeeException;
|
|
use App\Administration\Domain\Exception\ParentInvitationNotFoundException;
|
|
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\UserId;
|
|
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;
|
|
|
|
final class ActivateParentInvitationHandlerTest extends TestCase
|
|
{
|
|
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
|
private const string CODE = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
|
|
|
private InMemoryParentInvitationRepository $repository;
|
|
private ActivateParentInvitationHandler $handler;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->repository = new InMemoryParentInvitationRepository();
|
|
|
|
$clock = new class implements Clock {
|
|
public function now(): DateTimeImmutable
|
|
{
|
|
return new DateTimeImmutable('2026-02-08 10:00:00');
|
|
}
|
|
};
|
|
|
|
$passwordHasher = new class implements PasswordHasher {
|
|
public function hash(string $plainPassword): string
|
|
{
|
|
return 'hashed_' . $plainPassword;
|
|
}
|
|
|
|
public function verify(string $hashedPassword, string $plainPassword): bool
|
|
{
|
|
return $hashedPassword === 'hashed_' . $plainPassword;
|
|
}
|
|
};
|
|
|
|
$this->handler = new ActivateParentInvitationHandler(
|
|
$this->repository,
|
|
$passwordHasher,
|
|
$clock,
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function itValidatesAndReturnsActivationResult(): void
|
|
{
|
|
$invitation = $this->createSentInvitation();
|
|
|
|
$result = ($this->handler)(new ActivateParentInvitationCommand(
|
|
code: self::CODE,
|
|
firstName: 'Jean',
|
|
lastName: 'Parent',
|
|
password: 'SecurePass123!',
|
|
));
|
|
|
|
self::assertSame((string) $invitation->id, $result->invitationId);
|
|
self::assertSame((string) $invitation->studentId, $result->studentId);
|
|
self::assertSame('parent@example.com', $result->parentEmail);
|
|
self::assertSame('hashed_SecurePass123!', $result->hashedPassword);
|
|
self::assertSame('Jean', $result->firstName);
|
|
self::assertSame('Parent', $result->lastName);
|
|
}
|
|
|
|
#[Test]
|
|
public function itThrowsWhenCodeNotFound(): void
|
|
{
|
|
$this->expectException(ParentInvitationNotFoundException::class);
|
|
|
|
($this->handler)(new ActivateParentInvitationCommand(
|
|
code: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
firstName: 'Jean',
|
|
lastName: 'Parent',
|
|
password: 'SecurePass123!',
|
|
));
|
|
}
|
|
|
|
#[Test]
|
|
public function itThrowsWhenInvitationNotSent(): void
|
|
{
|
|
$this->createPendingInvitation();
|
|
|
|
$this->expectException(InvitationNonEnvoyeeException::class);
|
|
|
|
($this->handler)(new ActivateParentInvitationCommand(
|
|
code: self::CODE,
|
|
firstName: 'Jean',
|
|
lastName: 'Parent',
|
|
password: 'SecurePass123!',
|
|
));
|
|
}
|
|
|
|
#[Test]
|
|
public function itThrowsWhenInvitationExpired(): void
|
|
{
|
|
$this->createExpiredInvitation();
|
|
|
|
$this->expectException(InvitationExpireeException::class);
|
|
|
|
($this->handler)(new ActivateParentInvitationCommand(
|
|
code: self::CODE,
|
|
firstName: 'Jean',
|
|
lastName: 'Parent',
|
|
password: 'SecurePass123!',
|
|
));
|
|
}
|
|
|
|
#[Test]
|
|
public function itThrowsWhenInvitationAlreadyActivated(): void
|
|
{
|
|
$this->createActivatedInvitation();
|
|
|
|
$this->expectException(InvitationDejaActiveeException::class);
|
|
|
|
($this->handler)(new ActivateParentInvitationCommand(
|
|
code: self::CODE,
|
|
firstName: 'Jean',
|
|
lastName: 'Parent',
|
|
password: 'SecurePass123!',
|
|
));
|
|
}
|
|
|
|
private function createSentInvitation(): ParentInvitation
|
|
{
|
|
$invitation = ParentInvitation::creer(
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
studentId: UserId::generate(),
|
|
parentEmail: new Email('parent@example.com'),
|
|
code: new InvitationCode(self::CODE),
|
|
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->repository->save($invitation);
|
|
|
|
return $invitation;
|
|
}
|
|
|
|
private function createPendingInvitation(): ParentInvitation
|
|
{
|
|
$invitation = ParentInvitation::creer(
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
studentId: UserId::generate(),
|
|
parentEmail: new Email('parent@example.com'),
|
|
code: new InvitationCode(self::CODE),
|
|
createdAt: new DateTimeImmutable('2026-02-07 10:00:00'),
|
|
createdBy: UserId::generate(),
|
|
);
|
|
$invitation->pullDomainEvents();
|
|
$this->repository->save($invitation);
|
|
|
|
return $invitation;
|
|
}
|
|
|
|
private function createExpiredInvitation(): ParentInvitation
|
|
{
|
|
$invitation = ParentInvitation::creer(
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
studentId: UserId::generate(),
|
|
parentEmail: new Email('parent@example.com'),
|
|
code: new InvitationCode(self::CODE),
|
|
createdAt: new DateTimeImmutable('2026-01-01 10:00:00'),
|
|
createdBy: UserId::generate(),
|
|
);
|
|
$invitation->envoyer(new DateTimeImmutable('2026-01-01 10:00:00'));
|
|
$invitation->pullDomainEvents();
|
|
$this->repository->save($invitation);
|
|
|
|
return $invitation;
|
|
}
|
|
|
|
private function createActivatedInvitation(): ParentInvitation
|
|
{
|
|
$invitation = ParentInvitation::creer(
|
|
tenantId: TenantId::fromString(self::TENANT_ID),
|
|
studentId: UserId::generate(),
|
|
parentEmail: new Email('parent@example.com'),
|
|
code: new InvitationCode(self::CODE),
|
|
createdAt: new DateTimeImmutable('2026-02-07 10:00:00'),
|
|
createdBy: UserId::generate(),
|
|
);
|
|
$invitation->envoyer(new DateTimeImmutable('2026-02-07 10:00:00'));
|
|
$invitation->activer(UserId::generate(), new DateTimeImmutable('2026-02-07 12:00:00'));
|
|
$invitation->pullDomainEvents();
|
|
$this->repository->save($invitation);
|
|
|
|
return $invitation;
|
|
}
|
|
}
|