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.
This commit is contained in:
@@ -164,6 +164,11 @@ final class ActivateAccountProcessorTest extends TestCase
|
||||
{
|
||||
}
|
||||
|
||||
public function findById(UserId $id): ?User
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function findByEmail(Email $email, TenantId $tenantId): ?User
|
||||
{
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Console;
|
||||
|
||||
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\Console\MigrateUsersToPostgresCommand;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantRegistry;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
final class MigrateUsersToPostgresCommandTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ALPHA_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
private const string TENANT_BETA_ID = 'b2c3d4e5-f6a7-8901-bcde-f12345678901';
|
||||
|
||||
#[Test]
|
||||
public function migratesUsersFromSourceToTarget(): void
|
||||
{
|
||||
$source = new InMemoryUserRepository();
|
||||
$target = new InMemoryUserRepository();
|
||||
|
||||
$tenantAlpha = TenantId::fromString(self::TENANT_ALPHA_ID);
|
||||
|
||||
$user1 = $this->createUser('user1@example.com', $tenantAlpha);
|
||||
$user2 = $this->createUser('user2@example.com', $tenantAlpha);
|
||||
$source->save($user1);
|
||||
$source->save($user2);
|
||||
|
||||
$registry = $this->createRegistry([
|
||||
new TenantConfig($tenantAlpha, 'ecole-alpha', 'sqlite:///:memory:'),
|
||||
]);
|
||||
|
||||
$command = new MigrateUsersToPostgresCommand($source, $target, $registry);
|
||||
$tester = new CommandTester($command);
|
||||
$tester->execute([]);
|
||||
|
||||
self::assertSame(0, $tester->getStatusCode());
|
||||
self::assertCount(2, $target->findAllByTenant($tenantAlpha));
|
||||
self::assertStringContainsString('2 utilisateur(s) migré(s)', $tester->getDisplay());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isIdempotent(): void
|
||||
{
|
||||
$source = new InMemoryUserRepository();
|
||||
$target = new InMemoryUserRepository();
|
||||
|
||||
$tenantAlpha = TenantId::fromString(self::TENANT_ALPHA_ID);
|
||||
$user = $this->createUser('user@example.com', $tenantAlpha);
|
||||
$source->save($user);
|
||||
|
||||
$registry = $this->createRegistry([
|
||||
new TenantConfig($tenantAlpha, 'ecole-alpha', 'sqlite:///:memory:'),
|
||||
]);
|
||||
|
||||
$command = new MigrateUsersToPostgresCommand($source, $target, $registry);
|
||||
|
||||
// Run twice
|
||||
$tester = new CommandTester($command);
|
||||
$tester->execute([]);
|
||||
$tester->execute([]);
|
||||
|
||||
// Should still have exactly 1 user, not 2
|
||||
self::assertCount(1, $target->findAllByTenant($tenantAlpha));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handlesMultipleTenants(): void
|
||||
{
|
||||
$source = new InMemoryUserRepository();
|
||||
$target = new InMemoryUserRepository();
|
||||
|
||||
$tenantAlpha = TenantId::fromString(self::TENANT_ALPHA_ID);
|
||||
$tenantBeta = TenantId::fromString(self::TENANT_BETA_ID);
|
||||
|
||||
$source->save($this->createUser('alpha@example.com', $tenantAlpha));
|
||||
$source->save($this->createUser('beta@example.com', $tenantBeta));
|
||||
|
||||
$registry = $this->createRegistry([
|
||||
new TenantConfig($tenantAlpha, 'ecole-alpha', 'sqlite:///:memory:'),
|
||||
new TenantConfig($tenantBeta, 'ecole-beta', 'sqlite:///:memory:'),
|
||||
]);
|
||||
|
||||
$command = new MigrateUsersToPostgresCommand($source, $target, $registry);
|
||||
$tester = new CommandTester($command);
|
||||
$tester->execute([]);
|
||||
|
||||
self::assertSame(0, $tester->getStatusCode());
|
||||
self::assertCount(1, $target->findAllByTenant($tenantAlpha));
|
||||
self::assertCount(1, $target->findAllByTenant($tenantBeta));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handlesEmptyTenant(): void
|
||||
{
|
||||
$source = new InMemoryUserRepository();
|
||||
$target = new InMemoryUserRepository();
|
||||
|
||||
$tenantAlpha = TenantId::fromString(self::TENANT_ALPHA_ID);
|
||||
|
||||
$registry = $this->createRegistry([
|
||||
new TenantConfig($tenantAlpha, 'ecole-alpha', 'sqlite:///:memory:'),
|
||||
]);
|
||||
|
||||
$command = new MigrateUsersToPostgresCommand($source, $target, $registry);
|
||||
$tester = new CommandTester($command);
|
||||
$tester->execute([]);
|
||||
|
||||
self::assertSame(0, $tester->getStatusCode());
|
||||
self::assertStringContainsString('aucun utilisateur', $tester->getDisplay());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function preservesUserIds(): void
|
||||
{
|
||||
$source = new InMemoryUserRepository();
|
||||
$target = new InMemoryUserRepository();
|
||||
|
||||
$tenantAlpha = TenantId::fromString(self::TENANT_ALPHA_ID);
|
||||
$user = $this->createUser('user@example.com', $tenantAlpha);
|
||||
$originalId = (string) $user->id;
|
||||
$source->save($user);
|
||||
|
||||
$registry = $this->createRegistry([
|
||||
new TenantConfig($tenantAlpha, 'ecole-alpha', 'sqlite:///:memory:'),
|
||||
]);
|
||||
|
||||
$command = new MigrateUsersToPostgresCommand($source, $target, $registry);
|
||||
$tester = new CommandTester($command);
|
||||
$tester->execute([]);
|
||||
|
||||
$migratedUsers = $target->findAllByTenant($tenantAlpha);
|
||||
self::assertSame($originalId, (string) $migratedUsers[0]->id);
|
||||
}
|
||||
|
||||
private function createUser(string $email, TenantId $tenantId): User
|
||||
{
|
||||
return User::creer(
|
||||
email: new Email($email),
|
||||
role: Role::PARENT,
|
||||
tenantId: $tenantId,
|
||||
schoolName: 'École Test',
|
||||
dateNaissance: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15T10:00:00+00:00'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param TenantConfig[] $configs
|
||||
*/
|
||||
private function createRegistry(array $configs): TenantRegistry
|
||||
{
|
||||
return new class($configs) implements TenantRegistry {
|
||||
/** @param TenantConfig[] $configs */
|
||||
public function __construct(private readonly array $configs)
|
||||
{
|
||||
}
|
||||
|
||||
public function getConfig(TenantId $tenantId): TenantConfig
|
||||
{
|
||||
foreach ($this->configs as $config) {
|
||||
if ($config->tenantId->equals($tenantId)) {
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException('Tenant not found');
|
||||
}
|
||||
|
||||
public function getBySubdomain(string $subdomain): TenantConfig
|
||||
{
|
||||
foreach ($this->configs as $config) {
|
||||
if ($config->subdomain === $subdomain) {
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException('Tenant not found');
|
||||
}
|
||||
|
||||
public function exists(string $subdomain): bool
|
||||
{
|
||||
foreach ($this->configs as $config) {
|
||||
if ($config->subdomain === $subdomain) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return TenantConfig[] */
|
||||
public function getAllConfigs(): array
|
||||
{
|
||||
return $this->configs;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Persistence\Doctrine;
|
||||
|
||||
use App\Administration\Domain\Exception\UserNotFoundException;
|
||||
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\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Persistence\Doctrine\DoctrineUserRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class DoctrineUserRepositoryTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ALPHA_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
private const string TENANT_BETA_ID = 'b2c3d4e5-f6a7-8901-bcde-f12345678901';
|
||||
|
||||
#[Test]
|
||||
public function saveExecutesUpsertStatement(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->expects(self::once())
|
||||
->method('executeStatement')
|
||||
->with(
|
||||
self::stringContains('INSERT INTO users'),
|
||||
self::callback(static function (array $params): bool {
|
||||
return $params['email'] === 'test@example.com'
|
||||
&& $params['statut'] === 'pending'
|
||||
&& $params['school_name'] === 'École Test'
|
||||
&& str_contains($params['roles'], 'ROLE_PARENT');
|
||||
}),
|
||||
);
|
||||
|
||||
$repository = new DoctrineUserRepository($connection);
|
||||
|
||||
$user = 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'),
|
||||
);
|
||||
|
||||
$repository->save($user);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function findByIdReturnsUserWhenFound(): void
|
||||
{
|
||||
$userId = '550e8400-e29b-41d4-a716-446655440001';
|
||||
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('fetchAssociative')
|
||||
->willReturn($this->makeRow($userId));
|
||||
|
||||
$repository = new DoctrineUserRepository($connection);
|
||||
|
||||
$user = $repository->findById(UserId::fromString($userId));
|
||||
|
||||
self::assertNotNull($user);
|
||||
self::assertSame($userId, (string) $user->id);
|
||||
self::assertSame('test@example.com', (string) $user->email);
|
||||
self::assertSame(Role::PARENT, $user->role);
|
||||
self::assertSame('École Test', $user->schoolName);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function findByIdReturnsNullWhenNotFound(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('fetchAssociative')->willReturn(false);
|
||||
|
||||
$repository = new DoctrineUserRepository($connection);
|
||||
|
||||
$user = $repository->findById(UserId::fromString('550e8400-e29b-41d4-a716-446655440001'));
|
||||
|
||||
self::assertNull($user);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getThrowsWhenUserNotFound(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('fetchAssociative')->willReturn(false);
|
||||
|
||||
$repository = new DoctrineUserRepository($connection);
|
||||
|
||||
$this->expectException(UserNotFoundException::class);
|
||||
|
||||
$repository->get(UserId::fromString('550e8400-e29b-41d4-a716-446655440001'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function findByEmailReturnsUserWhenFound(): void
|
||||
{
|
||||
$userId = '550e8400-e29b-41d4-a716-446655440001';
|
||||
$tenantId = TenantId::fromString(self::TENANT_ALPHA_ID);
|
||||
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('fetchAssociative')
|
||||
->with(
|
||||
self::stringContains('tenant_id = :tenant_id AND email = :email'),
|
||||
self::callback(static fn (array $params) => $params['email'] === 'test@example.com'
|
||||
&& $params['tenant_id'] === self::TENANT_ALPHA_ID),
|
||||
)
|
||||
->willReturn($this->makeRow($userId));
|
||||
|
||||
$repository = new DoctrineUserRepository($connection);
|
||||
|
||||
$user = $repository->findByEmail(new Email('test@example.com'), $tenantId);
|
||||
|
||||
self::assertNotNull($user);
|
||||
self::assertSame($userId, (string) $user->id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function findByEmailReturnsNullForDifferentTenant(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('fetchAssociative')->willReturn(false);
|
||||
|
||||
$repository = new DoctrineUserRepository($connection);
|
||||
|
||||
$user = $repository->findByEmail(
|
||||
new Email('test@example.com'),
|
||||
TenantId::fromString(self::TENANT_BETA_ID),
|
||||
);
|
||||
|
||||
self::assertNull($user);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function findAllByTenantReturnsUsersForTenant(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('fetchAllAssociative')
|
||||
->willReturn([
|
||||
$this->makeRow('550e8400-e29b-41d4-a716-446655440001'),
|
||||
$this->makeRow('550e8400-e29b-41d4-a716-446655440002', 'other@example.com'),
|
||||
]);
|
||||
|
||||
$repository = new DoctrineUserRepository($connection);
|
||||
|
||||
$users = $repository->findAllByTenant(TenantId::fromString(self::TENANT_ALPHA_ID));
|
||||
|
||||
self::assertCount(2, $users);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function hydrateHandlesConsentementParental(): void
|
||||
{
|
||||
$userId = '550e8400-e29b-41d4-a716-446655440001';
|
||||
$row = $this->makeRow($userId);
|
||||
$row['consentement_parent_id'] = '660e8400-e29b-41d4-a716-446655440001';
|
||||
$row['consentement_eleve_id'] = '770e8400-e29b-41d4-a716-446655440001';
|
||||
$row['consentement_date'] = '2026-01-20T14:00:00+00:00';
|
||||
$row['consentement_ip'] = '192.168.1.1';
|
||||
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('fetchAssociative')->willReturn($row);
|
||||
|
||||
$repository = new DoctrineUserRepository($connection);
|
||||
|
||||
$user = $repository->findById(UserId::fromString($userId));
|
||||
|
||||
self::assertNotNull($user);
|
||||
self::assertNotNull($user->consentementParental);
|
||||
self::assertSame('660e8400-e29b-41d4-a716-446655440001', $user->consentementParental->parentId);
|
||||
self::assertSame('770e8400-e29b-41d4-a716-446655440001', $user->consentementParental->eleveId);
|
||||
self::assertSame('192.168.1.1', $user->consentementParental->ipAddress);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function hydrateHandlesMultipleRoles(): void
|
||||
{
|
||||
$userId = '550e8400-e29b-41d4-a716-446655440001';
|
||||
$row = $this->makeRow($userId);
|
||||
$row['roles'] = '["ROLE_PROF", "ROLE_ADMIN"]';
|
||||
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('fetchAssociative')->willReturn($row);
|
||||
|
||||
$repository = new DoctrineUserRepository($connection);
|
||||
|
||||
$user = $repository->findById(UserId::fromString($userId));
|
||||
|
||||
self::assertNotNull($user);
|
||||
self::assertCount(2, $user->roles);
|
||||
self::assertSame(Role::PROF, $user->roles[0]);
|
||||
self::assertSame(Role::ADMIN, $user->roles[1]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function hydrateHandlesBlockedUser(): void
|
||||
{
|
||||
$userId = '550e8400-e29b-41d4-a716-446655440001';
|
||||
$row = $this->makeRow($userId);
|
||||
$row['statut'] = StatutCompte::SUSPENDU->value;
|
||||
$row['blocked_at'] = '2026-01-20T14:00:00+00:00';
|
||||
$row['blocked_reason'] = 'Comportement inapproprié';
|
||||
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('fetchAssociative')->willReturn($row);
|
||||
|
||||
$repository = new DoctrineUserRepository($connection);
|
||||
|
||||
$user = $repository->findById(UserId::fromString($userId));
|
||||
|
||||
self::assertNotNull($user);
|
||||
self::assertSame(StatutCompte::SUSPENDU, $user->statut);
|
||||
self::assertNotNull($user->blockedAt);
|
||||
self::assertSame('Comportement inapproprié', $user->blockedReason);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function savePreservesConsentementParental(): void
|
||||
{
|
||||
$consentement = ConsentementParental::accorder(
|
||||
parentId: '660e8400-e29b-41d4-a716-446655440001',
|
||||
eleveId: '770e8400-e29b-41d4-a716-446655440001',
|
||||
at: new DateTimeImmutable('2026-01-20T14:00:00+00:00'),
|
||||
ipAddress: '192.168.1.1',
|
||||
);
|
||||
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->expects(self::once())
|
||||
->method('executeStatement')
|
||||
->with(
|
||||
self::anything(),
|
||||
self::callback(static function (array $params) {
|
||||
return $params['consentement_parent_id'] === '660e8400-e29b-41d4-a716-446655440001'
|
||||
&& $params['consentement_eleve_id'] === '770e8400-e29b-41d4-a716-446655440001'
|
||||
&& $params['consentement_ip'] === '192.168.1.1';
|
||||
}),
|
||||
);
|
||||
|
||||
$repository = new DoctrineUserRepository($connection);
|
||||
|
||||
$user = User::reconstitute(
|
||||
id: UserId::fromString('550e8400-e29b-41d4-a716-446655440001'),
|
||||
email: new Email('minor@example.com'),
|
||||
roles: [Role::ELEVE],
|
||||
tenantId: TenantId::fromString(self::TENANT_ALPHA_ID),
|
||||
schoolName: 'École Test',
|
||||
statut: StatutCompte::EN_ATTENTE,
|
||||
dateNaissance: new DateTimeImmutable('2015-05-15'),
|
||||
createdAt: new DateTimeImmutable('2026-01-15T10:00:00+00:00'),
|
||||
hashedPassword: null,
|
||||
activatedAt: null,
|
||||
consentementParental: $consentement,
|
||||
);
|
||||
|
||||
$repository->save($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function makeRow(string $userId, string $email = 'test@example.com'): array
|
||||
{
|
||||
return [
|
||||
'id' => $userId,
|
||||
'tenant_id' => self::TENANT_ALPHA_ID,
|
||||
'email' => $email,
|
||||
'first_name' => 'Jean',
|
||||
'last_name' => 'Dupont',
|
||||
'roles' => '["ROLE_PARENT"]',
|
||||
'hashed_password' => null,
|
||||
'statut' => 'pending',
|
||||
'school_name' => 'École Test',
|
||||
'date_naissance' => null,
|
||||
'created_at' => '2026-01-15T10:00:00+00:00',
|
||||
'activated_at' => null,
|
||||
'invited_at' => null,
|
||||
'blocked_at' => null,
|
||||
'blocked_reason' => null,
|
||||
'consentement_parent_id' => null,
|
||||
'consentement_eleve_id' => null,
|
||||
'consentement_date' => null,
|
||||
'consentement_ip' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user