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,41 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Dto;
/**
* Résultat paginé générique retourné par les Query Handlers.
*
* @template T
*/
final readonly class PaginatedResult
{
public const int DEFAULT_PAGE = 1;
public const int DEFAULT_LIMIT = 30;
public const int MAX_LIMIT = 100;
public const int MAX_SEARCH_LENGTH = 255;
/**
* @param T[] $items Les éléments de la page courante
* @param int $total Le nombre total d'éléments (toutes pages confondues)
* @param int $page La page courante (1-indexed)
* @param int $limit Le nombre d'éléments par page
*/
public function __construct(
public array $items,
public int $total,
public int $page,
public int $limit,
) {
}
public function totalPages(): int
{
if ($this->limit <= 0) {
return 0;
}
return (int) ceil($this->total / $this->limit);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetAllAssignments;
use DateTimeImmutable;
final readonly class AssignmentWithNamesDto
{
public function __construct(
public string $id,
public string $teacherId,
public string $teacherFirstName,
public string $teacherLastName,
public string $classId,
public string $className,
public string $subjectId,
public string $subjectName,
public string $academicYearId,
public string $status,
public DateTimeImmutable $startDate,
public ?DateTimeImmutable $endDate,
public DateTimeImmutable $createdAt,
) {
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetAllAssignments;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\SubjectRepository;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_slice;
use function count;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetAllAssignmentsHandler
{
public function __construct(
private TeacherAssignmentRepository $assignmentRepository,
private UserRepository $userRepository,
private ClassRepository $classRepository,
private SubjectRepository $subjectRepository,
private LoggerInterface $logger,
) {
}
/**
* @return PaginatedResult<AssignmentWithNamesDto>
*/
public function __invoke(GetAllAssignmentsQuery $query): PaginatedResult
{
$tenantId = TenantId::fromString($query->tenantId);
$assignments = $this->assignmentRepository->findAllActiveByTenant($tenantId);
// Build lookup maps for users, classes, and subjects
$users = $this->userRepository->findAllByTenant($tenantId);
/** @var array<string, array{firstName: string, lastName: string}> $userNames */
$userNames = [];
foreach ($users as $user) {
$userNames[(string) $user->id] = [
'firstName' => $user->firstName,
'lastName' => $user->lastName,
];
}
$classes = $this->classRepository->findAllActiveByTenant($tenantId);
/** @var array<string, string> $classNames */
$classNames = [];
foreach ($classes as $class) {
$classNames[(string) $class->id] = (string) $class->name;
}
$subjects = $this->subjectRepository->findAllActiveByTenant($tenantId);
/** @var array<string, string> $subjectNames */
$subjectNames = [];
foreach ($subjects as $subject) {
$subjectNames[(string) $subject->id] = (string) $subject->name;
}
$dtos = [];
foreach ($assignments as $assignment) {
$teacherId = (string) $assignment->teacherId;
$classId = (string) $assignment->classId;
$subjectId = (string) $assignment->subjectId;
if (!isset($userNames[$teacherId])) {
$this->logger->warning('Assignment {assignmentId} references unknown teacher {teacherId}', [
'assignmentId' => (string) $assignment->id,
'teacherId' => $teacherId,
]);
}
if (!isset($classNames[$classId])) {
$this->logger->warning('Assignment {assignmentId} references unknown class {classId}', [
'assignmentId' => (string) $assignment->id,
'classId' => $classId,
]);
}
if (!isset($subjectNames[$subjectId])) {
$this->logger->warning('Assignment {assignmentId} references unknown subject {subjectId}', [
'assignmentId' => (string) $assignment->id,
'subjectId' => $subjectId,
]);
}
$teacher = $userNames[$teacherId] ?? ['firstName' => '', 'lastName' => ''];
$dtos[] = new AssignmentWithNamesDto(
id: (string) $assignment->id,
teacherId: $teacherId,
teacherFirstName: $teacher['firstName'],
teacherLastName: $teacher['lastName'],
classId: $classId,
className: $classNames[$classId] ?? '',
subjectId: $subjectId,
subjectName: $subjectNames[$subjectId] ?? '',
academicYearId: (string) $assignment->academicYearId,
status: $assignment->status->value,
startDate: $assignment->startDate,
endDate: $assignment->endDate,
createdAt: $assignment->createdAt,
);
}
if ($query->search !== null && $query->search !== '') {
$searchLower = mb_strtolower($query->search);
$dtos = array_values(array_filter(
$dtos,
static fn (AssignmentWithNamesDto $dto) => str_contains(mb_strtolower($dto->teacherFirstName), $searchLower)
|| str_contains(mb_strtolower($dto->teacherLastName), $searchLower)
|| str_contains(mb_strtolower($dto->className), $searchLower)
|| str_contains(mb_strtolower($dto->subjectName), $searchLower),
));
}
$total = count($dtos);
$offset = ($query->page - 1) * $query->limit;
$items = array_slice($dtos, $offset, $query->limit);
return new PaginatedResult(
items: $items,
total: $total,
page: $query->page,
limit: $query->limit,
);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetAllAssignments;
use App\Administration\Application\Dto\PaginatedResult;
final readonly class GetAllAssignmentsQuery
{
public int $page;
public int $limit;
public ?string $search;
public function __construct(
public string $tenantId,
int $page = PaginatedResult::DEFAULT_PAGE,
int $limit = PaginatedResult::DEFAULT_LIMIT,
?string $search = null,
) {
$this->page = max(1, $page);
$this->limit = max(1, min(PaginatedResult::MAX_LIMIT, $limit));
$this->search = $search !== null ? mb_substr(trim($search), 0, PaginatedResult::MAX_SEARCH_LENGTH) : null;
}
}

View File

@@ -4,14 +4,16 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetClasses;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Repository\ClassRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_slice;
use function count;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Handler pour récupérer les classes actives d'un tenant.
*/
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetClassesHandler
{
@@ -21,18 +23,37 @@ final readonly class GetClassesHandler
}
/**
* @return ClassDto[]
* @return PaginatedResult<ClassDto>
*/
public function __invoke(GetClassesQuery $query): array
public function __invoke(GetClassesQuery $query): PaginatedResult
{
$classes = $this->classRepository->findActiveByTenantAndYear(
TenantId::fromString($query->tenantId),
AcademicYearId::fromString($query->academicYearId),
);
return array_map(
static fn ($class) => ClassDto::fromDomain($class),
$classes,
if ($query->search !== null && $query->search !== '') {
$searchLower = mb_strtolower($query->search);
$classes = array_filter(
$classes,
static fn ($class) => str_contains(mb_strtolower((string) $class->name), $searchLower)
|| ($class->level !== null && str_contains(mb_strtolower($class->level->value), $searchLower)),
);
$classes = array_values($classes);
}
$total = count($classes);
$offset = ($query->page - 1) * $query->limit;
$items = array_slice($classes, $offset, $query->limit);
return new PaginatedResult(
items: array_map(
static fn ($class) => ClassDto::fromDomain($class),
$items,
),
total: $total,
page: $query->page,
limit: $query->limit,
);
}
}

View File

@@ -4,14 +4,26 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetClasses;
use App\Administration\Application\Dto\PaginatedResult;
/**
* Query pour récupérer les classes actives d'un tenant pour une année scolaire.
*/
final readonly class GetClassesQuery
{
public int $page;
public int $limit;
public ?string $search;
public function __construct(
public string $tenantId,
public string $academicYearId,
int $page = PaginatedResult::DEFAULT_PAGE,
int $limit = PaginatedResult::DEFAULT_LIMIT,
?string $search = null,
) {
$this->page = max(1, $page);
$this->limit = max(1, min(PaginatedResult::MAX_LIMIT, $limit));
$this->search = $search !== null ? mb_substr(trim($search), 0, PaginatedResult::MAX_SEARCH_LENGTH) : null;
}
}

View File

@@ -4,17 +4,16 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetSubjects;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Repository\SubjectRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use function array_slice;
use function count;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Handler pour récupérer les matières actives d'un tenant.
*/
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetSubjectsHandler
{
@@ -24,25 +23,41 @@ final readonly class GetSubjectsHandler
}
/**
* @return SubjectDto[]
* @return PaginatedResult<SubjectDto>
*/
public function __invoke(GetSubjectsQuery $query): array
public function __invoke(GetSubjectsQuery $query): PaginatedResult
{
$subjects = $this->subjectRepository->findActiveByTenantAndSchool(
TenantId::fromString($query->tenantId),
SchoolId::fromString($query->schoolId),
);
// TODO: Récupérer les comptages d'enseignants et de classes
// quand les modules Affectations seront implémentés (T7)
if ($query->search !== null && $query->search !== '') {
$searchLower = mb_strtolower($query->search);
$subjects = array_filter(
$subjects,
static fn ($subject) => str_contains(mb_strtolower((string) $subject->name), $searchLower)
|| str_contains(mb_strtolower((string) $subject->code), $searchLower),
);
$subjects = array_values($subjects);
}
return array_map(
static fn ($subject) => SubjectDto::fromDomain(
$subject,
teacherCount: 0, // Placeholder - T7
classCount: 0, // Placeholder - T7
$total = count($subjects);
$offset = ($query->page - 1) * $query->limit;
$items = array_slice($subjects, $offset, $query->limit);
return new PaginatedResult(
items: array_map(
static fn ($subject) => SubjectDto::fromDomain(
$subject,
teacherCount: 0,
classCount: 0,
),
$items,
),
$subjects,
total: $total,
page: $query->page,
limit: $query->limit,
);
}
}

View File

@@ -4,14 +4,26 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetSubjects;
use App\Administration\Application\Dto\PaginatedResult;
/**
* Query pour récupérer les matières actives d'un tenant et d'une école.
*/
final readonly class GetSubjectsQuery
{
public int $page;
public int $limit;
public ?string $search;
public function __construct(
public string $tenantId,
public string $schoolId,
int $page = PaginatedResult::DEFAULT_PAGE,
int $limit = PaginatedResult::DEFAULT_LIMIT,
?string $search = null,
) {
$this->page = max(1, $page);
$this->limit = max(1, min(PaginatedResult::MAX_LIMIT, $limit));
$this->search = $search !== null ? mb_substr(trim($search), 0, PaginatedResult::MAX_SEARCH_LENGTH) : null;
}
}

View File

@@ -4,11 +4,16 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetUsers;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\StatutCompte;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use function array_slice;
use function count;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
@@ -21,15 +26,14 @@ final readonly class GetUsersHandler
}
/**
* @return UserDto[]
* @return PaginatedResult<UserDto>
*/
public function __invoke(GetUsersQuery $query): array
public function __invoke(GetUsersQuery $query): PaginatedResult
{
$users = $this->userRepository->findAllByTenant(
TenantId::fromString($query->tenantId),
);
// Apply filters
if ($query->role !== null) {
$filterRole = Role::tryFrom($query->role);
if ($filterRole !== null) {
@@ -50,9 +54,30 @@ final readonly class GetUsersHandler
}
}
return array_values(array_map(
fn ($user) => UserDto::fromDomain($user, $this->clock),
$users,
));
if ($query->search !== null && $query->search !== '') {
$searchLower = mb_strtolower($query->search);
$users = array_filter(
$users,
static fn ($user) => str_contains(mb_strtolower($user->firstName), $searchLower)
|| str_contains(mb_strtolower($user->lastName), $searchLower)
|| str_contains(mb_strtolower((string) $user->email), $searchLower),
);
}
$users = array_values($users);
$total = count($users);
$offset = ($query->page - 1) * $query->limit;
$items = array_slice($users, $offset, $query->limit);
return new PaginatedResult(
items: array_map(
fn ($user) => UserDto::fromDomain($user, $this->clock),
$items,
),
total: $total,
page: $query->page,
limit: $query->limit,
);
}
}

View File

@@ -4,12 +4,24 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetUsers;
use App\Administration\Application\Dto\PaginatedResult;
final readonly class GetUsersQuery
{
public int $page;
public int $limit;
public ?string $search;
public function __construct(
public string $tenantId,
public ?string $role = null,
public ?string $statut = null,
int $page = PaginatedResult::DEFAULT_PAGE,
int $limit = PaginatedResult::DEFAULT_LIMIT,
?string $search = null,
) {
$this->page = max(1, $page);
$this->limit = max(1, min(PaginatedResult::MAX_LIMIT, $limit));
$this->search = $search !== null ? mb_substr(trim($search), 0, PaginatedResult::MAX_SEARCH_LENGTH) : null;
}
}

View File

@@ -40,6 +40,13 @@ interface ClassRepository
AcademicYearId $academicYearId,
): array;
/**
* Retourne toutes les classes actives d'un tenant (toutes années confondues).
*
* @return SchoolClass[]
*/
public function findAllActiveByTenant(TenantId $tenantId): array;
/**
* Supprime une classe du repository.
*/

View File

@@ -40,6 +40,13 @@ interface SubjectRepository
SchoolId $schoolId,
): array;
/**
* Retourne toutes les matières actives d'un tenant (toutes écoles confondues).
*
* @return Subject[]
*/
public function findAllActiveByTenant(TenantId $tenantId): array;
/**
* Supprime une matière du repository.
*/

View File

@@ -67,4 +67,11 @@ interface TeacherAssignmentRepository
ClassId $classId,
TenantId $tenantId,
): array;
/**
* Retourne toutes les affectations actives d'un tenant.
*
* @return TeacherAssignment[]
*/
public function findAllActiveByTenant(TenantId $tenantId): array;
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\TraversablePaginator;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\GetClasses\GetClassesHandler;
use App\Administration\Application\Query\GetClasses\GetClassesQuery;
@@ -12,13 +13,14 @@ use App\Administration\Infrastructure\Api\Resource\ClassResource;
use App\Administration\Infrastructure\Security\ClassVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Infrastructure\Tenant\TenantContext;
use ArrayIterator;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* State Provider pour récupérer la liste des classes.
* State Provider pour récupérer la liste des classes avec pagination.
*
* @implements ProviderInterface<ClassResource>
*/
@@ -32,13 +34,9 @@ final readonly class ClassCollectionProvider implements ProviderInterface
) {
}
/**
* @return ClassResource[]
*/
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator
{
// Vérifier les permissions de lecture (sans sujet spécifique)
if (!$this->authorizationChecker->isGranted(ClassVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les classes.');
}
@@ -48,20 +46,28 @@ final readonly class ClassCollectionProvider implements ProviderInterface
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
/** @var array<string, string> $filters */
$filters = $context['filters'] ?? [];
$page = (int) ($filters['page'] ?? 1);
$itemsPerPage = (int) ($filters['itemsPerPage'] ?? 30);
$academicYearId = $this->academicYearResolver->resolve('current') ?? '';
if ($academicYearId === '') {
return [];
return new TraversablePaginator(new ArrayIterator([]), $page, $itemsPerPage, 0);
}
$query = new GetClassesQuery(
tenantId: $tenantId,
academicYearId: $academicYearId,
page: $page,
limit: $itemsPerPage,
search: isset($filters['search']) ? (string) $filters['search'] : null,
);
$classDtos = ($this->handler)($query);
$result = ($this->handler)($query);
return array_map(
$resources = array_map(
static function ($dto) {
$resource = new ClassResource();
$resource->id = $dto->id;
@@ -75,7 +81,14 @@ final readonly class ClassCollectionProvider implements ProviderInterface
return $resource;
},
$classDtos,
$result->items,
);
return new TraversablePaginator(
new ArrayIterator($resources),
$page,
$itemsPerPage,
$result->total,
);
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\TraversablePaginator;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\GetSubjects\GetSubjectsHandler;
use App\Administration\Application\Query\GetSubjects\GetSubjectsQuery;
@@ -15,13 +16,14 @@ use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map;
use ArrayIterator;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* State Provider pour récupérer la liste des matières.
* State Provider pour récupérer la liste des matières avec pagination.
*
* @implements ProviderInterface<SubjectResource>
*/
@@ -35,13 +37,9 @@ final readonly class SubjectCollectionProvider implements ProviderInterface
) {
}
/**
* @return SubjectResource[]
*/
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator
{
// Vérifier les permissions de lecture (sans sujet spécifique)
if (!$this->authorizationChecker->isGranted(SubjectVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les matières.');
}
@@ -52,14 +50,29 @@ final readonly class SubjectCollectionProvider implements ProviderInterface
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
$schoolId = $this->schoolIdResolver->resolveForTenant($tenantId);
/** @var array<string, string> $filters */
$filters = $context['filters'] ?? [];
$page = (int) ($filters['page'] ?? 1);
$itemsPerPage = (int) ($filters['itemsPerPage'] ?? 30);
$query = new GetSubjectsQuery(
tenantId: $tenantId,
schoolId: $schoolId,
page: $page,
limit: $itemsPerPage,
search: isset($filters['search']) ? (string) $filters['search'] : null,
);
$subjectDtos = ($this->handler)($query);
$result = ($this->handler)($query);
return array_map(SubjectResource::fromDto(...), $subjectDtos);
$resources = array_map(SubjectResource::fromDto(...), $result->items);
return new TraversablePaginator(
new ArrayIterator($resources),
$page,
$itemsPerPage,
$result->total,
);
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\TraversablePaginator;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\GetAllAssignments\AssignmentWithNamesDto;
use App\Administration\Application\Query\GetAllAssignments\GetAllAssignmentsHandler;
use App\Administration\Application\Query\GetAllAssignments\GetAllAssignmentsQuery;
use App\Administration\Infrastructure\Api\Resource\TeacherAssignmentResource;
use App\Administration\Infrastructure\Security\TeacherAssignmentVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map;
use ArrayIterator;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* State Provider pour récupérer toutes les affectations avec pagination et recherche.
*
* @implements ProviderInterface<TeacherAssignmentResource>
*/
final readonly class TeacherAssignmentsCollectionProvider implements ProviderInterface
{
public function __construct(
private GetAllAssignmentsHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator
{
if (!$this->authorizationChecker->isGranted(TeacherAssignmentVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les affectations.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
/** @var array<string, string> $filters */
$filters = $context['filters'] ?? [];
$page = (int) ($filters['page'] ?? 1);
$itemsPerPage = (int) ($filters['itemsPerPage'] ?? 30);
$query = new GetAllAssignmentsQuery(
tenantId: $tenantId,
page: $page,
limit: $itemsPerPage,
search: isset($filters['search']) ? (string) $filters['search'] : null,
);
$result = ($this->handler)($query);
$resources = array_map(
static function (AssignmentWithNamesDto $dto) {
$resource = new TeacherAssignmentResource();
$resource->id = $dto->id;
$resource->teacherId = $dto->teacherId;
$resource->classId = $dto->classId;
$resource->subjectId = $dto->subjectId;
$resource->academicYearId = $dto->academicYearId;
$resource->status = $dto->status;
$resource->startDate = $dto->startDate;
$resource->endDate = $dto->endDate;
$resource->createdAt = $dto->createdAt;
$resource->teacherFirstName = $dto->teacherFirstName;
$resource->teacherLastName = $dto->teacherLastName;
$resource->className = $dto->className;
$resource->subjectName = $dto->subjectName;
return $resource;
},
$result->items,
);
return new TraversablePaginator(
new ArrayIterator($resources),
$page,
$itemsPerPage,
$result->total,
);
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\TraversablePaginator;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\GetUsers\GetUsersHandler;
use App\Administration\Application\Query\GetUsers\GetUsersQuery;
@@ -14,13 +15,14 @@ use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map;
use ArrayIterator;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* State Provider pour récupérer la liste des utilisateurs avec filtres.
* State Provider pour récupérer la liste des utilisateurs avec filtres et pagination.
*
* @implements ProviderInterface<UserResource>
*/
@@ -33,11 +35,8 @@ final readonly class UserCollectionProvider implements ProviderInterface
) {
}
/**
* @return UserResource[]
*/
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator
{
if (!$this->authorizationChecker->isGranted(UserVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les utilisateurs.');
@@ -51,14 +50,27 @@ final readonly class UserCollectionProvider implements ProviderInterface
/** @var array<string, string> $filters */
$filters = $context['filters'] ?? [];
$page = (int) ($filters['page'] ?? 1);
$itemsPerPage = (int) ($filters['itemsPerPage'] ?? 30);
$query = new GetUsersQuery(
tenantId: $tenantId,
role: isset($filters['role']) ? (string) $filters['role'] : null,
statut: isset($filters['statut']) ? (string) $filters['statut'] : null,
page: $page,
limit: $itemsPerPage,
search: isset($filters['search']) ? (string) $filters['search'] : null,
);
$userDtos = ($this->handler)($query);
$result = ($this->handler)($query);
return array_map(UserResource::fromDto(...), $userDtos);
$resources = array_map(UserResource::fromDto(...), $result->items);
return new TraversablePaginator(
new ArrayIterator($resources),
$page,
$itemsPerPage,
$result->total,
);
}
}

View File

@@ -17,6 +17,7 @@ use App\Administration\Infrastructure\Api\Processor\RemoveTeacherAssignmentProce
use App\Administration\Infrastructure\Api\Provider\TeacherAssignmentItemProvider;
use App\Administration\Infrastructure\Api\Provider\TeacherAssignmentsByClassProvider;
use App\Administration\Infrastructure\Api\Provider\TeacherAssignmentsByTeacherProvider;
use App\Administration\Infrastructure\Api\Provider\TeacherAssignmentsCollectionProvider;
use DateTimeImmutable;
use Symfony\Component\Validator\Constraints as Assert;
@@ -29,6 +30,11 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'TeacherAssignment',
operations: [
new GetCollection(
uriTemplate: '/teacher-assignments',
provider: TeacherAssignmentsCollectionProvider::class,
name: 'get_all_teacher_assignments',
),
new GetCollection(
uriTemplate: '/teachers/{teacherId}/assignments',
uriVariables: [
@@ -90,6 +96,14 @@ final class TeacherAssignmentResource
public ?DateTimeImmutable $createdAt = null;
public ?string $teacherFirstName = null;
public ?string $teacherLastName = null;
public ?string $className = null;
public ?string $subjectName = null;
public static function fromDomain(TeacherAssignment $assignment): self
{
$resource = new self();

View File

@@ -130,6 +130,23 @@ final readonly class DoctrineClassRepository implements ClassRepository
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
#[Override]
public function findAllActiveByTenant(TenantId $tenantId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM school_classes
WHERE tenant_id = :tenant_id
AND status = :status
ORDER BY name ASC',
[
'tenant_id' => (string) $tenantId,
'status' => ClassStatus::ACTIVE->value,
],
);
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
#[Override]
public function delete(ClassId $id): void
{

View File

@@ -129,6 +129,23 @@ final readonly class DoctrineSubjectRepository implements SubjectRepository
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
#[Override]
public function findAllActiveByTenant(TenantId $tenantId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM subjects
WHERE tenant_id = :tenant_id
AND status = :status
ORDER BY name ASC',
[
'tenant_id' => (string) $tenantId,
'status' => SubjectStatus::ACTIVE->value,
],
);
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
#[Override]
public function delete(SubjectId $id): void
{

View File

@@ -197,6 +197,23 @@ final readonly class DoctrineTeacherAssignmentRepository implements TeacherAssig
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
#[Override]
public function findAllActiveByTenant(TenantId $tenantId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM teacher_assignments
WHERE tenant_id = :tenant_id
AND status = :status
ORDER BY created_at ASC',
[
'tenant_id' => (string) $tenantId,
'status' => AssignmentStatus::ACTIVE->value,
],
);
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
/**
* @param array<string, mixed> $row
*/

View File

@@ -82,6 +82,22 @@ final class InMemoryClassRepository implements ClassRepository
return $result;
}
#[Override]
public function findAllActiveByTenant(TenantId $tenantId): array
{
$result = [];
foreach ($this->byId as $class) {
if ($class->tenantId->equals($tenantId)
&& $class->status === ClassStatus::ACTIVE
) {
$result[] = $class;
}
}
return $result;
}
#[Override]
public function delete(ClassId $id): void
{

View File

@@ -99,6 +99,22 @@ final class InMemorySubjectRepository implements SubjectRepository
return $result;
}
#[Override]
public function findAllActiveByTenant(TenantId $tenantId): array
{
$result = [];
foreach ($this->byId as $subject) {
if ($subject->tenantId->equals($tenantId)
&& $subject->status === SubjectStatus::ACTIVE
) {
$result[] = $subject;
}
}
return $result;
}
#[Override]
public function delete(SubjectId $id): void
{

View File

@@ -134,4 +134,20 @@ final class InMemoryTeacherAssignmentRepository implements TeacherAssignmentRepo
return $result;
}
#[Override]
public function findAllActiveByTenant(TenantId $tenantId): array
{
$result = [];
foreach ($this->byId as $assignment) {
if ($assignment->tenantId->equals($tenantId)
&& $assignment->status === AssignmentStatus::ACTIVE
) {
$result[] = $assignment;
}
}
return $result;
}
}

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);
}
}

View File

@@ -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,
));
}
}

View File

@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Query\GetSubjects;
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 DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
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 GetSubjectsHandler $handler;
protected function setUp(): void
{
$this->subjectRepository = new InMemorySubjectRepository();
$this->handler = new GetSubjectsHandler($this->subjectRepository);
}
#[Test]
public function returnsAllActiveSubjectsForTenantAndSchool(): void
{
$this->seedSubjects();
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_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 filtersSubjectsByName(): void
{
$this->seedSubjects();
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
search: 'Mathématiques',
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame('Mathématiques', $result->items[0]->name);
}
#[Test]
public function filtersSubjectsByCode(): void
{
$this->seedSubjects();
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
search: 'FR',
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame('FR', $result->items[0]->code);
}
#[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);
}
#[Test]
public function paginatesResults(): void
{
$this->seedSubjects();
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_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->seedSubjects();
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_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->seedSubjects();
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
search: 'nonexistent',
);
$result = ($this->handler)($query);
self::assertCount(0, $result->items);
self::assertSame(0, $result->total);
}
#[Test]
public function clampsInvalidPageToOne(): void
{
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
page: -1,
);
self::assertSame(1, $query->page);
}
#[Test]
public function clampsExcessiveLimitToMax(): void
{
$query = new GetSubjectsQuery(
tenantId: self::TENANT_ID,
schoolId: self::SCHOOL_ID,
limit: 999,
);
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

@@ -46,7 +46,10 @@ final class GetUsersHandlerTest extends TestCase
$query = new GetUsersQuery(tenantId: self::TENANT_ID);
$result = ($this->handler)($query);
self::assertCount(3, $result);
self::assertCount(3, $result->items);
self::assertSame(3, $result->total);
self::assertSame(1, $result->page);
self::assertSame(30, $result->limit);
}
#[Test]
@@ -60,8 +63,9 @@ final class GetUsersHandlerTest extends TestCase
);
$result = ($this->handler)($query);
self::assertCount(2, $result);
foreach ($result as $dto) {
self::assertCount(2, $result->items);
self::assertSame(2, $result->total);
foreach ($result->items as $dto) {
self::assertSame(Role::PROF->value, $dto->role);
}
}
@@ -77,8 +81,9 @@ final class GetUsersHandlerTest extends TestCase
);
$result = ($this->handler)($query);
self::assertCount(2, $result);
foreach ($result as $dto) {
self::assertCount(2, $result->items);
self::assertSame(2, $result->total);
foreach ($result->items as $dto) {
self::assertSame('pending', $dto->statut);
}
}
@@ -88,7 +93,6 @@ final class GetUsersHandlerTest extends TestCase
{
$this->seedUsers();
// Add user to different tenant
$otherUser = User::inviter(
email: new Email('other@example.com'),
role: Role::ADMIN,
@@ -103,13 +107,13 @@ final class GetUsersHandlerTest extends TestCase
$query = new GetUsersQuery(tenantId: self::TENANT_ID);
$result = ($this->handler)($query);
self::assertCount(3, $result);
self::assertCount(3, $result->items);
self::assertSame(3, $result->total);
}
#[Test]
public function calculatesInvitationExpiree(): void
{
// Invited 10 days ago — should be expired
$user = User::inviter(
email: new Email('old@example.com'),
role: Role::PROF,
@@ -124,8 +128,171 @@ final class GetUsersHandlerTest extends TestCase
$query = new GetUsersQuery(tenantId: self::TENANT_ID);
$result = ($this->handler)($query);
self::assertCount(1, $result);
self::assertTrue($result[0]->invitationExpiree);
self::assertCount(1, $result->items);
self::assertTrue($result->items[0]->invitationExpiree);
}
#[Test]
public function paginatesResults(): void
{
$this->seedUsers();
$query = new GetUsersQuery(
tenantId: self::TENANT_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->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(2, $result->page);
}
#[Test]
public function searchesByFirstName(): void
{
$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 searchesByLastName(): void
{
$this->seedUsers();
$query = new GetUsersQuery(
tenantId: self::TENANT_ID,
search: 'Martin',
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame('Martin', $result->items[0]->lastName);
}
#[Test]
public function searchesByEmail(): void
{
$this->seedUsers();
$query = new GetUsersQuery(
tenantId: self::TENANT_ID,
search: 'parent@',
);
$result = ($this->handler)($query);
self::assertCount(1, $result->items);
self::assertSame('parent@example.com', $result->items[0]->email);
}
#[Test]
public function searchIsCaseInsensitive(): void
{
$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
@@ -152,7 +319,6 @@ final class GetUsersHandlerTest extends TestCase
lastName: 'Martin',
invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'),
);
// Activate teacher2
$teacher2->activer(
'$argon2id$hashed',
new DateTimeImmutable('2026-02-02 10:00:00'),