Files
Classeo/backend/tests/Unit/Administration/Infrastructure/Console/MigrateUsersToPostgresCommandTest.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

210 lines
7.0 KiB
PHP

<?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;
}
};
}
}