Files
Classeo/backend/tests/Unit/Administration/Infrastructure/Persistence/Cache/CachedUserRepositoryTest.php
Mathias STRASSER a0e19627a7 feat: Persister les utilisateurs en PostgreSQL avec cache-aside Redis
Les utilisateurs étaient stockés uniquement dans Redis (CacheUserRepository),
ce qui exposait à une perte totale des comptes en cas de restart Redis,
FLUSHDB ou perte du volume Docker. Les tables student_guardians et
teacher_assignments référençaient des user IDs sans FK réelle.

PostgreSQL devient la source de vérité via DoctrineUserRepository (DBAL,
upsert ON CONFLICT). CachedUserRepository décore l'interface existante
avec le pattern cache-aside : lectures Redis d'abord → miss → PostgreSQL
→ populate Redis ; écritures PostgreSQL d'abord → mise à jour Redis.
Si Redis est indisponible, l'application continue via PostgreSQL seul.

Une commande de migration (app:migrate-users-to-postgres) permet de copier
les données Redis existantes vers PostgreSQL de manière idempotente.
2026-02-15 16:45:24 +01:00

376 lines
13 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\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\UserRepository;
use App\Administration\Infrastructure\Persistence\Cache\CachedUserRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use RuntimeException;
final class CachedUserRepositoryTest extends TestCase
{
private const string TENANT_ALPHA_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
#[Test]
public function savePersistsToDoctrineFirst(): void
{
$user = $this->createTestUser();
$doctrine = $this->createMock(UserRepository::class);
$doctrine->expects(self::once())->method('save')->with($user);
$cache = $this->createStubCachePool();
$repository = new CachedUserRepository($doctrine, $cache);
$repository->save($user);
}
#[Test]
public function saveUpdatesRedisAfterDoctrine(): void
{
$user = $this->createTestUser();
$savedKeys = [];
$cacheItem = $this->createMock(CacheItemInterface::class);
$cacheItem->method('set')->willReturnSelf();
$cacheItem->method('isHit')->willReturn(false);
$cacheItem->method('get')->willReturn([]);
$cache = $this->createMock(CacheItemPoolInterface::class);
$cache->method('getItem')
->willReturnCallback(static function (string $key) use (&$savedKeys, $cacheItem) {
$savedKeys[] = $key;
return $cacheItem;
});
$cache->method('save')->willReturn(true);
$doctrine = $this->createMock(UserRepository::class);
$repository = new CachedUserRepository($doctrine, $cache);
$repository->save($user);
self::assertNotEmpty($savedKeys);
self::assertContains('user:' . $user->id, $savedKeys);
}
#[Test]
public function findByIdReturnsCachedUserOnHit(): void
{
$userId = UserId::fromString('550e8400-e29b-41d4-a716-446655440001');
$userData = $this->makeSerializedUser('550e8400-e29b-41d4-a716-446655440001');
$cacheItem = $this->createMock(CacheItemInterface::class);
$cacheItem->method('isHit')->willReturn(true);
$cacheItem->method('get')->willReturn($userData);
$cache = $this->createMock(CacheItemPoolInterface::class);
$cache->method('getItem')->willReturn($cacheItem);
$doctrine = $this->createMock(UserRepository::class);
// Doctrine should NOT be called on cache hit
$doctrine->expects(self::never())->method('findById');
$repository = new CachedUserRepository($doctrine, $cache);
$user = $repository->findById($userId);
self::assertNotNull($user);
self::assertSame('550e8400-e29b-41d4-a716-446655440001', (string) $user->id);
}
#[Test]
public function findByIdFallsBackToDoctrineOnCacheMiss(): void
{
$userId = UserId::fromString('550e8400-e29b-41d4-a716-446655440001');
$missItem = $this->createMock(CacheItemInterface::class);
$missItem->method('isHit')->willReturn(false);
$missItem->method('set')->willReturnSelf();
$cache = $this->createMock(CacheItemPoolInterface::class);
$cache->method('getItem')->willReturn($missItem);
$cache->method('save')->willReturn(true);
$expectedUser = $this->createTestUser();
$doctrine = $this->createMock(UserRepository::class);
$doctrine->expects(self::once())->method('findById')->willReturn($expectedUser);
$repository = new CachedUserRepository($doctrine, $cache);
$user = $repository->findById($userId);
self::assertNotNull($user);
}
#[Test]
public function findByIdFallsBackToDoctrineWhenRedisUnavailable(): void
{
$userId = UserId::fromString('550e8400-e29b-41d4-a716-446655440001');
$cache = $this->createMock(CacheItemPoolInterface::class);
$cache->method('getItem')->willThrowException(new RuntimeException('Redis connection refused'));
$expectedUser = $this->createTestUser();
$doctrine = $this->createMock(UserRepository::class);
$doctrine->expects(self::once())->method('findById')->willReturn($expectedUser);
$repository = new CachedUserRepository($doctrine, $cache);
$user = $repository->findById($userId);
self::assertNotNull($user);
}
#[Test]
public function saveSucceedsEvenWhenRedisIsDown(): void
{
$user = $this->createTestUser();
$cache = $this->createMock(CacheItemPoolInterface::class);
$cache->method('getItem')->willThrowException(new RuntimeException('Redis down'));
$doctrine = $this->createMock(UserRepository::class);
$doctrine->expects(self::once())->method('save')->with($user);
$repository = new CachedUserRepository($doctrine, $cache);
// Should not throw despite Redis being down
$repository->save($user);
}
#[Test]
public function findByEmailUsesEmailIndexFromCache(): void
{
$tenantId = TenantId::fromString(self::TENANT_ALPHA_ID);
$email = new Email('test@example.com');
$userId = '550e8400-e29b-41d4-a716-446655440001';
$userData = $this->makeSerializedUser($userId);
$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);
$cache = $this->createMock(CacheItemPoolInterface::class);
$cache->method('getItem')
->willReturnCallback(static function (string $key) use ($emailIndexItem, $userItem, $tenantId) {
$expectedEmailKey = 'user_email:' . $tenantId . ':test_at_example_dot_com';
if ($key === $expectedEmailKey) {
return $emailIndexItem;
}
return $userItem;
});
$doctrine = $this->createMock(UserRepository::class);
$doctrine->expects(self::never())->method('findByEmail');
$repository = new CachedUserRepository($doctrine, $cache);
$user = $repository->findByEmail($email, $tenantId);
self::assertNotNull($user);
}
#[Test]
public function findByEmailFallsBackToDoctrineOnCacheMiss(): void
{
$tenantId = TenantId::fromString(self::TENANT_ALPHA_ID);
$email = new Email('test@example.com');
$missItem = $this->createMock(CacheItemInterface::class);
$missItem->method('isHit')->willReturn(false);
$missItem->method('set')->willReturnSelf();
$cache = $this->createMock(CacheItemPoolInterface::class);
$cache->method('getItem')->willReturn($missItem);
$cache->method('save')->willReturn(true);
$expectedUser = $this->createTestUser();
$doctrine = $this->createMock(UserRepository::class);
$doctrine->expects(self::once())->method('findByEmail')->willReturn($expectedUser);
$repository = new CachedUserRepository($doctrine, $cache);
$user = $repository->findByEmail($email, $tenantId);
self::assertNotNull($user);
}
#[Test]
public function findAllByTenantAlwaysGoesToDoctrine(): void
{
$tenantId = TenantId::fromString(self::TENANT_ALPHA_ID);
$cache = $this->createStubCachePool();
$doctrine = $this->createMock(UserRepository::class);
$doctrine->expects(self::once())
->method('findAllByTenant')
->with($tenantId)
->willReturn([$this->createTestUser()]);
$repository = new CachedUserRepository($doctrine, $cache);
$users = $repository->findAllByTenant($tenantId);
self::assertCount(1, $users);
}
#[Test]
public function saveInvalidatesOldEmailIndexOnEmailChange(): void
{
$userId = '550e8400-e29b-41d4-a716-446655440001';
$oldEmail = 'old@example.com';
$newEmail = 'new@example.com';
$oldSerializedUser = $this->makeSerializedUser($userId);
$oldSerializedUser['email'] = $oldEmail;
$user = User::reconstitute(
id: UserId::fromString($userId),
email: new Email($newEmail),
roles: [Role::PARENT],
tenantId: TenantId::fromString(self::TENANT_ALPHA_ID),
schoolName: 'École Test',
statut: \App\Administration\Domain\Model\User\StatutCompte::EN_ATTENTE,
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-15T10:00:00+00:00'),
hashedPassword: null,
activatedAt: null,
consentementParental: null,
firstName: '',
lastName: '',
);
$existingItem = $this->createMock(CacheItemInterface::class);
$existingItem->method('isHit')->willReturn(true);
$existingItem->method('get')->willReturn($oldSerializedUser);
$existingItem->method('set')->willReturnSelf();
$otherItem = $this->createMock(CacheItemInterface::class);
$otherItem->method('isHit')->willReturn(false);
$otherItem->method('get')->willReturn([]);
$otherItem->method('set')->willReturnSelf();
$deletedKeys = [];
$cache = $this->createMock(CacheItemPoolInterface::class);
$cache->method('getItem')
->willReturnCallback(static function (string $key) use ($existingItem, $otherItem, $userId) {
if ($key === 'user:' . $userId) {
return $existingItem;
}
return $otherItem;
});
$cache->method('save')->willReturn(true);
$cache->method('deleteItem')
->willReturnCallback(static function (string $key) use (&$deletedKeys) {
$deletedKeys[] = $key;
return true;
});
$doctrine = $this->createMock(UserRepository::class);
$repository = new CachedUserRepository($doctrine, $cache);
$repository->save($user);
// The old email index should have been deleted
self::assertNotEmpty($deletedKeys, 'Old email index should be invalidated');
self::assertStringContainsString('user_email:', $deletedKeys[0]);
self::assertStringContainsString('old_at_example_dot_com', $deletedKeys[0]);
}
#[Test]
public function deserializeHandlesLegacySingleRoleFormat(): void
{
$userId = UserId::fromString('550e8400-e29b-41d4-a716-446655440001');
// Legacy format: 'role' key instead of 'roles'
$legacyData = $this->makeSerializedUser((string) $userId);
unset($legacyData['roles']);
$legacyData['role'] = 'ROLE_PROF';
$cacheItem = $this->createMock(CacheItemInterface::class);
$cacheItem->method('isHit')->willReturn(true);
$cacheItem->method('get')->willReturn($legacyData);
$cache = $this->createMock(CacheItemPoolInterface::class);
$cache->method('getItem')->willReturn($cacheItem);
$doctrine = $this->createMock(UserRepository::class);
$repository = new CachedUserRepository($doctrine, $cache);
$user = $repository->findById($userId);
self::assertNotNull($user);
self::assertCount(1, $user->roles);
self::assertSame(Role::PROF, $user->roles[0]);
}
private function createTestUser(): User
{
return User::creer(
email: new Email('test@example.com'),
role: Role::PARENT,
tenantId: TenantId::fromString(self::TENANT_ALPHA_ID),
schoolName: 'École Test',
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-15T10:00:00+00:00'),
);
}
/**
* @return array<string, mixed>
*/
private function makeSerializedUser(string $userId): array
{
return [
'id' => $userId,
'email' => 'test@example.com',
'roles' => ['ROLE_PARENT'],
'tenant_id' => self::TENANT_ALPHA_ID,
'school_name' => 'École Test',
'statut' => 'pending',
'hashed_password' => null,
'date_naissance' => null,
'created_at' => '2026-01-15T10:00:00+00:00',
'activated_at' => null,
'first_name' => '',
'last_name' => '',
'invited_at' => null,
'blocked_at' => null,
'blocked_reason' => null,
'consentement_parental' => null,
];
}
private function createStubCachePool(): CacheItemPoolInterface
{
$cacheItem = $this->createMock(CacheItemInterface::class);
$cacheItem->method('set')->willReturnSelf();
$cacheItem->method('isHit')->willReturn(false);
$cacheItem->method('get')->willReturn([]);
$cache = $this->createMock(CacheItemPoolInterface::class);
$cache->method('getItem')->willReturn($cacheItem);
$cache->method('save')->willReturn(true);
return $cache;
}
}