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
161 lines
5.5 KiB
PHP
161 lines
5.5 KiB
PHP
<?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);
|
|
}
|
|
}
|