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:
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -72,6 +72,14 @@ jobs:
|
|||||||
- name: Run PHPStan
|
- name: Run PHPStan
|
||||||
run: composer phpstan
|
run: composer phpstan
|
||||||
|
|
||||||
|
- name: Create test database and run migrations
|
||||||
|
run: |
|
||||||
|
php bin/console doctrine:database:create --if-not-exists --env=test
|
||||||
|
php bin/console doctrine:migrations:migrate --no-interaction --env=test
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://classeo:classeo@localhost:5432/classeo_test?serverVersion=18
|
||||||
|
REDIS_URL: redis://localhost:6379
|
||||||
|
|
||||||
- name: Run PHPUnit
|
- name: Run PHPUnit
|
||||||
run: composer test
|
run: composer test
|
||||||
env:
|
env:
|
||||||
|
|||||||
7
Makefile
7
Makefile
@@ -119,8 +119,13 @@ cs-fix: ## Corriger le code style PHP (PHP-CS-Fixer)
|
|||||||
cs-check: ## Vérifier le code style PHP sans corriger
|
cs-check: ## Vérifier le code style PHP sans corriger
|
||||||
docker compose exec php composer cs-check
|
docker compose exec php composer cs-check
|
||||||
|
|
||||||
|
.PHONY: setup-test-db
|
||||||
|
setup-test-db: ## Créer et migrer la base de test PostgreSQL
|
||||||
|
docker compose exec php php bin/console doctrine:database:create --if-not-exists --env=test -q
|
||||||
|
docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction --env=test -q
|
||||||
|
|
||||||
.PHONY: test-php
|
.PHONY: test-php
|
||||||
test-php: ## Lancer les tests PHPUnit
|
test-php: setup-test-db ## Lancer les tests PHPUnit
|
||||||
docker compose exec -e APP_ENV=test php composer test
|
docker compose exec -e APP_ENV=test php composer test
|
||||||
|
|
||||||
.PHONY: warmup
|
.PHONY: warmup
|
||||||
|
|||||||
@@ -67,8 +67,17 @@ services:
|
|||||||
App\Administration\Domain\Repository\ActivationTokenRepository:
|
App\Administration\Domain\Repository\ActivationTokenRepository:
|
||||||
alias: App\Administration\Infrastructure\Persistence\Redis\RedisActivationTokenRepository
|
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:
|
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:
|
App\Administration\Application\Port\PasswordHasher:
|
||||||
alias: App\Administration\Infrastructure\Security\SymfonyPasswordHasher
|
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 get(UserId $id): User;
|
||||||
|
|
||||||
|
public function findById(UserId $id): ?User;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds a user by email within a specific tenant.
|
* Finds a user by email within a specific tenant.
|
||||||
* Returns null if user doesn't exist in that 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 function in_array;
|
||||||
|
|
||||||
|
use Override;
|
||||||
use Psr\Cache\CacheItemPoolInterface;
|
use Psr\Cache\CacheItemPoolInterface;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ final readonly class CacheUserRepository implements UserRepository
|
|||||||
$this->usersCache->save($tenantItem);
|
$this->usersCache->save($tenantItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
public function findById(UserId $id): ?User
|
public function findById(UserId $id): ?User
|
||||||
{
|
{
|
||||||
$item = $this->usersCache->getItem(self::KEY_PREFIX . $id);
|
$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]
|
#[Override]
|
||||||
public function get(UserId $id): User
|
public function get(UserId $id): User
|
||||||
{
|
{
|
||||||
$user = $this->byId[(string) $id] ?? null;
|
$user = $this->findById($id);
|
||||||
|
|
||||||
if ($user === null) {
|
if ($user === null) {
|
||||||
throw UserNotFoundException::withId($id);
|
throw UserNotFoundException::withId($id);
|
||||||
@@ -39,6 +39,12 @@ final class InMemoryUserRepository implements UserRepository
|
|||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function findById(UserId $id): ?User
|
||||||
|
{
|
||||||
|
return $this->byId[(string) $id] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
#[Override]
|
#[Override]
|
||||||
public function findByEmail(Email $email, TenantId $tenantId): ?User
|
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
|
public function findByEmail(Email $email, TenantId $tenantId): ?User
|
||||||
{
|
{
|
||||||
return null;
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ test.describe('User Blocking Mid-Session [P1]', () => {
|
|||||||
// Ensure target user is unblocked before tests start
|
// Ensure target user is unblocked before tests start
|
||||||
try {
|
try {
|
||||||
execSync(
|
execSync(
|
||||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "UPDATE users SET statut = 'actif', blocked_reason = NULL WHERE email = '${TARGET_EMAIL}'" 2>&1`,
|
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "UPDATE users SET statut = 'active', blocked_at = NULL, blocked_reason = NULL WHERE email = '${TARGET_EMAIL}'" 2>&1`,
|
||||||
{ encoding: 'utf-8' }
|
{ encoding: 'utf-8' }
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user