feat: Pagination et recherche des sections admin

Les listes admin (utilisateurs, classes, matières, affectations) chargeaient
toutes les données d'un coup, ce qui dégradait l'expérience avec un volume
croissant. La pagination côté serveur existait dans la config API Platform
mais aucun Provider ne l'exploitait.

Cette implémentation ajoute la pagination serveur (30 items/page, max 100)
avec recherche textuelle sur toutes les sections, des composants frontend
réutilisables (Pagination + SearchInput avec debounce), et la synchronisation
URL pour le partage de liens filtrés.

Les Query valident leurs paramètres (clamp page/limit, trim search) pour
éviter les abus. Les affectations utilisent des lookup maps pour résoudre
les noms sans N+1 queries. Les pages admin gèrent les race conditions
via AbortController.
This commit is contained in:
2026-02-15 13:54:51 +01:00
parent 88e7f319db
commit 76e16db0d8
57 changed files with 3123 additions and 181 deletions

View File

@@ -0,0 +1,368 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Query\GetAllAssignments;
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 DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
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 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(),
);
}
#[Test]
public function returnsAllActiveAssignmentsWithNames(): 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',
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame('Jean', $result->items[0]->teacherFirstName);
}
#[Test]
public function searchesByClassName(): void
{
$this->seedData();
$query = new GetAllAssignmentsQuery(
tenantId: self::TENANT_ID,
search: '6ème',
);
$result = ($this->handler)($query);
self::assertGreaterThanOrEqual(1, count($result->items));
self::assertStringContainsString('6ème', $result->items[0]->className);
}
#[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);
}
#[Test]
public function paginatesResults(): void
{
$this->seedData();
$query = new GetAllAssignmentsQuery(
tenantId: self::TENANT_ID,
page: 1,
limit: 1,
);
$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());
}
#[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(2, $result->page);
}
#[Test]
public function returnsEmptyWhenNoMatches(): void
{
$this->seedData();
$query = new GetAllAssignmentsQuery(
tenantId: self::TENANT_ID,
search: 'nonexistent',
);
$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
{
$query = new GetAllAssignmentsQuery(
tenantId: self::TENANT_ID,
page: -1,
);
self::assertSame(1, $query->page);
}
#[Test]
public function clampsExcessiveLimitToMax(): void
{
$query = new GetAllAssignmentsQuery(
tenantId: self::TENANT_ID,
limit: 999,
);
self::assertSame(100, $query->limit);
}
private function seedData(): void
{
$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,
);
$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);
}
}