feat: Optimiser la pagination avec cache-aside et ports de lecture dédiés

Les listes paginées (utilisateurs, classes, matières, affectations,
invitations parents, droits à l'image) effectuaient des requêtes SQL
complètes à chaque chargement de page, sans aucun cache. Sur les
établissements avec plusieurs centaines d'enregistrements, cela causait
des temps de réponse perceptibles et une charge inutile sur PostgreSQL.

Cette refactorisation introduit un cache tag-aware (Redis en prod,
filesystem en dev) avec invalidation événementielle, et extrait les
requêtes de lecture dans des ports Application / implémentations DBAL
conformes à l'architecture hexagonale. Un middleware Messenger garantit
l'invalidation synchrone du cache même pour les événements routés en
asynchrone (envoi d'emails), évitant ainsi toute donnée périmée côté UI.
This commit is contained in:
2026-03-01 14:33:56 +01:00
parent ce05207c64
commit 23dd7177f2
41 changed files with 2854 additions and 1584 deletions

View File

@@ -4,365 +4,140 @@ declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Query\GetAllAssignments;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Port\PaginatedAssignmentsReader;
use App\Administration\Application\Query\GetAllAssignments\AssignmentWithNamesDto;
use App\Administration\Application\Query\GetAllAssignments\GetAllAssignmentsHandler;
use App\Administration\Application\Query\GetAllAssignments\GetAllAssignmentsQuery;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassName;
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
use App\Administration\Domain\Model\Subject\Subject;
use App\Administration\Domain\Model\Subject\SubjectCode;
use App\Administration\Domain\Model\Subject\SubjectName;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
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\Persistence\InMemory\InMemoryClassRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySubjectRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryTeacherAssignmentRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
use App\Shared\Domain\Tenant\TenantId;
use function count;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
final class GetAllAssignmentsHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string OTHER_TENANT_ID = '550e8400-e29b-41d4-a716-446655440099';
private InMemoryTeacherAssignmentRepository $assignmentRepository;
private InMemoryUserRepository $userRepository;
private InMemoryClassRepository $classRepository;
private InMemorySubjectRepository $subjectRepository;
private PaginatedAssignmentsReader $reader;
private PaginatedQueryCache $cache;
private GetAllAssignmentsHandler $handler;
protected function setUp(): void
{
$this->assignmentRepository = new InMemoryTeacherAssignmentRepository();
$this->userRepository = new InMemoryUserRepository();
$this->classRepository = new InMemoryClassRepository();
$this->subjectRepository = new InMemorySubjectRepository();
$this->handler = new GetAllAssignmentsHandler(
$this->assignmentRepository,
$this->userRepository,
$this->classRepository,
$this->subjectRepository,
new NullLogger(),
$this->reader = $this->createMock(PaginatedAssignmentsReader::class);
$this->cache = new PaginatedQueryCache(
new TagAwareAdapter(new ArrayAdapter()),
);
$this->handler = new GetAllAssignmentsHandler($this->reader, $this->cache);
}
#[Test]
public function returnsAllActiveAssignmentsWithNames(): void
public function returnsItemsForTenant(): void
{
$this->seedData();
$query = new GetAllAssignmentsQuery(tenantId: self::TENANT_ID);
$result = ($this->handler)($query);
self::assertCount(2, $result->items);
self::assertSame(2, $result->total);
self::assertSame(1, $result->page);
self::assertSame(30, $result->limit);
// Verify denormalized names are populated
$dto = $result->items[0];
self::assertNotSame('', $dto->teacherFirstName);
self::assertNotSame('', $dto->className);
self::assertNotSame('', $dto->subjectName);
}
#[Test]
public function searchesByTeacherName(): void
{
$this->seedData();
$query = new GetAllAssignmentsQuery(
tenantId: self::TENANT_ID,
search: 'Jean',
$dto = $this->createAssignmentDto();
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
);
$result = ($this->handler)($query);
$result = ($this->handler)(new GetAllAssignmentsQuery(tenantId: 'tenant-1'));
self::assertCount(1, $result->items);
self::assertSame('Jean', $result->items[0]->teacherFirstName);
self::assertSame(1, $result->total);
}
#[Test]
public function searchesByClassName(): void
public function mapsDtoFields(): void
{
$this->seedData();
$query = new GetAllAssignmentsQuery(
tenantId: self::TENANT_ID,
search: '6ème',
$dto = $this->createAssignmentDto();
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
);
$result = ($this->handler)($query);
self::assertGreaterThanOrEqual(1, count($result->items));
self::assertStringContainsString('6ème', $result->items[0]->className);
}
$result = ($this->handler)(new GetAllAssignmentsQuery(tenantId: 'tenant-1'));
#[Test]
public function searchesBySubjectName(): void
{
$this->seedData();
$query = new GetAllAssignmentsQuery(
tenantId: self::TENANT_ID,
search: 'Français',
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame('Français', $result->items[0]->subjectName);
}
#[Test]
public function searchIsCaseInsensitive(): void
{
$this->seedData();
$query = new GetAllAssignmentsQuery(
tenantId: self::TENANT_ID,
search: 'jean',
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame('Jean', $result->items[0]->teacherFirstName);
$item = $result->items[0];
self::assertSame('assign-1', $item->id);
self::assertSame('teacher-1', $item->teacherId);
self::assertSame('Jean', $item->teacherFirstName);
self::assertSame('Dupont', $item->teacherLastName);
self::assertSame('class-1', $item->classId);
self::assertSame('6eme A', $item->className);
self::assertSame('subj-1', $item->subjectId);
self::assertSame('Mathematiques', $item->subjectName);
self::assertSame('year-1', $item->academicYearId);
self::assertSame('active', $item->status);
self::assertNull($item->endDate);
}
#[Test]
public function paginatesResults(): void
{
$this->seedData();
$query = new GetAllAssignmentsQuery(
tenantId: self::TENANT_ID,
page: 1,
limit: 1,
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [], total: 50, page: 2, limit: 10),
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame(2, $result->total);
self::assertSame(1, $result->page);
self::assertSame(1, $result->limit);
self::assertSame(2, $result->totalPages());
}
$result = ($this->handler)(new GetAllAssignmentsQuery(tenantId: 'tenant-1', page: 2, limit: 10));
#[Test]
public function returnsSecondPage(): void
{
$this->seedData();
$query = new GetAllAssignmentsQuery(
tenantId: self::TENANT_ID,
page: 2,
limit: 1,
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame(2, $result->total);
self::assertSame(50, $result->total);
self::assertSame(2, $result->page);
self::assertSame(10, $result->limit);
}
#[Test]
public function returnsEmptyWhenNoMatches(): void
public function cachesResult(): void
{
$this->seedData();
$query = new GetAllAssignmentsQuery(
tenantId: self::TENANT_ID,
search: 'nonexistent',
$dto = $this->createAssignmentDto();
$this->reader->expects(self::once())->method('findPaginated')->willReturn(
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
);
$query = new GetAllAssignmentsQuery(tenantId: 'tenant-1');
($this->handler)($query);
$result = ($this->handler)($query);
self::assertCount(0, $result->items);
self::assertSame(0, $result->total);
}
#[Test]
public function excludesAssignmentsFromOtherTenants(): void
{
$this->seedData();
$query = new GetAllAssignmentsQuery(tenantId: self::OTHER_TENANT_ID);
$result = ($this->handler)($query);
self::assertCount(0, $result->items);
self::assertSame(0, $result->total);
}
#[Test]
public function handlesOrphanedAssignment(): void
{
// Create an assignment with no matching teacher/class/subject
$tenantId = TenantId::fromString(self::TENANT_ID);
$now = new DateTimeImmutable('2026-02-01 10:00:00');
$orphanedClass = SchoolClass::creer(
tenantId: $tenantId,
schoolId: SchoolId::generate(),
academicYearId: AcademicYearId::generate(),
name: new ClassName('Orphan Class'),
level: null,
capacity: null,
createdAt: $now,
);
$this->classRepository->save($orphanedClass);
$orphanedSubject = Subject::creer(
tenantId: $tenantId,
schoolId: SchoolId::generate(),
name: new SubjectName('Orphan Subject'),
code: new SubjectCode('ORPH'),
color: null,
createdAt: $now,
);
$this->subjectRepository->save($orphanedSubject);
// Teacher does NOT exist in userRepository
$orphanedTeacher = User::inviter(
email: new Email('orphan@example.com'),
role: Role::PROF,
tenantId: TenantId::fromString(self::OTHER_TENANT_ID), // Different tenant — won't be found
schoolName: 'Autre',
firstName: 'Ghost',
lastName: 'Teacher',
invitedAt: $now,
);
$this->userRepository->save($orphanedTeacher);
$assignment = TeacherAssignment::creer(
tenantId: $tenantId,
teacherId: $orphanedTeacher->id,
classId: $orphanedClass->id,
subjectId: $orphanedSubject->id,
academicYearId: AcademicYearId::generate(),
createdAt: $now,
);
$this->assignmentRepository->save($assignment);
$query = new GetAllAssignmentsQuery(tenantId: self::TENANT_ID);
$result = ($this->handler)($query);
// Assignment should appear with empty teacher names (orphaned reference logged as warning)
self::assertCount(1, $result->items);
self::assertSame('', $result->items[0]->teacherFirstName);
self::assertSame('', $result->items[0]->teacherLastName);
}
#[Test]
public function clampsInvalidPageToOne(): void
public function clampsPageToMinimumOne(): void
{
$query = new GetAllAssignmentsQuery(
tenantId: self::TENANT_ID,
page: -1,
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [], total: 0, page: 1, limit: 30),
);
self::assertSame(1, $query->page);
$result = ($this->handler)(new GetAllAssignmentsQuery(tenantId: 'tenant-1', page: -5));
self::assertSame(1, $result->page);
}
#[Test]
public function clampsExcessiveLimitToMax(): void
public function clampsLimitToMaximumHundred(): void
{
$query = new GetAllAssignmentsQuery(
tenantId: self::TENANT_ID,
limit: 999,
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [], total: 0, page: 1, limit: 100),
);
self::assertSame(100, $query->limit);
$result = ($this->handler)(new GetAllAssignmentsQuery(tenantId: 'tenant-1', limit: 500));
self::assertSame(100, $result->limit);
}
private function seedData(): void
private function createAssignmentDto(): AssignmentWithNamesDto
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$schoolId = SchoolId::generate();
$academicYearId = AcademicYearId::generate();
$now = new DateTimeImmutable('2026-02-01 10:00:00');
// Create teachers
$teacher1 = User::inviter(
email: new Email('teacher1@example.com'),
role: Role::PROF,
tenantId: $tenantId,
schoolName: 'École Alpha',
firstName: 'Jean',
lastName: 'Dupont',
invitedAt: $now,
return new AssignmentWithNamesDto(
id: 'assign-1',
teacherId: 'teacher-1',
teacherFirstName: 'Jean',
teacherLastName: 'Dupont',
classId: 'class-1',
className: '6eme A',
subjectId: 'subj-1',
subjectName: 'Mathematiques',
academicYearId: 'year-1',
status: 'active',
startDate: new DateTimeImmutable('2026-02-01'),
endDate: null,
createdAt: new DateTimeImmutable('2026-01-15'),
);
$this->userRepository->save($teacher1);
$teacher2 = User::inviter(
email: new Email('teacher2@example.com'),
role: Role::PROF,
tenantId: $tenantId,
schoolName: 'École Alpha',
firstName: 'Marie',
lastName: 'Martin',
invitedAt: $now,
);
$this->userRepository->save($teacher2);
// Create classes
$class1 = SchoolClass::creer(
tenantId: $tenantId,
schoolId: $schoolId,
academicYearId: $academicYearId,
name: new ClassName('6ème A'),
level: SchoolLevel::SIXIEME,
capacity: 30,
createdAt: $now,
);
$this->classRepository->save($class1);
// Create subjects
$subject1 = Subject::creer(
tenantId: $tenantId,
schoolId: $schoolId,
name: new SubjectName('Mathématiques'),
code: new SubjectCode('MATH'),
color: null,
createdAt: $now,
);
$this->subjectRepository->save($subject1);
$subject2 = Subject::creer(
tenantId: $tenantId,
schoolId: $schoolId,
name: new SubjectName('Français'),
code: new SubjectCode('FR'),
color: null,
createdAt: $now,
);
$this->subjectRepository->save($subject2);
// Create assignments
$assignment1 = TeacherAssignment::creer(
tenantId: $tenantId,
teacherId: $teacher1->id,
classId: $class1->id,
subjectId: $subject1->id,
academicYearId: $academicYearId,
createdAt: $now,
);
$this->assignmentRepository->save($assignment1);
$assignment2 = TeacherAssignment::creer(
tenantId: $tenantId,
teacherId: $teacher2->id,
classId: $class1->id,
subjectId: $subject2->id,
academicYearId: $academicYearId,
createdAt: $now,
);
$this->assignmentRepository->save($assignment2);
}
}

View File

@@ -4,212 +4,130 @@ declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Query\GetClasses;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Port\PaginatedClassesReader;
use App\Administration\Application\Query\GetClasses\ClassDto;
use App\Administration\Application\Query\GetClasses\GetClassesHandler;
use App\Administration\Application\Query\GetClasses\GetClassesQuery;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassName;
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
use App\Shared\Domain\Tenant\TenantId;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
final class GetClassesHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440010';
private InMemoryClassRepository $classRepository;
private PaginatedClassesReader $reader;
private PaginatedQueryCache $cache;
private GetClassesHandler $handler;
protected function setUp(): void
{
$this->classRepository = new InMemoryClassRepository();
$this->handler = new GetClassesHandler($this->classRepository);
$this->reader = $this->createMock(PaginatedClassesReader::class);
$this->cache = new PaginatedQueryCache(
new TagAwareAdapter(new ArrayAdapter()),
);
$this->handler = new GetClassesHandler($this->reader, $this->cache);
}
#[Test]
public function returnsAllActiveClassesForTenantAndYear(): void
public function returnsItemsForTenant(): void
{
$this->seedClasses();
$query = new GetClassesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
$dto = $this->createClassDto();
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
);
$result = ($this->handler)($query);
self::assertCount(3, $result->items);
self::assertSame(3, $result->total);
self::assertSame(1, $result->page);
self::assertSame(30, $result->limit);
}
#[Test]
public function filtersClassesByName(): void
{
$this->seedClasses();
$query = new GetClassesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
search: '6ème',
);
$result = ($this->handler)($query);
$result = ($this->handler)(new GetClassesQuery(tenantId: 'tenant-1', academicYearId: 'year-1'));
self::assertCount(1, $result->items);
self::assertSame('6ème A', $result->items[0]->name);
self::assertSame(1, $result->total);
}
#[Test]
public function filtersClassesByLevel(): void
public function mapsDtoFields(): void
{
$this->seedClasses();
$query = new GetClassesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
search: SchoolLevel::CM2->value,
$dto = $this->createClassDto();
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame('CM2 B', $result->items[0]->name);
}
$result = ($this->handler)(new GetClassesQuery(tenantId: 'tenant-1', academicYearId: 'year-1'));
#[Test]
public function searchIsCaseInsensitive(): void
{
$this->seedClasses();
$query = new GetClassesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
search: 'cm2',
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
$item = $result->items[0];
self::assertSame('class-1', $item->id);
self::assertSame('6eme A', $item->name);
self::assertSame('sixieme', $item->level);
self::assertSame(30, $item->capacity);
self::assertSame('active', $item->status);
self::assertSame('Description test', $item->description);
}
#[Test]
public function paginatesResults(): void
{
$this->seedClasses();
$query = new GetClassesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
page: 1,
limit: 2,
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [], total: 50, page: 2, limit: 10),
);
$result = ($this->handler)($query);
self::assertCount(2, $result->items);
self::assertSame(3, $result->total);
self::assertSame(1, $result->page);
self::assertSame(2, $result->limit);
self::assertSame(2, $result->totalPages());
$result = ($this->handler)(new GetClassesQuery(tenantId: 'tenant-1', academicYearId: 'year-1', page: 2, limit: 10));
self::assertSame(50, $result->total);
self::assertSame(2, $result->page);
self::assertSame(10, $result->limit);
}
#[Test]
public function returnsSecondPage(): void
public function cachesResult(): void
{
$this->seedClasses();
$query = new GetClassesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
page: 2,
limit: 2,
$dto = $this->createClassDto();
$this->reader->expects(self::once())->method('findPaginated')->willReturn(
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
);
$query = new GetClassesQuery(tenantId: 'tenant-1', academicYearId: 'year-1');
($this->handler)($query);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame(3, $result->total);
self::assertSame(2, $result->page);
}
#[Test]
public function returnsEmptyWhenNoMatches(): void
public function clampsPageToMinimumOne(): void
{
$this->seedClasses();
$query = new GetClassesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
search: 'nonexistent',
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [], total: 0, page: 1, limit: 30),
);
$result = ($this->handler)($query);
self::assertCount(0, $result->items);
self::assertSame(0, $result->total);
$result = ($this->handler)(new GetClassesQuery(tenantId: 'tenant-1', academicYearId: 'year-1', page: -5));
self::assertSame(1, $result->page);
}
#[Test]
public function clampsInvalidPageToOne(): void
public function clampsLimitToMaximumHundred(): void
{
$this->seedClasses();
$query = new GetClassesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
page: -1,
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [], total: 0, page: 1, limit: 100),
);
self::assertSame(1, $query->page);
$result = ($this->handler)(new GetClassesQuery(tenantId: 'tenant-1', academicYearId: 'year-1', limit: 500));
self::assertSame(100, $result->limit);
}
#[Test]
public function clampsExcessiveLimitToMax(): void
private function createClassDto(): ClassDto
{
$query = new GetClassesQuery(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
limit: 999,
);
self::assertSame(100, $query->limit);
}
private function seedClasses(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$schoolId = SchoolId::generate();
$academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID);
$now = new DateTimeImmutable('2026-02-01 10:00:00');
$this->classRepository->save(SchoolClass::creer(
tenantId: $tenantId,
schoolId: $schoolId,
academicYearId: $academicYearId,
name: new ClassName('6ème A'),
level: SchoolLevel::SIXIEME,
return new ClassDto(
id: 'class-1',
name: '6eme A',
level: 'sixieme',
capacity: 30,
createdAt: $now,
));
$this->classRepository->save(SchoolClass::creer(
tenantId: $tenantId,
schoolId: $schoolId,
academicYearId: $academicYearId,
name: new ClassName('CM2 B'),
level: SchoolLevel::CM2,
capacity: 25,
createdAt: $now,
));
$this->classRepository->save(SchoolClass::creer(
tenantId: $tenantId,
schoolId: $schoolId,
academicYearId: $academicYearId,
name: new ClassName('CP Alpha'),
level: SchoolLevel::CP,
capacity: 20,
createdAt: $now,
));
status: 'active',
description: 'Description test',
createdAt: new DateTimeImmutable('2026-01-15'),
updatedAt: new DateTimeImmutable('2026-01-15'),
);
}
}

View File

@@ -4,204 +4,135 @@ declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Query\GetParentInvitations;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Port\PaginatedParentInvitationsReader;
use App\Administration\Application\Query\GetParentInvitations\GetParentInvitationsHandler;
use App\Administration\Application\Query\GetParentInvitations\GetParentInvitationsQuery;
use App\Administration\Domain\Model\Invitation\InvitationCode;
use App\Administration\Domain\Model\Invitation\ParentInvitation;
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\Infrastructure\Persistence\InMemory\InMemoryParentInvitationRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
use App\Shared\Domain\Tenant\TenantId;
use App\Administration\Application\Query\GetParentInvitations\ParentInvitationDto;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
final class GetParentInvitationsHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string OTHER_TENANT_ID = '550e8400-e29b-41d4-a716-446655440099';
private InMemoryParentInvitationRepository $invitationRepository;
private InMemoryUserRepository $userRepository;
private PaginatedParentInvitationsReader $reader;
private PaginatedQueryCache $cache;
private GetParentInvitationsHandler $handler;
protected function setUp(): void
{
$this->invitationRepository = new InMemoryParentInvitationRepository();
$this->userRepository = new InMemoryUserRepository();
$this->handler = new GetParentInvitationsHandler(
$this->invitationRepository,
$this->userRepository,
$this->reader = $this->createMock(PaginatedParentInvitationsReader::class);
$this->cache = new PaginatedQueryCache(
new TagAwareAdapter(new ArrayAdapter()),
);
$this->handler = new GetParentInvitationsHandler($this->reader, $this->cache);
}
#[Test]
public function itReturnsAllInvitationsForTenant(): void
public function returnsItemsForTenant(): void
{
$student = $this->createAndSaveStudent('Alice', 'Dupont');
$this->createAndSaveInvitation($student->id, 'parent1@example.com');
$this->createAndSaveInvitation($student->id, 'parent2@example.com');
$dto = $this->createInvitationDto();
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
);
$result = ($this->handler)(new GetParentInvitationsQuery(
tenantId: self::TENANT_ID,
));
$result = ($this->handler)(new GetParentInvitationsQuery(tenantId: 'tenant-1'));
self::assertSame(2, $result->total);
self::assertCount(2, $result->items);
self::assertCount(1, $result->items);
self::assertSame(1, $result->total);
}
#[Test]
public function itFiltersInvitationsByStatus(): void
public function mapsDtoFields(): void
{
$student = $this->createAndSaveStudent('Bob', 'Martin');
$invitation = $this->createAndSaveInvitation($student->id, 'parent@example.com');
$this->createPendingInvitation($student->id, 'parent2@example.com');
$dto = $this->createInvitationDto();
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
);
$result = ($this->handler)(new GetParentInvitationsQuery(
tenantId: self::TENANT_ID,
$result = ($this->handler)(new GetParentInvitationsQuery(tenantId: 'tenant-1'));
$item = $result->items[0];
self::assertSame('inv-1', $item->id);
self::assertSame('student-1', $item->studentId);
self::assertSame('parent@test.com', $item->parentEmail);
self::assertSame('sent', $item->status);
self::assertSame('Alice', $item->studentFirstName);
self::assertSame('Dupont', $item->studentLastName);
self::assertNull($item->activatedAt);
self::assertNull($item->activatedUserId);
}
#[Test]
public function paginatesResults(): void
{
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [], total: 50, page: 2, limit: 10),
);
$result = ($this->handler)(new GetParentInvitationsQuery(tenantId: 'tenant-1', page: 2, limit: 10));
self::assertSame(50, $result->total);
self::assertSame(2, $result->page);
self::assertSame(10, $result->limit);
}
#[Test]
public function cachesResult(): void
{
$dto = $this->createInvitationDto();
$this->reader->expects(self::once())->method('findPaginated')->willReturn(
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
);
$query = new GetParentInvitationsQuery(tenantId: 'tenant-1');
($this->handler)($query);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
}
#[Test]
public function clampsPageToMinimumOne(): void
{
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [], total: 0, page: 1, limit: 30),
);
$result = ($this->handler)(new GetParentInvitationsQuery(tenantId: 'tenant-1', page: -5));
self::assertSame(1, $result->page);
}
#[Test]
public function clampsLimitToMaximumHundred(): void
{
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [], total: 0, page: 1, limit: 100),
);
$result = ($this->handler)(new GetParentInvitationsQuery(tenantId: 'tenant-1', limit: 500));
self::assertSame(100, $result->limit);
}
private function createInvitationDto(): ParentInvitationDto
{
return new ParentInvitationDto(
id: 'inv-1',
studentId: 'student-1',
parentEmail: 'parent@test.com',
status: 'sent',
));
self::assertSame(1, $result->total);
self::assertSame('parent@example.com', $result->items[0]->parentEmail);
}
#[Test]
public function itFiltersInvitationsByStudentId(): void
{
$student1 = $this->createAndSaveStudent('Alice', 'Dupont');
$student2 = $this->createAndSaveStudent('Bob', 'Martin');
$this->createAndSaveInvitation($student1->id, 'parent1@example.com');
$this->createAndSaveInvitation($student2->id, 'parent2@example.com');
$result = ($this->handler)(new GetParentInvitationsQuery(
tenantId: self::TENANT_ID,
studentId: (string) $student1->id,
));
self::assertSame(1, $result->total);
self::assertSame('parent1@example.com', $result->items[0]->parentEmail);
}
#[Test]
public function itSearchesByParentEmailOrStudentName(): void
{
$student = $this->createAndSaveStudent('Alice', 'Dupont');
$this->createAndSaveInvitation($student->id, 'parent@example.com');
$this->createAndSaveInvitation($student->id, 'other@example.com');
$result = ($this->handler)(new GetParentInvitationsQuery(
tenantId: self::TENANT_ID,
search: 'Alice',
));
self::assertSame(2, $result->total);
$result = ($this->handler)(new GetParentInvitationsQuery(
tenantId: self::TENANT_ID,
search: 'parent@',
));
self::assertSame(1, $result->total);
}
#[Test]
public function itPaginatesResults(): void
{
$student = $this->createAndSaveStudent('Alice', 'Dupont');
for ($i = 0; $i < 5; ++$i) {
$this->createAndSaveInvitation($student->id, "parent{$i}@example.com");
}
$result = ($this->handler)(new GetParentInvitationsQuery(
tenantId: self::TENANT_ID,
page: 1,
limit: 2,
));
self::assertSame(5, $result->total);
self::assertCount(2, $result->items);
}
#[Test]
public function itEnrichesResultsWithStudentNames(): void
{
$student = $this->createAndSaveStudent('Alice', 'Dupont');
$this->createAndSaveInvitation($student->id, 'parent@example.com');
$result = ($this->handler)(new GetParentInvitationsQuery(
tenantId: self::TENANT_ID,
));
self::assertSame('Alice', $result->items[0]->studentFirstName);
self::assertSame('Dupont', $result->items[0]->studentLastName);
}
#[Test]
public function itIsolatesByTenant(): void
{
$student = $this->createAndSaveStudent('Alice', 'Dupont');
$this->createAndSaveInvitation($student->id, 'parent@example.com');
$result = ($this->handler)(new GetParentInvitationsQuery(
tenantId: self::OTHER_TENANT_ID,
));
self::assertSame(0, $result->total);
}
private function createAndSaveStudent(string $firstName, string $lastName): User
{
$student = User::inviter(
email: new Email($firstName . '@example.com'),
role: Role::ELEVE,
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: 'École Alpha',
firstName: $firstName,
lastName: $lastName,
invitedAt: new DateTimeImmutable('2026-02-07 10:00:00'),
createdAt: new DateTimeImmutable('2026-02-07'),
expiresAt: new DateTimeImmutable('2026-03-07'),
sentAt: new DateTimeImmutable('2026-02-07'),
activatedAt: null,
activatedUserId: null,
studentFirstName: 'Alice',
studentLastName: 'Dupont',
);
$student->pullDomainEvents();
$this->userRepository->save($student);
return $student;
}
private function createAndSaveInvitation(UserId $studentId, string $parentEmail): ParentInvitation
{
$code = bin2hex(random_bytes(16));
$invitation = ParentInvitation::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
studentId: $studentId,
parentEmail: new Email($parentEmail),
code: new InvitationCode($code),
createdAt: new DateTimeImmutable('2026-02-07 10:00:00'),
createdBy: UserId::generate(),
);
$invitation->envoyer(new DateTimeImmutable('2026-02-07 10:00:00'));
$invitation->pullDomainEvents();
$this->invitationRepository->save($invitation);
return $invitation;
}
private function createPendingInvitation(UserId $studentId, string $parentEmail): ParentInvitation
{
$code = bin2hex(random_bytes(16));
$invitation = ParentInvitation::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
studentId: $studentId,
parentEmail: new Email($parentEmail),
code: new InvitationCode($code),
createdAt: new DateTimeImmutable('2026-02-07 10:00:00'),
createdBy: UserId::generate(),
);
$invitation->pullDomainEvents();
$this->invitationRepository->save($invitation);
return $invitation;
}
}

View File

@@ -4,141 +4,131 @@ declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Query\GetStudentsImageRights;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Port\PaginatedStudentImageRightsReader;
use App\Administration\Application\Query\GetStudentsImageRights\GetStudentsImageRightsHandler;
use App\Administration\Application\Query\GetStudentsImageRights\GetStudentsImageRightsQuery;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\ImageRightsStatus;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
use App\Shared\Domain\Tenant\TenantId;
use App\Administration\Application\Query\GetStudentsImageRights\StudentImageRightsDto;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
final class GetStudentsImageRightsHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private InMemoryUserRepository $userRepository;
private PaginatedStudentImageRightsReader $reader;
private PaginatedQueryCache $cache;
private GetStudentsImageRightsHandler $handler;
protected function setUp(): void
{
$this->userRepository = new InMemoryUserRepository();
$this->handler = new GetStudentsImageRightsHandler($this->userRepository);
}
#[Test]
public function returnsOnlyStudents(): void
{
$this->seedStudentsAndParent();
$query = new GetStudentsImageRightsQuery(tenantId: self::TENANT_ID);
$result = ($this->handler)($query);
self::assertCount(2, $result);
}
#[Test]
public function filtersStudentsByStatus(): void
{
$this->seedStudentsAndParent();
$query = new GetStudentsImageRightsQuery(
tenantId: self::TENANT_ID,
status: 'authorized',
$this->reader = $this->createMock(PaginatedStudentImageRightsReader::class);
$this->cache = new PaginatedQueryCache(
new TagAwareAdapter(new ArrayAdapter()),
);
$result = ($this->handler)($query);
self::assertCount(1, $result);
self::assertSame('authorized', $result[0]->imageRightsStatus);
$this->handler = new GetStudentsImageRightsHandler($this->reader, $this->cache);
}
#[Test]
public function returnsEmptyForNoStudents(): void
public function returnsItemsForTenant(): void
{
$query = new GetStudentsImageRightsQuery(tenantId: self::TENANT_ID);
$result = ($this->handler)($query);
self::assertCount(0, $result);
}
#[Test]
public function doesNotReturnStudentsFromOtherTenant(): void
{
$this->seedStudentsAndParent();
$query = new GetStudentsImageRightsQuery(
tenantId: '550e8400-e29b-41d4-a716-446655440099',
$dto = $this->createStudentImageRightsDto();
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
);
$result = ($this->handler)($query);
self::assertCount(0, $result);
$result = ($this->handler)(new GetStudentsImageRightsQuery(tenantId: 'tenant-1'));
self::assertCount(1, $result->items);
self::assertSame(1, $result->total);
}
#[Test]
public function returnsDtoWithCorrectFields(): void
public function mapsDtoFields(): void
{
$this->seedStudentsAndParent();
$query = new GetStudentsImageRightsQuery(
tenantId: self::TENANT_ID,
status: 'authorized',
$dto = $this->createStudentImageRightsDto();
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
);
$result = ($this->handler)($query);
self::assertCount(1, $result);
$dto = $result[0];
self::assertSame('Alice', $dto->firstName);
self::assertSame('Dupont', $dto->lastName);
self::assertSame('authorized', $dto->imageRightsStatus);
self::assertSame('Autorisé', $dto->imageRightsStatusLabel);
$result = ($this->handler)(new GetStudentsImageRightsQuery(tenantId: 'tenant-1'));
$item = $result->items[0];
self::assertSame('student-1', $item->id);
self::assertSame('Alice', $item->firstName);
self::assertSame('Dupont', $item->lastName);
self::assertSame('alice@test.com', $item->email);
self::assertSame('authorized', $item->imageRightsStatus);
self::assertSame('Autorise', $item->imageRightsStatusLabel);
self::assertSame('6eme A', $item->className);
}
private function seedStudentsAndParent(): void
#[Test]
public function paginatesResults(): void
{
$student1 = User::inviter(
email: new Email('alice@example.com'),
role: Role::ELEVE,
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: 'École Alpha',
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [], total: 50, page: 2, limit: 10),
);
$result = ($this->handler)(new GetStudentsImageRightsQuery(tenantId: 'tenant-1', page: 2, limit: 10));
self::assertSame(50, $result->total);
self::assertSame(2, $result->page);
self::assertSame(10, $result->limit);
}
#[Test]
public function cachesResult(): void
{
$dto = $this->createStudentImageRightsDto();
$this->reader->expects(self::once())->method('findPaginated')->willReturn(
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
);
$query = new GetStudentsImageRightsQuery(tenantId: 'tenant-1');
($this->handler)($query);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
}
#[Test]
public function clampsPageToMinimumOne(): void
{
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [], total: 0, page: 1, limit: 30),
);
$result = ($this->handler)(new GetStudentsImageRightsQuery(tenantId: 'tenant-1', page: -5));
self::assertSame(1, $result->page);
}
#[Test]
public function clampsLimitToMaximumHundred(): void
{
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [], total: 0, page: 1, limit: 100),
);
$result = ($this->handler)(new GetStudentsImageRightsQuery(tenantId: 'tenant-1', limit: 500));
self::assertSame(100, $result->limit);
}
private function createStudentImageRightsDto(): StudentImageRightsDto
{
return new StudentImageRightsDto(
id: 'student-1',
firstName: 'Alice',
lastName: 'Dupont',
invitedAt: new DateTimeImmutable('2026-01-15'),
dateNaissance: new DateTimeImmutable('2012-06-15'),
email: 'alice@test.com',
imageRightsStatus: 'authorized',
imageRightsStatusLabel: 'Autorise',
imageRightsUpdatedAt: new DateTimeImmutable('2026-02-01'),
className: '6eme A',
);
$student1->modifierDroitImage(
ImageRightsStatus::AUTHORIZED,
UserId::fromString('550e8400-e29b-41d4-a716-446655440099'),
new DateTimeImmutable('2026-02-01'),
);
$student2 = User::inviter(
email: new Email('bob@example.com'),
role: Role::ELEVE,
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: 'École Alpha',
firstName: 'Bob',
lastName: 'Martin',
invitedAt: new DateTimeImmutable('2026-01-15'),
dateNaissance: new DateTimeImmutable('2013-03-20'),
);
// Bob has default NOT_SPECIFIED
$parent = User::inviter(
email: new Email('parent@example.com'),
role: Role::PARENT,
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: 'École Alpha',
firstName: 'Pierre',
lastName: 'Dupont',
invitedAt: new DateTimeImmutable('2026-01-15'),
);
$this->userRepository->save($student1);
$this->userRepository->save($student2);
$this->userRepository->save($parent);
}
}

View File

@@ -4,206 +4,134 @@ declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Query\GetSubjects;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Port\PaginatedSubjectsReader;
use App\Administration\Application\Query\GetSubjects\GetSubjectsHandler;
use App\Administration\Application\Query\GetSubjects\GetSubjectsQuery;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Model\Subject\Subject;
use App\Administration\Domain\Model\Subject\SubjectCode;
use App\Administration\Domain\Model\Subject\SubjectColor;
use App\Administration\Domain\Model\Subject\SubjectName;
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySubjectRepository;
use App\Shared\Domain\Tenant\TenantId;
use App\Administration\Application\Query\GetSubjects\SubjectDto;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
final class GetSubjectsHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440020';
private InMemorySubjectRepository $subjectRepository;
private PaginatedSubjectsReader $reader;
private PaginatedQueryCache $cache;
private GetSubjectsHandler $handler;
protected function setUp(): void
{
$this->subjectRepository = new InMemorySubjectRepository();
$this->handler = new GetSubjectsHandler($this->subjectRepository);
$this->reader = $this->createMock(PaginatedSubjectsReader::class);
$this->cache = new PaginatedQueryCache(
new TagAwareAdapter(new ArrayAdapter()),
);
$this->handler = new GetSubjectsHandler($this->reader, $this->cache);
}
#[Test]
public function returnsAllActiveSubjectsForTenantAndSchool(): void
public function returnsItemsForTenant(): void
{
$this->seedSubjects();
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
$dto = $this->createSubjectDto();
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
);
$result = ($this->handler)($query);
self::assertCount(3, $result->items);
self::assertSame(3, $result->total);
self::assertSame(1, $result->page);
self::assertSame(30, $result->limit);
}
#[Test]
public function filtersSubjectsByName(): void
{
$this->seedSubjects();
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
search: 'Mathématiques',
);
$result = ($this->handler)($query);
$result = ($this->handler)(new GetSubjectsQuery(tenantId: 'tenant-1', schoolId: 'school-1'));
self::assertCount(1, $result->items);
self::assertSame('Mathématiques', $result->items[0]->name);
self::assertSame(1, $result->total);
}
#[Test]
public function filtersSubjectsByCode(): void
public function mapsDtoFields(): void
{
$this->seedSubjects();
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
search: 'FR',
$dto = $this->createSubjectDto();
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame('FR', $result->items[0]->code);
}
$result = ($this->handler)(new GetSubjectsQuery(tenantId: 'tenant-1', schoolId: 'school-1'));
#[Test]
public function searchIsCaseInsensitive(): void
{
$this->seedSubjects();
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
search: 'math',
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
$item = $result->items[0];
self::assertSame('subject-1', $item->id);
self::assertSame('Mathematiques', $item->name);
self::assertSame('MATH', $item->code);
self::assertSame('#3B82F6', $item->color);
self::assertSame('Maths avancees', $item->description);
self::assertSame('active', $item->status);
self::assertSame(2, $item->teacherCount);
self::assertSame(1, $item->classCount);
}
#[Test]
public function paginatesResults(): void
{
$this->seedSubjects();
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
page: 1,
limit: 2,
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [], total: 50, page: 2, limit: 10),
);
$result = ($this->handler)($query);
self::assertCount(2, $result->items);
self::assertSame(3, $result->total);
self::assertSame(1, $result->page);
self::assertSame(2, $result->limit);
self::assertSame(2, $result->totalPages());
$result = ($this->handler)(new GetSubjectsQuery(tenantId: 'tenant-1', schoolId: 'school-1', page: 2, limit: 10));
self::assertSame(50, $result->total);
self::assertSame(2, $result->page);
self::assertSame(10, $result->limit);
}
#[Test]
public function returnsSecondPage(): void
public function cachesResult(): void
{
$this->seedSubjects();
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
page: 2,
limit: 2,
$dto = $this->createSubjectDto();
$this->reader->expects(self::once())->method('findPaginated')->willReturn(
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
);
$query = new GetSubjectsQuery(tenantId: 'tenant-1', schoolId: 'school-1');
($this->handler)($query);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame(3, $result->total);
self::assertSame(2, $result->page);
}
#[Test]
public function returnsEmptyWhenNoMatches(): void
public function clampsPageToMinimumOne(): void
{
$this->seedSubjects();
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
search: 'nonexistent',
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [], total: 0, page: 1, limit: 30),
);
$result = ($this->handler)($query);
self::assertCount(0, $result->items);
self::assertSame(0, $result->total);
$result = ($this->handler)(new GetSubjectsQuery(tenantId: 'tenant-1', schoolId: 'school-1', page: -5));
self::assertSame(1, $result->page);
}
#[Test]
public function clampsInvalidPageToOne(): void
public function clampsLimitToMaximumHundred(): void
{
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
page: -1,
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [], total: 0, page: 1, limit: 100),
);
self::assertSame(1, $query->page);
$result = ($this->handler)(new GetSubjectsQuery(tenantId: 'tenant-1', schoolId: 'school-1', limit: 500));
self::assertSame(100, $result->limit);
}
#[Test]
public function clampsExcessiveLimitToMax(): void
private function createSubjectDto(): SubjectDto
{
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
limit: 999,
return new SubjectDto(
id: 'subject-1',
name: 'Mathematiques',
code: 'MATH',
color: '#3B82F6',
description: 'Maths avancees',
status: 'active',
createdAt: new DateTimeImmutable('2026-01-15'),
updatedAt: new DateTimeImmutable('2026-01-15'),
teacherCount: 2,
classCount: 1,
);
self::assertSame(100, $query->limit);
}
private function seedSubjects(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$schoolId = SchoolId::fromString(self::SCHOOL_ID);
$now = new DateTimeImmutable('2026-02-01 10:00:00');
$this->subjectRepository->save(Subject::creer(
tenantId: $tenantId,
schoolId: $schoolId,
name: new SubjectName('Mathématiques'),
code: new SubjectCode('MATH'),
color: new SubjectColor('#3B82F6'),
createdAt: $now,
));
$this->subjectRepository->save(Subject::creer(
tenantId: $tenantId,
schoolId: $schoolId,
name: new SubjectName('Français'),
code: new SubjectCode('FR'),
color: new SubjectColor('#EF4444'),
createdAt: $now,
));
$this->subjectRepository->save(Subject::creer(
tenantId: $tenantId,
schoolId: $schoolId,
name: new SubjectName('Histoire-Géo'),
code: new SubjectCode('HG'),
color: new SubjectColor('#10B981'),
createdAt: $now,
));
}
}

View File

@@ -4,337 +4,134 @@ declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Query\GetUsers;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Port\PaginatedUsersReader;
use App\Administration\Application\Query\GetUsers\GetUsersHandler;
use App\Administration\Application\Query\GetUsers\GetUsersQuery;
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\Policy\ConsentementParentalPolicy;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use App\Administration\Application\Query\GetUsers\UserDto;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
final class GetUsersHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private const string OTHER_TENANT_ID = '550e8400-e29b-41d4-a716-446655440099';
private InMemoryUserRepository $userRepository;
private Clock $clock;
private PaginatedUsersReader $reader;
private PaginatedQueryCache $cache;
private GetUsersHandler $handler;
protected function setUp(): void
{
$this->userRepository = new InMemoryUserRepository();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-02-07 10:00:00');
}
};
$this->handler = new GetUsersHandler($this->userRepository, $this->clock);
}
#[Test]
public function returnsAllUsersForTenant(): void
{
$this->seedUsers();
$query = new GetUsersQuery(tenantId: self::TENANT_ID);
$result = ($this->handler)($query);
self::assertCount(3, $result->items);
self::assertSame(3, $result->total);
self::assertSame(1, $result->page);
self::assertSame(30, $result->limit);
}
#[Test]
public function filtersUsersByRole(): void
{
$this->seedUsers();
$query = new GetUsersQuery(
tenantId: self::TENANT_ID,
role: Role::PROF->value,
$this->reader = $this->createMock(PaginatedUsersReader::class);
$this->cache = new PaginatedQueryCache(
new TagAwareAdapter(new ArrayAdapter()),
);
$result = ($this->handler)($query);
self::assertCount(2, $result->items);
self::assertSame(2, $result->total);
foreach ($result->items as $dto) {
self::assertSame(Role::PROF->value, $dto->role);
}
$this->handler = new GetUsersHandler($this->reader, $this->cache);
}
#[Test]
public function filtersUsersByStatut(): void
public function returnsUsersForTenant(): void
{
$this->seedUsers();
$query = new GetUsersQuery(
tenantId: self::TENANT_ID,
statut: 'pending',
$dto = $this->createUserDto();
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
);
$result = ($this->handler)($query);
self::assertCount(2, $result->items);
self::assertSame(2, $result->total);
foreach ($result->items as $dto) {
self::assertSame('pending', $dto->statut);
}
}
#[Test]
public function excludesUsersFromOtherTenants(): void
{
$this->seedUsers();
$otherUser = User::inviter(
email: new Email('other@example.com'),
role: Role::ADMIN,
tenantId: TenantId::fromString(self::OTHER_TENANT_ID),
schoolName: 'Autre École',
firstName: 'Autre',
lastName: 'User',
invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'),
);
$this->userRepository->save($otherUser);
$query = new GetUsersQuery(tenantId: self::TENANT_ID);
$result = ($this->handler)($query);
self::assertCount(3, $result->items);
self::assertSame(3, $result->total);
}
#[Test]
public function calculatesInvitationExpiree(): void
{
$user = User::inviter(
email: new Email('old@example.com'),
role: Role::PROF,
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: 'École Alpha',
firstName: 'Old',
lastName: 'Invitation',
invitedAt: new DateTimeImmutable('2026-01-25 10:00:00'),
);
$this->userRepository->save($user);
$query = new GetUsersQuery(tenantId: self::TENANT_ID);
$result = ($this->handler)($query);
$result = ($this->handler)(new GetUsersQuery(tenantId: 'tenant-1'));
self::assertCount(1, $result->items);
self::assertTrue($result->items[0]->invitationExpiree);
self::assertSame(1, $result->total);
}
#[Test]
public function mapsDtoFields(): void
{
$dto = $this->createUserDto();
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
);
$result = ($this->handler)(new GetUsersQuery(tenantId: 'tenant-1'));
$item = $result->items[0];
self::assertSame('user-1', $item->id);
self::assertSame('prof@test.com', $item->email);
self::assertSame('ROLE_PROF', $item->role);
self::assertSame('Dupont', $item->lastName);
}
#[Test]
public function paginatesResults(): void
{
$this->seedUsers();
$query = new GetUsersQuery(
tenantId: self::TENANT_ID,
page: 1,
limit: 2,
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [], total: 50, page: 2, limit: 10),
);
$result = ($this->handler)($query);
self::assertCount(2, $result->items);
self::assertSame(3, $result->total);
self::assertSame(1, $result->page);
self::assertSame(2, $result->limit);
self::assertSame(2, $result->totalPages());
}
$result = ($this->handler)(new GetUsersQuery(tenantId: 'tenant-1', page: 2, limit: 10));
#[Test]
public function returnsSecondPage(): void
{
$this->seedUsers();
$query = new GetUsersQuery(
tenantId: self::TENANT_ID,
page: 2,
limit: 2,
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame(3, $result->total);
self::assertSame(50, $result->total);
self::assertSame(2, $result->page);
self::assertSame(10, $result->limit);
}
#[Test]
public function searchesByFirstName(): void
public function cachesResult(): void
{
$this->seedUsers();
$query = new GetUsersQuery(
tenantId: self::TENANT_ID,
search: 'Jean',
$dto = $this->createUserDto();
$this->reader->expects(self::once())->method('findPaginated')->willReturn(
new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30),
);
$query = new GetUsersQuery(tenantId: 'tenant-1');
($this->handler)($query);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame('Jean', $result->items[0]->firstName);
}
#[Test]
public function searchesByLastName(): void
public function clampsPageToMinimumOne(): void
{
$this->seedUsers();
$query = new GetUsersQuery(
tenantId: self::TENANT_ID,
search: 'Martin',
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [], total: 0, page: 1, limit: 30),
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame('Martin', $result->items[0]->lastName);
$result = ($this->handler)(new GetUsersQuery(tenantId: 'tenant-1', page: -5));
self::assertSame(1, $result->page);
}
#[Test]
public function searchesByEmail(): void
public function clampsLimitToMaximumHundred(): void
{
$this->seedUsers();
$query = new GetUsersQuery(
tenantId: self::TENANT_ID,
search: 'parent@',
$this->reader->method('findPaginated')->willReturn(
new PaginatedResult(items: [], total: 0, page: 1, limit: 100),
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame('parent@example.com', $result->items[0]->email);
$result = ($this->handler)(new GetUsersQuery(tenantId: 'tenant-1', limit: 500));
self::assertSame(100, $result->limit);
}
#[Test]
public function searchIsCaseInsensitive(): void
private function createUserDto(): UserDto
{
$this->seedUsers();
$query = new GetUsersQuery(
tenantId: self::TENANT_ID,
search: 'jean',
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame('Jean', $result->items[0]->firstName);
}
#[Test]
public function searchCombinesWithRoleFilter(): void
{
$this->seedUsers();
$query = new GetUsersQuery(
tenantId: self::TENANT_ID,
role: Role::PROF->value,
search: 'Jean',
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame('Jean', $result->items[0]->firstName);
self::assertSame(Role::PROF->value, $result->items[0]->role);
}
#[Test]
public function searchResetsCountCorrectly(): void
{
$this->seedUsers();
$query = new GetUsersQuery(
tenantId: self::TENANT_ID,
search: 'nonexistent',
);
$result = ($this->handler)($query);
self::assertCount(0, $result->items);
self::assertSame(0, $result->total);
}
#[Test]
public function clampsPageZeroToOne(): void
{
$query = new GetUsersQuery(tenantId: self::TENANT_ID, page: 0);
self::assertSame(1, $query->page);
}
#[Test]
public function clampsNegativePageToOne(): void
{
$query = new GetUsersQuery(tenantId: self::TENANT_ID, page: -5);
self::assertSame(1, $query->page);
}
#[Test]
public function clampsLimitZeroToOne(): void
{
$query = new GetUsersQuery(tenantId: self::TENANT_ID, limit: 0);
self::assertSame(1, $query->limit);
}
#[Test]
public function clampsExcessiveLimitToHundred(): void
{
$query = new GetUsersQuery(tenantId: self::TENANT_ID, limit: 999);
self::assertSame(100, $query->limit);
}
#[Test]
public function clampsNegativeLimitToOne(): void
{
$query = new GetUsersQuery(tenantId: self::TENANT_ID, limit: -10);
self::assertSame(1, $query->limit);
}
private function seedUsers(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$teacher1 = User::inviter(
email: new Email('teacher1@example.com'),
role: Role::PROF,
tenantId: $tenantId,
schoolName: 'École Alpha',
return new UserDto(
id: 'user-1',
email: 'prof@test.com',
role: 'ROLE_PROF',
roleLabel: 'Enseignant',
roles: ['ROLE_PROF'],
firstName: 'Jean',
lastName: 'Dupont',
invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'),
statut: 'actif',
createdAt: new DateTimeImmutable('2026-01-15'),
invitedAt: new DateTimeImmutable('2026-01-10'),
activatedAt: new DateTimeImmutable('2026-01-12'),
blockedAt: null,
blockedReason: null,
invitationExpiree: false,
);
$this->userRepository->save($teacher1);
$teacher2 = User::inviter(
email: new Email('teacher2@example.com'),
role: Role::PROF,
tenantId: $tenantId,
schoolName: 'École Alpha',
firstName: 'Marie',
lastName: 'Martin',
invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'),
);
$teacher2->activer(
'$argon2id$hashed',
new DateTimeImmutable('2026-02-02 10:00:00'),
new ConsentementParentalPolicy($this->clock),
);
$this->userRepository->save($teacher2);
$parent = User::inviter(
email: new Email('parent@example.com'),
role: Role::PARENT,
tenantId: $tenantId,
schoolName: 'École Alpha',
firstName: 'Pierre',
lastName: 'Parent',
invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'),
);
$this->userRepository->save($parent);
}
}

View File

@@ -0,0 +1,348 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Service\Cache;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use App\Administration\Domain\Event\AffectationRetiree;
use App\Administration\Domain\Event\ClasseCreee;
use App\Administration\Domain\Event\DroitImageModifie;
use App\Administration\Domain\Event\EleveInscrit;
use App\Administration\Domain\Event\EnseignantAffecte;
use App\Administration\Domain\Event\ImportElevesTermine;
use App\Administration\Domain\Event\ImportEnseignantsTermine;
use App\Administration\Domain\Event\InvitationParentActivee;
use App\Administration\Domain\Event\InvitationRenvoyee;
use App\Administration\Domain\Event\MatiereCreee;
use App\Administration\Domain\Event\UtilisateurInvite;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Administration\Domain\Model\Invitation\ParentInvitationId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\SchoolClass\ClassName;
use App\Administration\Domain\Model\Subject\SubjectCode;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\Subject\SubjectName;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use App\Administration\Domain\Model\User\ImageRightsStatus;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\EventHandler\PaginatedQueryCacheInvalidator;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
final class PaginatedQueryCacheInvalidatorTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private PaginatedQueryCache $cache;
private PaginatedQueryCacheInvalidator $invalidator;
protected function setUp(): void
{
$this->cache = new PaginatedQueryCache(
new TagAwareAdapter(new ArrayAdapter()),
);
$this->invalidator = new PaginatedQueryCacheInvalidator($this->cache);
}
// === Users ===
#[Test]
public function utilisateurInviteInvalidatesUsersCache(): void
{
$this->warmCache('users', self::TENANT_ID);
$this->invalidator->onUtilisateurInvite($this->createUtilisateurInvite(self::TENANT_ID));
$this->assertCacheWasInvalidated('users', self::TENANT_ID);
}
#[Test]
public function invitationRenvoyeeInvalidatesUsersCache(): void
{
$this->warmCache('users', self::TENANT_ID);
$event = new InvitationRenvoyee(
userId: UserId::generate(),
email: 'test@example.com',
tenantId: TenantId::fromString(self::TENANT_ID),
occurredOn: new DateTimeImmutable(),
);
$this->invalidator->onInvitationRenvoyee($event);
$this->assertCacheWasInvalidated('users', self::TENANT_ID);
}
#[Test]
public function eleveInscritInvalidatesUsersAndImageRightsCache(): void
{
$this->warmCache('users', self::TENANT_ID);
$this->warmCache('students_image_rights', self::TENANT_ID);
$event = new EleveInscrit(
userId: UserId::generate(),
firstName: 'Alice',
lastName: 'Martin',
tenantId: TenantId::fromString(self::TENANT_ID),
occurredOn: new DateTimeImmutable(),
);
$this->invalidator->onEleveInscrit($event);
$this->assertCacheWasInvalidated('users', self::TENANT_ID);
$this->assertCacheWasInvalidated('students_image_rights', self::TENANT_ID);
}
// === Classes ===
#[Test]
public function classeCreeeInvalidatesClassesAndAssignmentsCache(): void
{
$this->warmCache('classes', self::TENANT_ID);
$this->warmCache('assignments', self::TENANT_ID);
$event = new ClasseCreee(
classId: ClassId::generate(),
tenantId: TenantId::fromString(self::TENANT_ID),
name: new ClassName('6ème A'),
level: null,
occurredOn: new DateTimeImmutable(),
);
$this->invalidator->onClasseCreee($event);
$this->assertCacheWasInvalidated('classes', self::TENANT_ID);
$this->assertCacheWasInvalidated('assignments', self::TENANT_ID);
}
// === Subjects ===
#[Test]
public function matiereCreeeInvalidatesSubjectsAndAssignmentsCache(): void
{
$this->warmCache('subjects', self::TENANT_ID);
$this->warmCache('assignments', self::TENANT_ID);
$event = new MatiereCreee(
subjectId: SubjectId::generate(),
tenantId: TenantId::fromString(self::TENANT_ID),
name: new SubjectName('Mathématiques'),
code: new SubjectCode('MATH'),
occurredOn: new DateTimeImmutable(),
);
$this->invalidator->onMatiereCreee($event);
$this->assertCacheWasInvalidated('subjects', self::TENANT_ID);
$this->assertCacheWasInvalidated('assignments', self::TENANT_ID);
}
// === Assignments ===
#[Test]
public function enseignantAffecteInvalidatesAssignmentsCache(): void
{
$this->warmCache('assignments', self::TENANT_ID);
$event = new EnseignantAffecte(
assignmentId: TeacherAssignmentId::generate(),
teacherId: UserId::generate(),
classId: ClassId::generate(),
subjectId: SubjectId::generate(),
tenantId: TenantId::fromString(self::TENANT_ID),
occurredOn: new DateTimeImmutable(),
);
$this->invalidator->onEnseignantAffecte($event);
$this->assertCacheWasInvalidated('assignments', self::TENANT_ID);
}
#[Test]
public function affectationRetireeInvalidatesAssignmentsCache(): void
{
$this->warmCache('assignments', self::TENANT_ID);
$event = new AffectationRetiree(
assignmentId: TeacherAssignmentId::generate(),
teacherId: UserId::generate(),
classId: ClassId::generate(),
subjectId: SubjectId::generate(),
tenantId: TenantId::fromString(self::TENANT_ID),
occurredOn: new DateTimeImmutable(),
);
$this->invalidator->onAffectationRetiree($event);
$this->assertCacheWasInvalidated('assignments', self::TENANT_ID);
}
// === Parent invitations ===
#[Test]
public function invitationParentActiveeInvalidatesParentInvitationsAndUsersCache(): void
{
$this->warmCache('parent_invitations', self::TENANT_ID);
$this->warmCache('users', self::TENANT_ID);
$event = new InvitationParentActivee(
invitationId: ParentInvitationId::generate(),
studentId: UserId::generate(),
parentUserId: UserId::generate(),
tenantId: TenantId::fromString(self::TENANT_ID),
occurredOn: new DateTimeImmutable(),
);
$this->invalidator->onInvitationParentActivee($event);
$this->assertCacheWasInvalidated('parent_invitations', self::TENANT_ID);
$this->assertCacheWasInvalidated('users', self::TENANT_ID);
}
// === Image rights ===
#[Test]
public function droitImageModifieInvalidatesImageRightsCache(): void
{
$this->warmCache('students_image_rights', self::TENANT_ID);
$event = new DroitImageModifie(
userId: UserId::generate(),
email: 'alice@example.com',
ancienStatut: ImageRightsStatus::NOT_SPECIFIED,
nouveauStatut: ImageRightsStatus::AUTHORIZED,
modifiePar: UserId::generate(),
tenantId: TenantId::fromString(self::TENANT_ID),
occurredOn: new DateTimeImmutable(),
);
$this->invalidator->onDroitImageModifie($event);
$this->assertCacheWasInvalidated('students_image_rights', self::TENANT_ID);
}
// === Imports ===
#[Test]
public function importElevesTermineInvalidatesUsersImageRightsAndClassesCache(): void
{
$this->warmCache('users', self::TENANT_ID);
$this->warmCache('students_image_rights', self::TENANT_ID);
$this->warmCache('classes', self::TENANT_ID);
$event = new ImportElevesTermine(
batchId: ImportBatchId::generate(),
tenantId: TenantId::fromString(self::TENANT_ID),
importedCount: 10,
errorCount: 0,
occurredOn: new DateTimeImmutable(),
);
$this->invalidator->onImportElevesTermine($event);
$this->assertCacheWasInvalidated('users', self::TENANT_ID);
$this->assertCacheWasInvalidated('students_image_rights', self::TENANT_ID);
$this->assertCacheWasInvalidated('classes', self::TENANT_ID);
}
#[Test]
public function importEnseignantsTermineInvalidatesUsersAndAssignmentsCache(): void
{
$this->warmCache('users', self::TENANT_ID);
$this->warmCache('assignments', self::TENANT_ID);
$event = new ImportEnseignantsTermine(
batchId: ImportBatchId::generate(),
tenantId: TenantId::fromString(self::TENANT_ID),
importedCount: 5,
errorCount: 0,
occurredOn: new DateTimeImmutable(),
);
$this->invalidator->onImportEnseignantsTermine($event);
$this->assertCacheWasInvalidated('users', self::TENANT_ID);
$this->assertCacheWasInvalidated('assignments', self::TENANT_ID);
}
// === Tenant isolation ===
#[Test]
public function invalidationDoesNotAffectOtherTenants(): void
{
$otherTenantId = '550e8400-e29b-41d4-a716-446655440099';
$this->warmCache('users', self::TENANT_ID);
$this->warmCache('users', $otherTenantId);
$this->invalidator->onUtilisateurInvite($this->createUtilisateurInvite(self::TENANT_ID));
$this->assertCacheWasInvalidated('users', self::TENANT_ID);
$this->assertCacheStillValid('users', $otherTenantId);
}
#[Test]
public function invalidationDoesNotAffectOtherEntityTypes(): void
{
$this->warmCache('users', self::TENANT_ID);
$this->warmCache('classes', self::TENANT_ID);
$this->invalidator->onUtilisateurInvite($this->createUtilisateurInvite(self::TENANT_ID));
$this->assertCacheWasInvalidated('users', self::TENANT_ID);
$this->assertCacheStillValid('classes', self::TENANT_ID);
}
private function warmCache(string $entityType, string $tenantId): void
{
$this->cache->getOrLoad(
$entityType,
$tenantId,
['page' => 1, 'limit' => 30],
static fn (): PaginatedResult => new PaginatedResult(items: ['original'], total: 1, page: 1, limit: 30),
);
}
private function assertCacheWasInvalidated(string $entityType, string $tenantId): void
{
$result = $this->cache->getOrLoad(
$entityType,
$tenantId,
['page' => 1, 'limit' => 30],
static fn (): PaginatedResult => new PaginatedResult(items: ['fresh'], total: 1, page: 1, limit: 30),
);
self::assertSame(['fresh'], $result->items, "Cache for {$entityType}/{$tenantId} should have been invalidated");
}
private function assertCacheStillValid(string $entityType, string $tenantId): void
{
$result = $this->cache->getOrLoad(
$entityType,
$tenantId,
['page' => 1, 'limit' => 30],
static fn (): PaginatedResult => new PaginatedResult(items: ['fresh'], total: 1, page: 1, limit: 30),
);
self::assertSame(['original'], $result->items, "Cache for {$entityType}/{$tenantId} should still contain original data");
}
private function createUtilisateurInvite(string $tenantId): UtilisateurInvite
{
return new UtilisateurInvite(
userId: UserId::generate(),
email: 'test@example.com',
role: 'ROLE_PROF',
firstName: 'Jean',
lastName: 'Dupont',
tenantId: TenantId::fromString($tenantId),
occurredOn: new DateTimeImmutable(),
);
}
}

View File

@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Service\Cache;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
final class PaginatedQueryCacheTest extends TestCase
{
private PaginatedQueryCache $cache;
protected function setUp(): void
{
$this->cache = new PaginatedQueryCache(
new TagAwareAdapter(new ArrayAdapter()),
);
}
#[Test]
public function loadsFromCallableOnCacheMiss(): void
{
$expected = new PaginatedResult(items: ['item1'], total: 1, page: 1, limit: 30);
$callCount = 0;
$result = $this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], static function () use ($expected, &$callCount): PaginatedResult {
++$callCount;
return $expected;
});
self::assertSame($expected, $result);
self::assertSame(1, $callCount);
}
#[Test]
public function returnsCachedResultOnHit(): void
{
$expected = new PaginatedResult(items: ['item1'], total: 1, page: 1, limit: 30);
$callCount = 0;
$loader = static function () use ($expected, &$callCount): PaginatedResult {
++$callCount;
return $expected;
};
$this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], $loader);
$result = $this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], $loader);
self::assertSame(1, $callCount);
self::assertEquals($expected, $result);
}
#[Test]
public function differentParamsProduceDifferentCacheEntries(): void
{
$result1 = new PaginatedResult(items: ['page1'], total: 2, page: 1, limit: 1);
$result2 = new PaginatedResult(items: ['page2'], total: 2, page: 2, limit: 1);
$this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], static fn (): PaginatedResult => $result1);
$actual = $this->cache->getOrLoad('users', 'tenant-1', ['page' => 2], static fn (): PaginatedResult => $result2);
self::assertEquals($result2, $actual);
}
#[Test]
public function invalidatesCacheByEntityTypeAndTenant(): void
{
$callCount = 0;
$loader = static function () use (&$callCount): PaginatedResult {
++$callCount;
return new PaginatedResult(items: [], total: 0, page: 1, limit: 30);
};
$this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], $loader);
self::assertSame(1, $callCount);
$this->cache->invalidate('users', 'tenant-1');
$this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], $loader);
self::assertSame(2, $callCount);
}
#[Test]
public function invalidationDoesNotAffectOtherEntityTypes(): void
{
$usersCallCount = 0;
$classesCallCount = 0;
$usersLoader = static function () use (&$usersCallCount): PaginatedResult {
++$usersCallCount;
return new PaginatedResult(items: [], total: 0, page: 1, limit: 30);
};
$classesLoader = static function () use (&$classesCallCount): PaginatedResult {
++$classesCallCount;
return new PaginatedResult(items: [], total: 0, page: 1, limit: 30);
};
$this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], $usersLoader);
$this->cache->getOrLoad('classes', 'tenant-1', ['page' => 1], $classesLoader);
$this->cache->invalidate('users', 'tenant-1');
$this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], $usersLoader);
$this->cache->getOrLoad('classes', 'tenant-1', ['page' => 1], $classesLoader);
self::assertSame(2, $usersCallCount);
self::assertSame(1, $classesCallCount);
}
#[Test]
public function invalidationDoesNotAffectOtherTenants(): void
{
$tenant1Count = 0;
$tenant2Count = 0;
$tenant1Loader = static function () use (&$tenant1Count): PaginatedResult {
++$tenant1Count;
return new PaginatedResult(items: [], total: 0, page: 1, limit: 30);
};
$tenant2Loader = static function () use (&$tenant2Count): PaginatedResult {
++$tenant2Count;
return new PaginatedResult(items: [], total: 0, page: 1, limit: 30);
};
$this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], $tenant1Loader);
$this->cache->getOrLoad('users', 'tenant-2', ['page' => 1], $tenant2Loader);
$this->cache->invalidate('users', 'tenant-1');
$this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], $tenant1Loader);
$this->cache->getOrLoad('users', 'tenant-2', ['page' => 1], $tenant2Loader);
self::assertSame(2, $tenant1Count);
self::assertSame(1, $tenant2Count);
}
#[Test]
public function paramOrderDoesNotAffectCacheKey(): void
{
$callCount = 0;
$loader = static function () use (&$callCount): PaginatedResult {
++$callCount;
return new PaginatedResult(items: [], total: 0, page: 1, limit: 30);
};
$this->cache->getOrLoad('users', 'tenant-1', ['page' => 1, 'role' => 'admin'], $loader);
$this->cache->getOrLoad('users', 'tenant-1', ['role' => 'admin', 'page' => 1], $loader);
self::assertSame(1, $callCount);
}
}