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:
@@ -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
|
||||
|
||||
71
backend/migrations/Version20260214100000.php
Normal file
71
backend/migrations/Version20260214100000.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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