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.
210 lines
7.0 KiB
PHP
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;
|
|
}
|
|
};
|
|
}
|
|
}
|