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:
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Query\GetClasses;
|
||||
|
||||
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 DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
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 GetClassesHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->classRepository = new InMemoryClassRepository();
|
||||
$this->handler = new GetClassesHandler($this->classRepository);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsAllActiveClassesForTenantAndYear(): void
|
||||
{
|
||||
$this->seedClasses();
|
||||
|
||||
$query = new GetClassesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_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 filtersClassesByName(): void
|
||||
{
|
||||
$this->seedClasses();
|
||||
|
||||
$query = new GetClassesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
search: '6ème',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame('6ème A', $result->items[0]->name);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function filtersClassesByLevel(): void
|
||||
{
|
||||
$this->seedClasses();
|
||||
|
||||
$query = new GetClassesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
search: SchoolLevel::CM2->value,
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame('CM2 B', $result->items[0]->name);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function paginatesResults(): void
|
||||
{
|
||||
$this->seedClasses();
|
||||
|
||||
$query = new GetClassesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
page: 1,
|
||||
limit: 2,
|
||||
);
|
||||
$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());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsSecondPage(): void
|
||||
{
|
||||
$this->seedClasses();
|
||||
|
||||
$query = new GetClassesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
page: 2,
|
||||
limit: 2,
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame(3, $result->total);
|
||||
self::assertSame(2, $result->page);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsEmptyWhenNoMatches(): void
|
||||
{
|
||||
$this->seedClasses();
|
||||
|
||||
$query = new GetClassesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
search: 'nonexistent',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(0, $result->items);
|
||||
self::assertSame(0, $result->total);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clampsInvalidPageToOne(): void
|
||||
{
|
||||
$this->seedClasses();
|
||||
|
||||
$query = new GetClassesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
page: -1,
|
||||
);
|
||||
|
||||
self::assertSame(1, $query->page);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clampsExcessiveLimitToMax(): void
|
||||
{
|
||||
$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,
|
||||
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,
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user