From a0e19627a7984f55ac4bf3df70e57a5405571b67 Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Sun, 15 Feb 2026 14:39:17 +0100 Subject: [PATCH] feat: Persister les utilisateurs en PostgreSQL avec cache-aside Redis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/ci.yml | 8 + Makefile | 7 +- backend/config/services.yaml | 11 +- backend/migrations/Version20260214100000.php | 71 ++++ .../Domain/Repository/UserRepository.php | 2 + .../Console/MigrateUsersToPostgresCommand.php | 69 ++++ .../Persistence/Cache/CacheUserRepository.php | 2 + .../Cache/CachedUserRepository.php | 289 ++++++++++++++ .../Doctrine/DoctrineUserRepository.php | 235 +++++++++++ .../InMemory/InMemoryUserRepository.php | 8 +- .../ActivateAccountProcessorTest.php | 5 + .../MigrateUsersToPostgresCommandTest.php | 209 ++++++++++ .../Cache/CachedUserRepositoryTest.php | 375 ++++++++++++++++++ .../Doctrine/DoctrineUserRepositoryTest.php | 292 ++++++++++++++ frontend/e2e/user-blocking-session.spec.ts | 2 +- 15 files changed, 1581 insertions(+), 4 deletions(-) create mode 100644 backend/migrations/Version20260214100000.php create mode 100644 backend/src/Administration/Infrastructure/Console/MigrateUsersToPostgresCommand.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/Cache/CachedUserRepository.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineUserRepository.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Console/MigrateUsersToPostgresCommandTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Persistence/Cache/CachedUserRepositoryTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Persistence/Doctrine/DoctrineUserRepositoryTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d0ddb8..f805c55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,14 @@ jobs: - name: Run 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 run: composer test env: diff --git a/Makefile b/Makefile index b866bb2..7991eab 100644 --- a/Makefile +++ b/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 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 -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 .PHONY: warmup diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 094fbc1..3c0ee2c 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -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 diff --git a/backend/migrations/Version20260214100000.php b/backend/migrations/Version20260214100000.php new file mode 100644 index 0000000..ae59c50 --- /dev/null +++ b/backend/migrations/Version20260214100000.php @@ -0,0 +1,71 @@ +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); + } +} diff --git a/backend/src/Administration/Domain/Repository/UserRepository.php b/backend/src/Administration/Domain/Repository/UserRepository.php index f23567d..61886c4 100644 --- a/backend/src/Administration/Domain/Repository/UserRepository.php +++ b/backend/src/Administration/Domain/Repository/UserRepository.php @@ -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. diff --git a/backend/src/Administration/Infrastructure/Console/MigrateUsersToPostgresCommand.php b/backend/src/Administration/Infrastructure/Console/MigrateUsersToPostgresCommand.php new file mode 100644 index 0000000..530e948 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Console/MigrateUsersToPostgresCommand.php @@ -0,0 +1,69 @@ +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; + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/Cache/CacheUserRepository.php b/backend/src/Administration/Infrastructure/Persistence/Cache/CacheUserRepository.php index e539e7f..3145048 100644 --- a/backend/src/Administration/Infrastructure/Persistence/Cache/CacheUserRepository.php +++ b/backend/src/Administration/Infrastructure/Persistence/Cache/CacheUserRepository.php @@ -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); diff --git a/backend/src/Administration/Infrastructure/Persistence/Cache/CachedUserRepository.php b/backend/src/Administration/Infrastructure/Persistence/Cache/CachedUserRepository.php new file mode 100644 index 0000000..f16c89e --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/Cache/CachedUserRepository.php @@ -0,0 +1,289 @@ +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 $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 $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 + */ + 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 $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); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineUserRepository.php b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineUserRepository.php new file mode 100644 index 0000000..a58fdca --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineUserRepository.php @@ -0,0 +1,235 @@ +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 $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, + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryUserRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryUserRepository.php index 0ad3fcc..938870c 100644 --- a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryUserRepository.php +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryUserRepository.php @@ -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 { diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Processor/ActivateAccountProcessorTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/ActivateAccountProcessorTest.php index 5fbef51..35964c8 100644 --- a/backend/tests/Unit/Administration/Infrastructure/Api/Processor/ActivateAccountProcessorTest.php +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/ActivateAccountProcessorTest.php @@ -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; diff --git a/backend/tests/Unit/Administration/Infrastructure/Console/MigrateUsersToPostgresCommandTest.php b/backend/tests/Unit/Administration/Infrastructure/Console/MigrateUsersToPostgresCommandTest.php new file mode 100644 index 0000000..dabc483 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Console/MigrateUsersToPostgresCommandTest.php @@ -0,0 +1,209 @@ +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; + } + }; + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Persistence/Cache/CachedUserRepositoryTest.php b/backend/tests/Unit/Administration/Infrastructure/Persistence/Cache/CachedUserRepositoryTest.php new file mode 100644 index 0000000..3847f08 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Persistence/Cache/CachedUserRepositoryTest.php @@ -0,0 +1,375 @@ +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 + */ + 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; + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Persistence/Doctrine/DoctrineUserRepositoryTest.php b/backend/tests/Unit/Administration/Infrastructure/Persistence/Doctrine/DoctrineUserRepositoryTest.php new file mode 100644 index 0000000..c45c06f --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Persistence/Doctrine/DoctrineUserRepositoryTest.php @@ -0,0 +1,292 @@ +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 + */ + 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, + ]; + } +} diff --git a/frontend/e2e/user-blocking-session.spec.ts b/frontend/e2e/user-blocking-session.spec.ts index 35ff0eb..ef17c96 100644 --- a/frontend/e2e/user-blocking-session.spec.ts +++ b/frontend/e2e/user-blocking-session.spec.ts @@ -38,7 +38,7 @@ test.describe('User Blocking Mid-Session [P1]', () => { // Ensure target user is unblocked before tests start try { 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' } ); } catch {