feat: Activation de compte utilisateur avec validation token
L'inscription Classeo se fait via invitation : un admin crée un compte, l'utilisateur reçoit un lien d'activation par email pour définir son mot de passe. Ce flow sécurisé évite les inscriptions non autorisées et garantit que seuls les utilisateurs légitimes accèdent au système. Points clés de l'implémentation : - Tokens d'activation à usage unique stockés en cache (Redis/filesystem) - Validation du consentement parental pour les mineurs < 15 ans (RGPD) - L'échec d'activation ne consume pas le token (retry possible) - Users dans un cache séparé sans TTL (pas d'expiration) - Hot reload en dev (FrankenPHP sans mode worker) Story: 1.3 - Inscription et activation de compte
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Command\ActivateAccount;
|
||||
|
||||
use App\Administration\Application\Command\ActivateAccount\ActivateAccountCommand;
|
||||
use App\Administration\Application\Command\ActivateAccount\ActivateAccountHandler;
|
||||
use App\Administration\Application\Command\ActivateAccount\ActivateAccountResult;
|
||||
use App\Administration\Application\Port\PasswordHasher;
|
||||
use App\Administration\Domain\Exception\ActivationTokenAlreadyUsedException;
|
||||
use App\Administration\Domain\Exception\ActivationTokenExpiredException;
|
||||
use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryActivationTokenRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ActivateAccountHandlerTest extends TestCase
|
||||
{
|
||||
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
private const string EMAIL = 'user@example.com';
|
||||
private const string ROLE = 'ROLE_PARENT';
|
||||
private const string SCHOOL_NAME = 'École Alpha';
|
||||
private const string PASSWORD = 'SecurePass123';
|
||||
private const string HASHED_PASSWORD = '$argon2id$hashed_password';
|
||||
|
||||
private InMemoryActivationTokenRepository $tokenRepository;
|
||||
private PasswordHasher $passwordHasher;
|
||||
private Clock $clock;
|
||||
private ActivateAccountHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tokenRepository = new InMemoryActivationTokenRepository();
|
||||
$this->passwordHasher = new class implements PasswordHasher {
|
||||
#[Override]
|
||||
public function hash(string $plainPassword): string
|
||||
{
|
||||
return '$argon2id$hashed_password';
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function verify(string $hashedPassword, string $plainPassword): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
};
|
||||
$this->clock = new class implements Clock {
|
||||
public DateTimeImmutable $now;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->now = new DateTimeImmutable('2026-01-16 10:00:00');
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return $this->now;
|
||||
}
|
||||
};
|
||||
|
||||
$this->handler = new ActivateAccountHandler(
|
||||
$this->tokenRepository,
|
||||
$this->passwordHasher,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activateAccountSuccessfully(): void
|
||||
{
|
||||
$token = $this->createAndSaveToken();
|
||||
|
||||
$command = new ActivateAccountCommand(
|
||||
tokenValue: $token->tokenValue,
|
||||
password: self::PASSWORD,
|
||||
);
|
||||
|
||||
$result = ($this->handler)($command);
|
||||
|
||||
self::assertInstanceOf(ActivateAccountResult::class, $result);
|
||||
self::assertSame(self::USER_ID, $result->userId);
|
||||
self::assertSame(self::EMAIL, $result->email);
|
||||
self::assertSame(self::ROLE, $result->role);
|
||||
self::assertSame(self::HASHED_PASSWORD, $result->hashedPassword);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activateAccountValidatesButDoesNotConsumeToken(): void
|
||||
{
|
||||
// Handler only validates the token - consumption is deferred to the processor
|
||||
// after successful user activation, so failed activations don't burn the token
|
||||
$token = $this->createAndSaveToken();
|
||||
$tokenValue = $token->tokenValue;
|
||||
|
||||
$command = new ActivateAccountCommand(
|
||||
tokenValue: $tokenValue,
|
||||
password: self::PASSWORD,
|
||||
);
|
||||
|
||||
($this->handler)($command);
|
||||
|
||||
// Token should still exist and NOT be marked as used
|
||||
$updatedToken = $this->tokenRepository->findByTokenValue($tokenValue);
|
||||
self::assertNotNull($updatedToken);
|
||||
self::assertFalse($updatedToken->isUsed());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activateAccountThrowsWhenTokenNotFound(): void
|
||||
{
|
||||
$command = new ActivateAccountCommand(
|
||||
tokenValue: 'non-existent-token',
|
||||
password: self::PASSWORD,
|
||||
);
|
||||
|
||||
$this->expectException(ActivationTokenNotFoundException::class);
|
||||
|
||||
($this->handler)($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activateAccountThrowsWhenTokenExpired(): void
|
||||
{
|
||||
$token = $this->createAndSaveToken(
|
||||
createdAt: new DateTimeImmutable('2026-01-01 10:00:00'),
|
||||
);
|
||||
|
||||
// Clock is set to 2026-01-16, token expires 2026-01-08
|
||||
$command = new ActivateAccountCommand(
|
||||
tokenValue: $token->tokenValue,
|
||||
password: self::PASSWORD,
|
||||
);
|
||||
|
||||
$this->expectException(ActivationTokenExpiredException::class);
|
||||
|
||||
($this->handler)($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activateAccountThrowsWhenTokenAlreadyUsed(): void
|
||||
{
|
||||
$token = $this->createAndSaveToken();
|
||||
|
||||
// Simulate a token that was already used (e.g., by the processor after successful activation)
|
||||
$token->use($this->clock->now());
|
||||
$this->tokenRepository->save($token);
|
||||
|
||||
$command = new ActivateAccountCommand(
|
||||
tokenValue: $token->tokenValue,
|
||||
password: self::PASSWORD,
|
||||
);
|
||||
|
||||
// Should fail because token is already used
|
||||
$this->expectException(ActivationTokenAlreadyUsedException::class);
|
||||
|
||||
($this->handler)($command);
|
||||
}
|
||||
|
||||
private function createAndSaveToken(?DateTimeImmutable $createdAt = null): ActivationToken
|
||||
{
|
||||
$token = ActivationToken::generate(
|
||||
userId: self::USER_ID,
|
||||
email: self::EMAIL,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
role: self::ROLE,
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
createdAt: $createdAt ?? new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||
);
|
||||
|
||||
$this->tokenRepository->save($token);
|
||||
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\ActivationToken;
|
||||
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
final class ActivationTokenIdTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function generateCreatesValidUuid(): void
|
||||
{
|
||||
$id = ActivationTokenId::generate();
|
||||
|
||||
self::assertInstanceOf(ActivationTokenId::class, $id);
|
||||
self::assertTrue(Uuid::isValid((string) $id));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fromStringCreatesIdFromValidUuid(): void
|
||||
{
|
||||
$uuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||
$id = ActivationTokenId::fromString($uuid);
|
||||
|
||||
self::assertSame($uuid, (string) $id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function equalsReturnsTrueForSameValue(): void
|
||||
{
|
||||
$uuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||
$id1 = ActivationTokenId::fromString($uuid);
|
||||
$id2 = ActivationTokenId::fromString($uuid);
|
||||
|
||||
self::assertTrue($id1->equals($id2));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function equalsReturnsFalseForDifferentValue(): void
|
||||
{
|
||||
$id1 = ActivationTokenId::generate();
|
||||
$id2 = ActivationTokenId::generate();
|
||||
|
||||
self::assertFalse($id1->equals($id2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\ActivationToken;
|
||||
|
||||
use App\Administration\Domain\Event\ActivationTokenGenerated;
|
||||
use App\Administration\Domain\Event\ActivationTokenUsed;
|
||||
use App\Administration\Domain\Exception\ActivationTokenAlreadyUsedException;
|
||||
use App\Administration\Domain\Exception\ActivationTokenExpiredException;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ActivationTokenTest extends TestCase
|
||||
{
|
||||
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
private const string EMAIL = 'user@example.com';
|
||||
private const string ROLE = 'ROLE_PARENT';
|
||||
private const string SCHOOL_NAME = 'École Alpha';
|
||||
|
||||
#[Test]
|
||||
public function generateCreatesTokenWithCorrectProperties(): void
|
||||
{
|
||||
$userId = self::USER_ID;
|
||||
$email = self::EMAIL;
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$role = self::ROLE;
|
||||
$schoolName = self::SCHOOL_NAME;
|
||||
$now = new DateTimeImmutable('2026-01-15 10:00:00');
|
||||
|
||||
$token = ActivationToken::generate(
|
||||
userId: $userId,
|
||||
email: $email,
|
||||
tenantId: $tenantId,
|
||||
role: $role,
|
||||
schoolName: $schoolName,
|
||||
createdAt: $now,
|
||||
);
|
||||
|
||||
self::assertInstanceOf(ActivationTokenId::class, $token->id);
|
||||
self::assertSame($userId, $token->userId);
|
||||
self::assertSame($email, $token->email);
|
||||
self::assertTrue($tenantId->equals($token->tenantId));
|
||||
self::assertSame($role, $token->role);
|
||||
self::assertSame($schoolName, $token->schoolName);
|
||||
self::assertEquals($now, $token->createdAt);
|
||||
self::assertFalse($token->isUsed());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function generateRecordsActivationTokenGeneratedEvent(): void
|
||||
{
|
||||
$token = $this->createToken();
|
||||
|
||||
$events = $token->pullDomainEvents();
|
||||
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(ActivationTokenGenerated::class, $events[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function tokenValueIsUuidV4Format(): void
|
||||
{
|
||||
$token = $this->createToken();
|
||||
|
||||
self::assertMatchesRegularExpression(
|
||||
'/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i',
|
||||
$token->tokenValue,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function expiresAtIs7DaysAfterCreation(): void
|
||||
{
|
||||
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
|
||||
$expectedExpiration = new DateTimeImmutable('2026-01-22 10:00:00');
|
||||
|
||||
$token = ActivationToken::generate(
|
||||
userId: self::USER_ID,
|
||||
email: self::EMAIL,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
role: self::ROLE,
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
self::assertEquals($expectedExpiration, $token->expiresAt);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isExpiredReturnsFalseWhenNotExpired(): void
|
||||
{
|
||||
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
|
||||
$checkAt = new DateTimeImmutable('2026-01-20 10:00:00');
|
||||
|
||||
$token = ActivationToken::generate(
|
||||
userId: self::USER_ID,
|
||||
email: self::EMAIL,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
role: self::ROLE,
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
self::assertFalse($token->isExpired($checkAt));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isExpiredReturnsTrueWhenExpired(): void
|
||||
{
|
||||
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
|
||||
$checkAt = new DateTimeImmutable('2026-01-25 10:00:00');
|
||||
|
||||
$token = ActivationToken::generate(
|
||||
userId: self::USER_ID,
|
||||
email: self::EMAIL,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
role: self::ROLE,
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
self::assertTrue($token->isExpired($checkAt));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isExpiredReturnsTrueAtExactExpirationMoment(): void
|
||||
{
|
||||
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
|
||||
$checkAt = new DateTimeImmutable('2026-01-22 10:00:00');
|
||||
|
||||
$token = ActivationToken::generate(
|
||||
userId: self::USER_ID,
|
||||
email: self::EMAIL,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
role: self::ROLE,
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
self::assertTrue($token->isExpired($checkAt));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function useMarksTokenAsUsed(): void
|
||||
{
|
||||
$token = $this->createToken();
|
||||
$usedAt = new DateTimeImmutable('2026-01-16 10:00:00');
|
||||
|
||||
$token->use($usedAt);
|
||||
|
||||
self::assertTrue($token->isUsed());
|
||||
self::assertEquals($usedAt, $token->usedAt);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function useRecordsActivationTokenUsedEvent(): void
|
||||
{
|
||||
$token = $this->createToken();
|
||||
$token->pullDomainEvents();
|
||||
|
||||
$usedAt = new DateTimeImmutable('2026-01-16 10:00:00');
|
||||
$token->use($usedAt);
|
||||
|
||||
$events = $token->pullDomainEvents();
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(ActivationTokenUsed::class, $events[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function useThrowsExceptionWhenTokenAlreadyUsed(): void
|
||||
{
|
||||
$token = $this->createToken();
|
||||
$firstUse = new DateTimeImmutable('2026-01-16 10:00:00');
|
||||
$token->use($firstUse);
|
||||
|
||||
$this->expectException(ActivationTokenAlreadyUsedException::class);
|
||||
|
||||
$secondUse = new DateTimeImmutable('2026-01-17 10:00:00');
|
||||
$token->use($secondUse);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function useThrowsExceptionWhenTokenExpired(): void
|
||||
{
|
||||
$createdAt = new DateTimeImmutable('2026-01-15 10:00:00');
|
||||
$usedAt = new DateTimeImmutable('2026-01-25 10:00:00');
|
||||
|
||||
$token = ActivationToken::generate(
|
||||
userId: self::USER_ID,
|
||||
email: self::EMAIL,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
role: self::ROLE,
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
$this->expectException(ActivationTokenExpiredException::class);
|
||||
|
||||
$token->use($usedAt);
|
||||
}
|
||||
|
||||
private function createToken(): ActivationToken
|
||||
{
|
||||
return ActivationToken::generate(
|
||||
userId: self::USER_ID,
|
||||
email: self::EMAIL,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
role: self::ROLE,
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||
);
|
||||
}
|
||||
}
|
||||
220
backend/tests/Unit/Administration/Domain/Model/User/UserTest.php
Normal file
220
backend/tests/Unit/Administration/Domain/Model/User/UserTest.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\User;
|
||||
|
||||
use App\Administration\Domain\Event\CompteActive;
|
||||
use App\Administration\Domain\Event\CompteCreated;
|
||||
use App\Administration\Domain\Exception\CompteNonActivableException;
|
||||
use App\Administration\Domain\Model\ConsentementParental\ConsentementParental;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\StatutCompte;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class UserTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
private const string SCHOOL_NAME = 'École Alpha';
|
||||
|
||||
private Clock $clock;
|
||||
private ConsentementParentalPolicy $consentementPolicy;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-01-31 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->consentementPolicy = new ConsentementParentalPolicy($this->clock);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function creerCreatesUserWithPendingStatus(): void
|
||||
{
|
||||
$user = $this->createUser();
|
||||
|
||||
self::assertSame(StatutCompte::EN_ATTENTE, $user->statut);
|
||||
self::assertNull($user->hashedPassword);
|
||||
self::assertNull($user->activatedAt);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function creerRecordsCompteCreatedEvent(): void
|
||||
{
|
||||
$user = $this->createUser();
|
||||
|
||||
$events = $user->pullDomainEvents();
|
||||
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(CompteCreated::class, $events[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activerSetsPasswordAndChangesStatusToActive(): void
|
||||
{
|
||||
$user = $this->createUser();
|
||||
$hashedPassword = '$argon2id$hashed';
|
||||
$activatedAt = new DateTimeImmutable('2026-01-31 10:00:00');
|
||||
|
||||
$user->activer($hashedPassword, $activatedAt, $this->consentementPolicy);
|
||||
|
||||
self::assertSame(StatutCompte::ACTIF, $user->statut);
|
||||
self::assertSame($hashedPassword, $user->hashedPassword);
|
||||
self::assertEquals($activatedAt, $user->activatedAt);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activerRecordsCompteActiveEvent(): void
|
||||
{
|
||||
$user = $this->createUser();
|
||||
$user->pullDomainEvents();
|
||||
|
||||
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
|
||||
|
||||
$events = $user->pullDomainEvents();
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(CompteActive::class, $events[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activerThrowsWhenStatusIsNotPending(): void
|
||||
{
|
||||
$user = $this->createUser();
|
||||
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
|
||||
|
||||
$this->expectException(CompteNonActivableException::class);
|
||||
|
||||
$user->activer('$argon2id$another', new DateTimeImmutable(), $this->consentementPolicy);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activerThrowsForMinorWithoutConsent(): void
|
||||
{
|
||||
// Créer un utilisateur mineur (14 ans)
|
||||
$user = User::creer(
|
||||
email: new Email('eleve@example.com'),
|
||||
role: Role::ELEVE,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
dateNaissance: new DateTimeImmutable('2012-06-15'), // 13 ans
|
||||
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||
);
|
||||
|
||||
$this->expectException(CompteNonActivableException::class);
|
||||
$this->expectExceptionMessage('consentement parental manquant');
|
||||
|
||||
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activerSucceedsForMinorWithConsent(): void
|
||||
{
|
||||
// Créer un utilisateur mineur (14 ans)
|
||||
$user = User::creer(
|
||||
email: new Email('eleve@example.com'),
|
||||
role: Role::ELEVE,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
dateNaissance: new DateTimeImmutable('2012-06-15'),
|
||||
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||
);
|
||||
|
||||
// Enregistrer le consentement parental
|
||||
$consentement = ConsentementParental::accorder(
|
||||
parentId: 'parent-uuid',
|
||||
eleveId: (string) $user->id,
|
||||
at: new DateTimeImmutable('2026-01-20 10:00:00'),
|
||||
ipAddress: '192.168.1.1',
|
||||
);
|
||||
$user->enregistrerConsentementParental($consentement);
|
||||
|
||||
// L'activation devrait maintenant réussir
|
||||
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
|
||||
|
||||
self::assertSame(StatutCompte::ACTIF, $user->statut);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activerSucceedsForAdultWithoutConsent(): void
|
||||
{
|
||||
// Créer un utilisateur adulte (16 ans)
|
||||
$user = User::creer(
|
||||
email: new Email('eleve@example.com'),
|
||||
role: Role::ELEVE,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
dateNaissance: new DateTimeImmutable('2010-01-01'), // 16 ans
|
||||
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||
);
|
||||
|
||||
// Pas de consentement nécessaire
|
||||
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
|
||||
|
||||
self::assertSame(StatutCompte::ACTIF, $user->statut);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function peutSeConnecterReturnsTrueOnlyWhenActive(): void
|
||||
{
|
||||
$user = $this->createUser();
|
||||
|
||||
self::assertFalse($user->peutSeConnecter());
|
||||
|
||||
$user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy);
|
||||
|
||||
self::assertTrue($user->peutSeConnecter());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function necessiteConsentementParentalReturnsTrueForMinor(): void
|
||||
{
|
||||
$user = User::creer(
|
||||
email: new Email('eleve@example.com'),
|
||||
role: Role::ELEVE,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
dateNaissance: new DateTimeImmutable('2012-06-15'), // 13 ans
|
||||
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||
);
|
||||
|
||||
self::assertTrue($user->necessiteConsentementParental($this->consentementPolicy));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function necessiteConsentementParentalReturnsFalseForAdult(): void
|
||||
{
|
||||
$user = User::creer(
|
||||
email: new Email('parent@example.com'),
|
||||
role: Role::PARENT,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
dateNaissance: null, // Parents n'ont pas de date de naissance enregistrée
|
||||
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||
);
|
||||
|
||||
self::assertFalse($user->necessiteConsentementParental($this->consentementPolicy));
|
||||
}
|
||||
|
||||
private function createUser(): User
|
||||
{
|
||||
return User::creer(
|
||||
email: new Email('user@example.com'),
|
||||
role: Role::PARENT,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
dateNaissance: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Policy;
|
||||
|
||||
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
|
||||
use App\Shared\Domain\Clock;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ConsentementParentalPolicyTest extends TestCase
|
||||
{
|
||||
private Clock $clock;
|
||||
private ConsentementParentalPolicy $policy;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-01-31 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->policy = new ConsentementParentalPolicy($this->clock);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function consentementRequisPourUtilisateurDe14Ans(): void
|
||||
{
|
||||
$dateNaissance = new DateTimeImmutable('2012-01-31');
|
||||
|
||||
self::assertTrue($this->policy->estRequis($dateNaissance));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function consentementRequisPourUtilisateurDe10Ans(): void
|
||||
{
|
||||
$dateNaissance = new DateTimeImmutable('2016-01-31');
|
||||
|
||||
self::assertTrue($this->policy->estRequis($dateNaissance));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function consentementNonRequisPourUtilisateurDe15Ans(): void
|
||||
{
|
||||
$dateNaissance = new DateTimeImmutable('2011-01-30');
|
||||
|
||||
self::assertFalse($this->policy->estRequis($dateNaissance));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function consentementNonRequisPourUtilisateurDe16Ans(): void
|
||||
{
|
||||
$dateNaissance = new DateTimeImmutable('2010-01-31');
|
||||
|
||||
self::assertFalse($this->policy->estRequis($dateNaissance));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function consentementNonRequisSiDateNaissanceNulle(): void
|
||||
{
|
||||
self::assertFalse($this->policy->estRequis(null));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('agesBordureProvider')]
|
||||
public function consentementRequisAuxAgesBordure(
|
||||
string $dateNaissance,
|
||||
bool $consentementRequis,
|
||||
string $description,
|
||||
): void {
|
||||
$result = $this->policy->estRequis(new DateTimeImmutable($dateNaissance));
|
||||
|
||||
self::assertSame($consentementRequis, $result, $description);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string, bool, string}>
|
||||
*/
|
||||
public static function agesBordureProvider(): iterable
|
||||
{
|
||||
// Current date is 2026-01-31
|
||||
yield '14 ans et 364 jours' => [
|
||||
'2011-02-01',
|
||||
true,
|
||||
'Un jour avant 15 ans → consentement requis',
|
||||
];
|
||||
|
||||
yield '15 ans exactement' => [
|
||||
'2011-01-31',
|
||||
false,
|
||||
'Le jour des 15 ans → consentement non requis',
|
||||
];
|
||||
|
||||
yield '15 ans et 1 jour' => [
|
||||
'2011-01-30',
|
||||
false,
|
||||
'Un jour après 15 ans → consentement non requis',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Application\Command\ActivateAccount\ActivateAccountHandler;
|
||||
use App\Administration\Application\Port\PasswordHasher;
|
||||
use App\Administration\Domain\Exception\UserNotFoundException;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Administration\Infrastructure\Api\Processor\ActivateAccountProcessor;
|
||||
use App\Administration\Infrastructure\Api\Resource\ActivateAccountInput;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryActivationTokenRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* Tests for ActivateAccountProcessor focusing on token consumption behavior.
|
||||
*
|
||||
* Key invariant: Failed activations must NOT consume the token, allowing retries.
|
||||
*/
|
||||
final class ActivateAccountProcessorTest extends TestCase
|
||||
{
|
||||
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
private const string EMAIL = 'user@example.com';
|
||||
private const string ROLE = 'ROLE_PARENT';
|
||||
private const string SCHOOL_NAME = 'École Test';
|
||||
private const string PASSWORD = 'SecurePass123';
|
||||
private const string HASHED_PASSWORD = '$argon2id$hashed';
|
||||
|
||||
private InMemoryActivationTokenRepository $tokenRepository;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tokenRepository = new InMemoryActivationTokenRepository();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-01-16 10:00:00');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function tokenRemainsValidWhenUserNotFound(): void
|
||||
{
|
||||
// Arrange: Create a valid token
|
||||
$token = $this->createAndSaveToken();
|
||||
$tokenValue = $token->tokenValue;
|
||||
|
||||
// Create processor with a UserRepository that throws UserNotFoundException
|
||||
$processor = $this->createProcessorWithMissingUser();
|
||||
|
||||
$input = new ActivateAccountInput();
|
||||
$input->tokenValue = $tokenValue;
|
||||
$input->password = self::PASSWORD;
|
||||
|
||||
// Act: Try to activate (should fail because user not found)
|
||||
try {
|
||||
$processor->process($input, new Post());
|
||||
self::fail('Expected NotFoundHttpException to be thrown');
|
||||
} catch (NotFoundHttpException $e) {
|
||||
self::assertSame('Utilisateur introuvable.', $e->getMessage());
|
||||
}
|
||||
|
||||
// Assert: Token should NOT be consumed - retry should be possible
|
||||
$tokenAfterFailure = $this->tokenRepository->findByTokenValue($tokenValue);
|
||||
self::assertNotNull($tokenAfterFailure, 'Token should still exist after failed activation');
|
||||
self::assertFalse($tokenAfterFailure->isUsed(), 'Token should NOT be marked as used after failed activation');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function tokenCanBeReusedAfterFailedActivation(): void
|
||||
{
|
||||
// Arrange: Create a valid token
|
||||
$token = $this->createAndSaveToken();
|
||||
$tokenValue = $token->tokenValue;
|
||||
|
||||
$processorWithMissingUser = $this->createProcessorWithMissingUser();
|
||||
|
||||
$input = new ActivateAccountInput();
|
||||
$input->tokenValue = $tokenValue;
|
||||
$input->password = self::PASSWORD;
|
||||
|
||||
// Act: First activation fails (user not found)
|
||||
try {
|
||||
$processorWithMissingUser->process($input, new Post());
|
||||
} catch (NotFoundHttpException) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
// Assert: Can call handler again with same token (retry scenario)
|
||||
$handler = $this->createHandler();
|
||||
$result = ($handler)(new \App\Administration\Application\Command\ActivateAccount\ActivateAccountCommand(
|
||||
tokenValue: $tokenValue,
|
||||
password: self::PASSWORD,
|
||||
));
|
||||
|
||||
// Should succeed - token was not burned
|
||||
self::assertSame(self::USER_ID, $result->userId);
|
||||
}
|
||||
|
||||
private function createAndSaveToken(): ActivationToken
|
||||
{
|
||||
$token = ActivationToken::generate(
|
||||
userId: self::USER_ID,
|
||||
email: self::EMAIL,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
role: self::ROLE,
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||
);
|
||||
|
||||
$this->tokenRepository->save($token);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function createHandler(): ActivateAccountHandler
|
||||
{
|
||||
$passwordHasher = new class implements PasswordHasher {
|
||||
public function hash(string $plainPassword): string
|
||||
{
|
||||
return '$argon2id$hashed';
|
||||
}
|
||||
|
||||
public function verify(string $hashedPassword, string $plainPassword): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
return new ActivateAccountHandler(
|
||||
$this->tokenRepository,
|
||||
$passwordHasher,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
private function createProcessorWithMissingUser(): ActivateAccountProcessor
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
|
||||
// UserRepository that always throws UserNotFoundException
|
||||
$userRepository = new class implements UserRepository {
|
||||
public function save(\App\Administration\Domain\Model\User\User $user): void
|
||||
{
|
||||
}
|
||||
|
||||
public function findById(UserId $id): ?\App\Administration\Domain\Model\User\User
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function findByEmail(\App\Administration\Domain\Model\User\Email $email): ?\App\Administration\Domain\Model\User\User
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function get(UserId $id): \App\Administration\Domain\Model\User\User
|
||||
{
|
||||
throw UserNotFoundException::withId($id);
|
||||
}
|
||||
};
|
||||
|
||||
$consentementPolicy = new ConsentementParentalPolicy($this->clock);
|
||||
|
||||
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||
|
||||
return new ActivateAccountProcessor(
|
||||
$handler,
|
||||
$userRepository,
|
||||
$this->tokenRepository,
|
||||
$consentementPolicy,
|
||||
$this->clock,
|
||||
$eventBus,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Persistence\Cache;
|
||||
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Infrastructure\Persistence\Cache\CacheUserRepository;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Cache\CacheItemInterface;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
|
||||
/**
|
||||
* Tests for CacheUserRepository.
|
||||
*
|
||||
* Key invariant: Users must not expire from cache (unlike activation tokens which have 7-day TTL).
|
||||
* This was a bug where users were stored in the activation_tokens.cache pool with TTL,
|
||||
* causing activated accounts to become inaccessible after 7 days.
|
||||
*/
|
||||
final class CacheUserRepositoryTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function userIsSavedWithoutExpiration(): void
|
||||
{
|
||||
// Arrange: Create a mock cache that tracks expiration settings
|
||||
$expirationSet = null;
|
||||
|
||||
$cacheItem = $this->createMock(CacheItemInterface::class);
|
||||
$cacheItem->method('set')->willReturnSelf();
|
||||
$cacheItem->method('expiresAfter')
|
||||
->willReturnCallback(static function ($ttl) use (&$expirationSet, $cacheItem) {
|
||||
$expirationSet = $ttl;
|
||||
|
||||
return $cacheItem;
|
||||
});
|
||||
|
||||
$cachePool = $this->createMock(CacheItemPoolInterface::class);
|
||||
$cachePool->method('getItem')->willReturn($cacheItem);
|
||||
$cachePool->method('save')->willReturn(true);
|
||||
|
||||
$repository = new CacheUserRepository($cachePool);
|
||||
|
||||
$user = User::creer(
|
||||
email: new Email('test@example.com'),
|
||||
role: Role::PARENT,
|
||||
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440001'),
|
||||
schoolName: 'École Test',
|
||||
dateNaissance: null,
|
||||
createdAt: new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
// Act
|
||||
$repository->save($user);
|
||||
|
||||
// Assert: No expiration should be set (expiresAfter should not be called with a TTL)
|
||||
// The users.cache pool is configured with default_lifetime: 0 (no expiration)
|
||||
// But CacheUserRepository should NOT explicitly set any TTL
|
||||
self::assertNull(
|
||||
$expirationSet,
|
||||
'User cache entries should not have explicit expiration set by the repository'
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function userCanBeRetrievedById(): void
|
||||
{
|
||||
// Arrange
|
||||
$userId = '550e8400-e29b-41d4-a716-446655440001';
|
||||
$email = 'test@example.com';
|
||||
$tenantId = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
$userData = [
|
||||
'id' => $userId,
|
||||
'email' => $email,
|
||||
'role' => 'ROLE_PARENT',
|
||||
'tenant_id' => $tenantId,
|
||||
'school_name' => 'École Test',
|
||||
'statut' => 'pending',
|
||||
'hashed_password' => null,
|
||||
'date_naissance' => null,
|
||||
'created_at' => '2026-01-15T10:00:00+00:00',
|
||||
'activated_at' => null,
|
||||
'consentement_parental' => null,
|
||||
];
|
||||
|
||||
$cacheItem = $this->createMock(CacheItemInterface::class);
|
||||
$cacheItem->method('isHit')->willReturn(true);
|
||||
$cacheItem->method('get')->willReturn($userData);
|
||||
|
||||
$cachePool = $this->createMock(CacheItemPoolInterface::class);
|
||||
$cachePool->method('getItem')->willReturn($cacheItem);
|
||||
|
||||
$repository = new CacheUserRepository($cachePool);
|
||||
|
||||
// Act
|
||||
$user = $repository->findById(\App\Administration\Domain\Model\User\UserId::fromString($userId));
|
||||
|
||||
// Assert
|
||||
self::assertNotNull($user);
|
||||
self::assertSame($userId, (string) $user->id);
|
||||
self::assertSame($email, (string) $user->email);
|
||||
self::assertSame(Role::PARENT, $user->role);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function userCanBeRetrievedByEmail(): void
|
||||
{
|
||||
// Arrange
|
||||
$userId = '550e8400-e29b-41d4-a716-446655440001';
|
||||
$email = 'test@example.com';
|
||||
$tenantId = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
$userData = [
|
||||
'id' => $userId,
|
||||
'email' => $email,
|
||||
'role' => 'ROLE_PARENT',
|
||||
'tenant_id' => $tenantId,
|
||||
'school_name' => 'École Test',
|
||||
'statut' => 'pending',
|
||||
'hashed_password' => null,
|
||||
'date_naissance' => null,
|
||||
'created_at' => '2026-01-15T10:00:00+00:00',
|
||||
'activated_at' => null,
|
||||
'consentement_parental' => null,
|
||||
];
|
||||
|
||||
$emailIndexItem = $this->createMock(CacheItemInterface::class);
|
||||
$emailIndexItem->method('isHit')->willReturn(true);
|
||||
$emailIndexItem->method('get')->willReturn($userId);
|
||||
|
||||
$userItem = $this->createMock(CacheItemInterface::class);
|
||||
$userItem->method('isHit')->willReturn(true);
|
||||
$userItem->method('get')->willReturn($userData);
|
||||
|
||||
$cachePool = $this->createMock(CacheItemPoolInterface::class);
|
||||
$cachePool->method('getItem')
|
||||
->willReturnCallback(static function ($key) use ($emailIndexItem, $userItem) {
|
||||
if (str_starts_with($key, 'user_email:')) {
|
||||
return $emailIndexItem;
|
||||
}
|
||||
|
||||
return $userItem;
|
||||
});
|
||||
|
||||
$repository = new CacheUserRepository($cachePool);
|
||||
|
||||
// Act
|
||||
$user = $repository->findByEmail(new Email($email));
|
||||
|
||||
// Assert
|
||||
self::assertNotNull($user);
|
||||
self::assertSame($userId, (string) $user->id);
|
||||
self::assertSame($email, (string) $user->email);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Exception\ActivationTokenNotFoundException;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationTokenId;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryActivationTokenRepository;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class InMemoryActivationTokenRepositoryTest extends TestCase
|
||||
{
|
||||
private InMemoryActivationTokenRepository $repository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryActivationTokenRepository();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function saveAndFindByTokenValue(): void
|
||||
{
|
||||
$token = $this->createToken();
|
||||
|
||||
$this->repository->save($token);
|
||||
$found = $this->repository->findByTokenValue($token->tokenValue);
|
||||
|
||||
self::assertSame($token, $found);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function saveAndGetById(): void
|
||||
{
|
||||
$token = $this->createToken();
|
||||
|
||||
$this->repository->save($token);
|
||||
$found = $this->repository->get($token->id);
|
||||
|
||||
self::assertSame($token, $found);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function findByTokenValueReturnsNullWhenNotFound(): void
|
||||
{
|
||||
$result = $this->repository->findByTokenValue('non-existent-token');
|
||||
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getThrowsExceptionWhenNotFound(): void
|
||||
{
|
||||
$this->expectException(ActivationTokenNotFoundException::class);
|
||||
|
||||
$this->repository->get(ActivationTokenId::generate());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function deleteRemovesToken(): void
|
||||
{
|
||||
$token = $this->createToken();
|
||||
$this->repository->save($token);
|
||||
|
||||
$this->repository->delete($token->id);
|
||||
|
||||
self::assertNull($this->repository->findByTokenValue($token->tokenValue));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function deleteRemovesTokenFromIdIndex(): void
|
||||
{
|
||||
$token = $this->createToken();
|
||||
$this->repository->save($token);
|
||||
|
||||
$this->repository->delete($token->id);
|
||||
|
||||
$this->expectException(ActivationTokenNotFoundException::class);
|
||||
$this->repository->get($token->id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function deleteNonExistentTokenDoesNotThrow(): void
|
||||
{
|
||||
$this->repository->delete(ActivationTokenId::generate());
|
||||
|
||||
$this->addToAssertionCount(1); // No exception thrown
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function saveUpdatesExistingToken(): void
|
||||
{
|
||||
$token = $this->createToken();
|
||||
$this->repository->save($token);
|
||||
|
||||
// Modify the token (mark as used)
|
||||
$usedAt = new DateTimeImmutable('2026-01-16 10:00:00');
|
||||
$token->use($usedAt);
|
||||
$this->repository->save($token);
|
||||
|
||||
$found = $this->repository->findByTokenValue($token->tokenValue);
|
||||
|
||||
self::assertTrue($found?->isUsed());
|
||||
}
|
||||
|
||||
private function createToken(): ActivationToken
|
||||
{
|
||||
return ActivationToken::generate(
|
||||
userId: '550e8400-e29b-41d4-a716-446655440001',
|
||||
email: 'user@example.com',
|
||||
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440002'),
|
||||
role: 'ROLE_PARENT',
|
||||
schoolName: 'École Alpha',
|
||||
createdAt: new DateTimeImmutable('2026-01-15 10:00:00'),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user