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:
2026-02-15 14:39:17 +01:00
parent 76e16db0d8
commit a0e19627a7
15 changed files with 1581 additions and 4 deletions

View File

@@ -67,8 +67,17 @@ services:
App\Administration\Domain\Repository\ActivationTokenRepository:
alias: App\Administration\Infrastructure\Persistence\Redis\RedisActivationTokenRepository
App\Administration\Infrastructure\Persistence\Cache\CachedUserRepository:
arguments:
$inner: '@App\Administration\Infrastructure\Persistence\Doctrine\DoctrineUserRepository'
App\Administration\Domain\Repository\UserRepository:
alias: App\Administration\Infrastructure\Persistence\Cache\CacheUserRepository
alias: App\Administration\Infrastructure\Persistence\Cache\CachedUserRepository
App\Administration\Infrastructure\Console\MigrateUsersToPostgresCommand:
arguments:
$source: '@App\Administration\Infrastructure\Persistence\Cache\CacheUserRepository'
$target: '@App\Administration\Infrastructure\Persistence\Doctrine\DoctrineUserRepository'
App\Administration\Application\Port\PasswordHasher:
alias: App\Administration\Infrastructure\Security\SymfonyPasswordHasher

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Creates the users table in PostgreSQL.
*
* Users were previously stored only in Redis (CacheUserRepository).
* This migration establishes PostgreSQL as the source of truth,
* enabling referential integrity, SQL queries, and data durability.
*/
final class Version20260214100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create users table in PostgreSQL for durable user storage';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE users (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
email VARCHAR(255) NOT NULL,
first_name VARCHAR(100) NOT NULL DEFAULT '',
last_name VARCHAR(100) NOT NULL DEFAULT '',
roles JSONB NOT NULL DEFAULT '[]',
hashed_password TEXT,
statut VARCHAR(30) NOT NULL DEFAULT 'pending',
school_name VARCHAR(255) NOT NULL DEFAULT '',
date_naissance DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
activated_at TIMESTAMPTZ,
invited_at TIMESTAMPTZ,
blocked_at TIMESTAMPTZ,
blocked_reason TEXT,
consentement_parent_id UUID,
consentement_eleve_id UUID,
consentement_date TIMESTAMPTZ,
consentement_ip VARCHAR(45),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, email)
)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX idx_users_tenant ON users(tenant_id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX idx_users_tenant_statut ON users(tenant_id, statut)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX idx_users_created_at ON users(created_at)
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
DROP TABLE IF EXISTS users
SQL);
}
}

View File

@@ -18,6 +18,8 @@ interface UserRepository
*/
public function get(UserId $id): User;
public function findById(UserId $id): ?User;
/**
* Finds a user by email within a specific tenant.
* Returns null if user doesn't exist in that tenant.

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Console;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Infrastructure\Tenant\TenantRegistry;
use function count;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Migrates existing users from Redis cache to PostgreSQL.
*
* This command reads all users stored in Redis (via CacheUserRepository)
* and persists them to PostgreSQL (via DoctrineUserRepository).
* It is idempotent: re-running it will upsert existing records.
*/
#[AsCommand(
name: 'app:migrate-users-to-postgres',
description: 'Migrate existing users from Redis cache to PostgreSQL',
)]
final class MigrateUsersToPostgresCommand extends Command
{
public function __construct(
private readonly UserRepository $source,
private readonly UserRepository $target,
private readonly TenantRegistry $tenantRegistry,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Migration des utilisateurs Redis → PostgreSQL');
$tenants = $this->tenantRegistry->getAllConfigs();
$totalMigrated = 0;
foreach ($tenants as $tenant) {
$tenantId = $tenant->tenantId;
$users = $this->source->findAllByTenant($tenantId);
if ($users === []) {
$io->text("Tenant {$tenant->subdomain}: aucun utilisateur");
continue;
}
foreach ($users as $user) {
$this->target->save($user);
++$totalMigrated;
}
$io->text("Tenant {$tenant->subdomain}: " . count($users) . ' utilisateur(s) migré(s)');
}
$io->success("Migration terminée : {$totalMigrated} utilisateur(s) migré(s) vers PostgreSQL");
return Command::SUCCESS;
}
}

View File

@@ -17,6 +17,7 @@ use DateTimeImmutable;
use function in_array;
use Override;
use Psr\Cache\CacheItemPoolInterface;
use RuntimeException;
@@ -66,6 +67,7 @@ final readonly class CacheUserRepository implements UserRepository
$this->usersCache->save($tenantItem);
}
#[Override]
public function findById(UserId $id): ?User
{
$item = $this->usersCache->getItem(self::KEY_PREFIX . $id);

View File

@@ -0,0 +1,289 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Cache;
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\Domain\Repository\UserRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Psr\Cache\CacheItemPoolInterface;
use Throwable;
/**
* Cache-aside decorator around DoctrineUserRepository.
*
* Reads: Redis first, miss → PostgreSQL → populate Redis.
* Writes: PostgreSQL first (source of truth) → update Redis.
* If Redis is unavailable, falls back to PostgreSQL silently.
*/
final readonly class CachedUserRepository implements UserRepository
{
private const string KEY_PREFIX = 'user:';
private const string EMAIL_INDEX_PREFIX = 'user_email:';
public function __construct(
private UserRepository $inner,
private CacheItemPoolInterface $usersCache,
) {
}
#[Override]
public function save(User $user): void
{
// 1. PostgreSQL first (source of truth)
$this->inner->save($user);
// 2. Update Redis cache
try {
// Invalidate stale email index if the email changed
$existingItem = $this->usersCache->getItem(self::KEY_PREFIX . $user->id);
if ($existingItem->isHit()) {
/** @var array<string, mixed> $oldData */
$oldData = $existingItem->get();
/** @var string $oldEmail */
$oldEmail = $oldData['email'] ?? '';
if ($oldEmail !== '' && $oldEmail !== (string) $user->email) {
/** @var string $oldTenantId */
$oldTenantId = $oldData['tenant_id'] ?? (string) $user->tenantId;
$oldEmailKey = $this->emailIndexKey(
new Email($oldEmail),
TenantId::fromString($oldTenantId),
);
$this->usersCache->deleteItem($oldEmailKey);
}
}
$existingItem->set($this->serialize($user));
$this->usersCache->save($existingItem);
// Email index
$emailKey = $this->emailIndexKey($user->email, $user->tenantId);
$emailItem = $this->usersCache->getItem($emailKey);
$emailItem->set((string) $user->id);
$this->usersCache->save($emailItem);
} catch (Throwable) {
// Redis unavailable — PostgreSQL write succeeded, data is safe
}
}
#[Override]
public function get(UserId $id): User
{
$user = $this->findById($id);
if ($user === null) {
throw UserNotFoundException::withId($id);
}
return $user;
}
#[Override]
public function findById(UserId $id): ?User
{
// 1. Try Redis
try {
$item = $this->usersCache->getItem(self::KEY_PREFIX . $id);
if ($item->isHit()) {
/** @var array<string, mixed> $data */
$data = $item->get();
return $this->deserialize($data);
}
} catch (Throwable) {
// Redis unavailable, continue to PostgreSQL
}
// 2. Fallback PostgreSQL
$user = $this->inner->findById($id);
// 3. Populate cache
if ($user !== null) {
$this->populateCache($user);
}
return $user;
}
#[Override]
public function findByEmail(Email $email, TenantId $tenantId): ?User
{
// 1. Try Redis email index
try {
$emailKey = $this->emailIndexKey($email, $tenantId);
$emailItem = $this->usersCache->getItem($emailKey);
if ($emailItem->isHit()) {
/** @var string $userId */
$userId = $emailItem->get();
return $this->findById(UserId::fromString($userId));
}
} catch (Throwable) {
// Redis unavailable, continue to PostgreSQL
}
// 2. Fallback PostgreSQL
$user = $this->inner->findByEmail($email, $tenantId);
// 3. Populate cache
if ($user !== null) {
$this->populateCache($user);
}
return $user;
}
#[Override]
public function findAllByTenant(TenantId $tenantId): array
{
// Always go to PostgreSQL for full list (cache may be incomplete)
$users = $this->inner->findAllByTenant($tenantId);
// Populate cache for each user
foreach ($users as $user) {
$this->populateCache($user);
}
return $users;
}
private function populateCache(User $user): void
{
try {
$item = $this->usersCache->getItem(self::KEY_PREFIX . $user->id);
$item->set($this->serialize($user));
$this->usersCache->save($item);
// Email index
$emailKey = $this->emailIndexKey($user->email, $user->tenantId);
$emailItem = $this->usersCache->getItem($emailKey);
$emailItem->set((string) $user->id);
$this->usersCache->save($emailItem);
} catch (Throwable) {
// Redis unavailable
}
}
/**
* @return array<string, mixed>
*/
private function serialize(User $user): array
{
$consentement = $user->consentementParental;
return [
'id' => (string) $user->id,
'email' => (string) $user->email,
'roles' => array_map(static fn (Role $r) => $r->value, $user->roles),
'tenant_id' => (string) $user->tenantId,
'school_name' => $user->schoolName,
'statut' => $user->statut->value,
'hashed_password' => $user->hashedPassword,
'date_naissance' => $user->dateNaissance?->format('Y-m-d'),
'created_at' => $user->createdAt->format('c'),
'activated_at' => $user->activatedAt?->format('c'),
'first_name' => $user->firstName,
'last_name' => $user->lastName,
'invited_at' => $user->invitedAt?->format('c'),
'blocked_at' => $user->blockedAt?->format('c'),
'blocked_reason' => $user->blockedReason,
'consentement_parental' => $consentement !== null ? [
'parent_id' => $consentement->parentId,
'eleve_id' => $consentement->eleveId,
'date_consentement' => $consentement->dateConsentement->format('c'),
'ip_address' => $consentement->ipAddress,
] : null,
];
}
/**
* @param array<string, mixed> $data
*/
private function deserialize(array $data): User
{
/** @var string $id */
$id = $data['id'];
/** @var string $email */
$email = $data['email'];
// Support both legacy single role ('role') and multi-role ('roles') format
/** @var string[] $roleStrings */
$roleStrings = $data['roles'] ?? (isset($data['role']) ? [$data['role']] : []);
/** @var string $tenantId */
$tenantId = $data['tenant_id'];
/** @var string $schoolName */
$schoolName = $data['school_name'];
/** @var string $statut */
$statut = $data['statut'];
/** @var string|null $hashedPassword */
$hashedPassword = $data['hashed_password'];
/** @var string|null $dateNaissance */
$dateNaissance = $data['date_naissance'];
/** @var string $createdAt */
$createdAt = $data['created_at'];
/** @var string|null $activatedAt */
$activatedAt = $data['activated_at'];
/** @var string $firstName */
$firstName = $data['first_name'] ?? '';
/** @var string $lastName */
$lastName = $data['last_name'] ?? '';
/** @var string|null $invitedAt */
$invitedAt = $data['invited_at'] ?? null;
/** @var string|null $blockedAt */
$blockedAt = $data['blocked_at'] ?? null;
/** @var string|null $blockedReason */
$blockedReason = $data['blocked_reason'] ?? null;
$roles = array_map(static fn (string $r) => Role::from($r), $roleStrings);
$consentement = null;
/** @var array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null $consentementData */
$consentementData = $data['consentement_parental'] ?? null;
if ($consentementData !== null) {
$consentement = ConsentementParental::accorder(
parentId: $consentementData['parent_id'],
eleveId: $consentementData['eleve_id'],
at: new DateTimeImmutable($consentementData['date_consentement']),
ipAddress: $consentementData['ip_address'],
);
}
return User::reconstitute(
id: UserId::fromString($id),
email: new Email($email),
roles: $roles,
tenantId: TenantId::fromString($tenantId),
schoolName: $schoolName,
statut: StatutCompte::from($statut),
dateNaissance: $dateNaissance !== null ? new DateTimeImmutable($dateNaissance) : null,
createdAt: new DateTimeImmutable($createdAt),
hashedPassword: $hashedPassword,
activatedAt: $activatedAt !== null ? new DateTimeImmutable($activatedAt) : null,
consentementParental: $consentement,
firstName: $firstName,
lastName: $lastName,
invitedAt: $invitedAt !== null ? new DateTimeImmutable($invitedAt) : null,
blockedAt: $blockedAt !== null ? new DateTimeImmutable($blockedAt) : null,
blockedReason: $blockedReason,
);
}
private function normalizeEmail(Email $email): string
{
return strtolower(str_replace(['@', '.'], ['_at_', '_dot_'], (string) $email));
}
private function emailIndexKey(Email $email, TenantId $tenantId): string
{
return self::EMAIL_INDEX_PREFIX . $tenantId . ':' . $this->normalizeEmail($email);
}
}

View File

@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace App\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\Domain\Repository\UserRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use function is_array;
use Override;
use RuntimeException;
use function sprintf;
final readonly class DoctrineUserRepository implements UserRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function save(User $user): void
{
$consentement = $user->consentementParental;
$this->connection->executeStatement(
<<<'SQL'
INSERT INTO users (
id, tenant_id, email, first_name, last_name, roles,
hashed_password, statut, school_name, date_naissance,
created_at, activated_at, invited_at, blocked_at, blocked_reason,
consentement_parent_id, consentement_eleve_id, consentement_date, consentement_ip,
updated_at
)
VALUES (
:id, :tenant_id, :email, :first_name, :last_name, :roles,
:hashed_password, :statut, :school_name, :date_naissance,
:created_at, :activated_at, :invited_at, :blocked_at, :blocked_reason,
:consentement_parent_id, :consentement_eleve_id, :consentement_date, :consentement_ip,
NOW()
)
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
roles = EXCLUDED.roles,
hashed_password = EXCLUDED.hashed_password,
statut = EXCLUDED.statut,
school_name = EXCLUDED.school_name,
date_naissance = EXCLUDED.date_naissance,
activated_at = EXCLUDED.activated_at,
invited_at = EXCLUDED.invited_at,
blocked_at = EXCLUDED.blocked_at,
blocked_reason = EXCLUDED.blocked_reason,
consentement_parent_id = EXCLUDED.consentement_parent_id,
consentement_eleve_id = EXCLUDED.consentement_eleve_id,
consentement_date = EXCLUDED.consentement_date,
consentement_ip = EXCLUDED.consentement_ip,
updated_at = NOW()
SQL,
[
'id' => (string) $user->id,
'tenant_id' => (string) $user->tenantId,
'email' => (string) $user->email,
'first_name' => $user->firstName,
'last_name' => $user->lastName,
'roles' => json_encode(array_map(static fn (Role $r) => $r->value, $user->roles)),
'hashed_password' => $user->hashedPassword,
'statut' => $user->statut->value,
'school_name' => $user->schoolName,
'date_naissance' => $user->dateNaissance?->format('Y-m-d'),
'created_at' => $user->createdAt->format(DateTimeImmutable::ATOM),
'activated_at' => $user->activatedAt?->format(DateTimeImmutable::ATOM),
'invited_at' => $user->invitedAt?->format(DateTimeImmutable::ATOM),
'blocked_at' => $user->blockedAt?->format(DateTimeImmutable::ATOM),
'blocked_reason' => $user->blockedReason,
'consentement_parent_id' => $consentement?->parentId,
'consentement_eleve_id' => $consentement?->eleveId,
'consentement_date' => $consentement?->dateConsentement->format(DateTimeImmutable::ATOM),
'consentement_ip' => $consentement?->ipAddress,
],
);
}
#[Override]
public function get(UserId $id): User
{
$user = $this->findById($id);
if ($user === null) {
throw UserNotFoundException::withId($id);
}
return $user;
}
#[Override]
public function findById(UserId $id): ?User
{
$row = $this->connection->fetchAssociative(
'SELECT * FROM users WHERE id = :id',
['id' => (string) $id],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function findByEmail(Email $email, TenantId $tenantId): ?User
{
$row = $this->connection->fetchAssociative(
'SELECT * FROM users WHERE tenant_id = :tenant_id AND email = :email',
[
'tenant_id' => (string) $tenantId,
'email' => (string) $email,
],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function findAllByTenant(TenantId $tenantId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM users WHERE tenant_id = :tenant_id ORDER BY created_at ASC',
['tenant_id' => (string) $tenantId],
);
return array_map(fn (array $row) => $this->hydrate($row), $rows);
}
/**
* @param array<string, mixed> $row
*/
private function hydrate(array $row): User
{
/** @var string $id */
$id = $row['id'];
/** @var string $tenantId */
$tenantId = $row['tenant_id'];
/** @var string $email */
$email = $row['email'];
/** @var string $firstName */
$firstName = $row['first_name'];
/** @var string $lastName */
$lastName = $row['last_name'];
/** @var string $rolesJson */
$rolesJson = $row['roles'];
/** @var string|null $hashedPassword */
$hashedPassword = $row['hashed_password'];
/** @var string $statut */
$statut = $row['statut'];
/** @var string $schoolName */
$schoolName = $row['school_name'];
/** @var string|null $dateNaissance */
$dateNaissance = $row['date_naissance'];
/** @var string $createdAt */
$createdAt = $row['created_at'];
/** @var string|null $activatedAt */
$activatedAt = $row['activated_at'];
/** @var string|null $invitedAt */
$invitedAt = $row['invited_at'];
/** @var string|null $blockedAt */
$blockedAt = $row['blocked_at'];
/** @var string|null $blockedReason */
$blockedReason = $row['blocked_reason'];
/** @var string|null $consentementParentId */
$consentementParentId = $row['consentement_parent_id'];
/** @var string|null $consentementEleveId */
$consentementEleveId = $row['consentement_eleve_id'];
/** @var string|null $consentementDate */
$consentementDate = $row['consentement_date'];
/** @var string|null $consentementIp */
$consentementIp = $row['consentement_ip'];
/** @var string[]|null $roleValues */
$roleValues = json_decode($rolesJson, true);
if (!is_array($roleValues)) {
throw new RuntimeException(sprintf('Invalid roles JSON for user %s: %s', $id, $rolesJson));
}
$roles = array_map(static fn (string $r) => Role::from($r), $roleValues);
$consentement = null;
if ($consentementParentId !== null && $consentementEleveId !== null && $consentementDate !== null) {
$consentement = ConsentementParental::accorder(
parentId: $consentementParentId,
eleveId: $consentementEleveId,
at: new DateTimeImmutable($consentementDate),
ipAddress: $consentementIp ?? '',
);
}
return User::reconstitute(
id: UserId::fromString($id),
email: new Email($email),
roles: $roles,
tenantId: TenantId::fromString($tenantId),
schoolName: $schoolName,
statut: StatutCompte::from($statut),
dateNaissance: $dateNaissance !== null ? new DateTimeImmutable($dateNaissance) : null,
createdAt: new DateTimeImmutable($createdAt),
hashedPassword: $hashedPassword,
activatedAt: $activatedAt !== null ? new DateTimeImmutable($activatedAt) : null,
consentementParental: $consentement,
firstName: $firstName,
lastName: $lastName,
invitedAt: $invitedAt !== null ? new DateTimeImmutable($invitedAt) : null,
blockedAt: $blockedAt !== null ? new DateTimeImmutable($blockedAt) : null,
blockedReason: $blockedReason,
);
}
}

View File

@@ -30,7 +30,7 @@ final class InMemoryUserRepository implements UserRepository
#[Override]
public function get(UserId $id): User
{
$user = $this->byId[(string) $id] ?? null;
$user = $this->findById($id);
if ($user === null) {
throw UserNotFoundException::withId($id);
@@ -39,6 +39,12 @@ final class InMemoryUserRepository implements UserRepository
return $user;
}
#[Override]
public function findById(UserId $id): ?User
{
return $this->byId[(string) $id] ?? null;
}
#[Override]
public function findByEmail(Email $email, TenantId $tenantId): ?User
{

View File

@@ -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;

View File

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

View File

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

View File

@@ -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,
];
}
}