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.
293 lines
11 KiB
PHP
293 lines
11 KiB
PHP
<?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,
|
|
];
|
|
}
|
|
}
|