From 76e16db0d8a12ace213bd44093d511a616b55cee Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Sun, 15 Feb 2026 13:54:51 +0100 Subject: [PATCH] feat: Pagination et recherche des sections admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Application/Dto/PaginatedResult.php | 41 ++ .../AssignmentWithNamesDto.php | 27 ++ .../GetAllAssignmentsHandler.php | 134 +++++++ .../GetAllAssignmentsQuery.php | 25 ++ .../Query/GetClasses/GetClassesHandler.php | 37 +- .../Query/GetClasses/GetClassesQuery.php | 12 + .../Query/GetSubjects/GetSubjectsHandler.php | 43 +- .../Query/GetSubjects/GetSubjectsQuery.php | 12 + .../Query/GetUsers/GetUsersHandler.php | 39 +- .../Query/GetUsers/GetUsersQuery.php | 12 + .../Domain/Repository/ClassRepository.php | 7 + .../Domain/Repository/SubjectRepository.php | 7 + .../TeacherAssignmentRepository.php | 7 + .../Api/Provider/ClassCollectionProvider.php | 33 +- .../Provider/SubjectCollectionProvider.php | 29 +- .../TeacherAssignmentsCollectionProvider.php | 95 +++++ .../Api/Provider/UserCollectionProvider.php | 26 +- .../Resource/TeacherAssignmentResource.php | 14 + .../Doctrine/DoctrineClassRepository.php | 17 + .../Doctrine/DoctrineSubjectRepository.php | 17 + .../DoctrineTeacherAssignmentRepository.php | 17 + .../InMemory/InMemoryClassRepository.php | 16 + .../InMemory/InMemorySubjectRepository.php | 16 + .../InMemoryTeacherAssignmentRepository.php | 16 + .../GetAllAssignmentsHandlerTest.php | 368 ++++++++++++++++++ .../GetClasses/GetClassesHandlerTest.php | 215 ++++++++++ .../GetSubjects/GetSubjectsHandlerTest.php | 209 ++++++++++ .../Query/GetUsers/GetUsersHandlerTest.php | 188 ++++++++- frontend/e2e/activation-parent-link.spec.ts | 2 +- frontend/e2e/admin-search-pagination.spec.ts | 339 ++++++++++++++++ frontend/e2e/child-selector.spec.ts | 4 +- frontend/e2e/class-detail.spec.ts | 2 +- frontend/e2e/classes.spec.ts | 2 +- frontend/e2e/dashboard.spec.ts | 2 +- frontend/e2e/guardian-management.spec.ts | 2 +- frontend/e2e/login.spec.ts | 6 +- frontend/e2e/pedagogy.spec.ts | 2 +- frontend/e2e/periods.spec.ts | 2 +- frontend/e2e/role-access-control.spec.ts | 212 ++++++++++ frontend/e2e/sessions.spec.ts | 2 +- frontend/e2e/settings.spec.ts | 4 +- frontend/e2e/students.spec.ts | 32 +- frontend/e2e/subjects.spec.ts | 2 +- frontend/e2e/teacher-assignments.spec.ts | 2 +- frontend/e2e/user-blocking-session.spec.ts | 24 +- frontend/e2e/user-blocking.spec.ts | 26 +- frontend/e2e/user-creation.spec.ts | 8 +- frontend/eslint.config.js | 8 +- .../molecules/Pagination/Pagination.svelte | 167 ++++++++ .../molecules/SearchInput/SearchInput.svelte | 133 +++++++ .../src/routes/admin/assignments/+page.svelte | 163 +++++--- .../src/routes/admin/classes/+page.svelte | 79 +++- .../src/routes/admin/subjects/+page.svelte | 78 +++- frontend/src/routes/admin/users/+page.svelte | 90 ++++- frontend/svelte.config.js | 2 +- .../molecules/Pagination/Pagination.test.ts | 119 ++++++ .../molecules/SearchInput/SearchInput.test.ts | 111 ++++++ 57 files changed, 3123 insertions(+), 181 deletions(-) create mode 100644 backend/src/Administration/Application/Dto/PaginatedResult.php create mode 100644 backend/src/Administration/Application/Query/GetAllAssignments/AssignmentWithNamesDto.php create mode 100644 backend/src/Administration/Application/Query/GetAllAssignments/GetAllAssignmentsHandler.php create mode 100644 backend/src/Administration/Application/Query/GetAllAssignments/GetAllAssignmentsQuery.php create mode 100644 backend/src/Administration/Infrastructure/Api/Provider/TeacherAssignmentsCollectionProvider.php create mode 100644 backend/tests/Unit/Administration/Application/Query/GetAllAssignments/GetAllAssignmentsHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Query/GetClasses/GetClassesHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Query/GetSubjects/GetSubjectsHandlerTest.php create mode 100644 frontend/e2e/admin-search-pagination.spec.ts create mode 100644 frontend/e2e/role-access-control.spec.ts create mode 100644 frontend/src/lib/components/molecules/Pagination/Pagination.svelte create mode 100644 frontend/src/lib/components/molecules/SearchInput/SearchInput.svelte create mode 100644 frontend/tests/unit/lib/components/molecules/Pagination/Pagination.test.ts create mode 100644 frontend/tests/unit/lib/components/molecules/SearchInput/SearchInput.test.ts diff --git a/backend/src/Administration/Application/Dto/PaginatedResult.php b/backend/src/Administration/Application/Dto/PaginatedResult.php new file mode 100644 index 0000000..f5c88af --- /dev/null +++ b/backend/src/Administration/Application/Dto/PaginatedResult.php @@ -0,0 +1,41 @@ +limit <= 0) { + return 0; + } + + return (int) ceil($this->total / $this->limit); + } +} diff --git a/backend/src/Administration/Application/Query/GetAllAssignments/AssignmentWithNamesDto.php b/backend/src/Administration/Application/Query/GetAllAssignments/AssignmentWithNamesDto.php new file mode 100644 index 0000000..67ba697 --- /dev/null +++ b/backend/src/Administration/Application/Query/GetAllAssignments/AssignmentWithNamesDto.php @@ -0,0 +1,27 @@ + + */ + 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 $userNames */ + $userNames = []; + foreach ($users as $user) { + $userNames[(string) $user->id] = [ + 'firstName' => $user->firstName, + 'lastName' => $user->lastName, + ]; + } + + $classes = $this->classRepository->findAllActiveByTenant($tenantId); + /** @var array $classNames */ + $classNames = []; + foreach ($classes as $class) { + $classNames[(string) $class->id] = (string) $class->name; + } + + $subjects = $this->subjectRepository->findAllActiveByTenant($tenantId); + /** @var array $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, + ); + } +} diff --git a/backend/src/Administration/Application/Query/GetAllAssignments/GetAllAssignmentsQuery.php b/backend/src/Administration/Application/Query/GetAllAssignments/GetAllAssignmentsQuery.php new file mode 100644 index 0000000..4b10eb0 --- /dev/null +++ b/backend/src/Administration/Application/Query/GetAllAssignments/GetAllAssignmentsQuery.php @@ -0,0 +1,25 @@ +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; + } +} diff --git a/backend/src/Administration/Application/Query/GetClasses/GetClassesHandler.php b/backend/src/Administration/Application/Query/GetClasses/GetClassesHandler.php index f45f273..0ee1f02 100644 --- a/backend/src/Administration/Application/Query/GetClasses/GetClassesHandler.php +++ b/backend/src/Administration/Application/Query/GetClasses/GetClassesHandler.php @@ -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 */ - 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, ); } } diff --git a/backend/src/Administration/Application/Query/GetClasses/GetClassesQuery.php b/backend/src/Administration/Application/Query/GetClasses/GetClassesQuery.php index 96fe67b..9a04c39 100644 --- a/backend/src/Administration/Application/Query/GetClasses/GetClassesQuery.php +++ b/backend/src/Administration/Application/Query/GetClasses/GetClassesQuery.php @@ -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; } } diff --git a/backend/src/Administration/Application/Query/GetSubjects/GetSubjectsHandler.php b/backend/src/Administration/Application/Query/GetSubjects/GetSubjectsHandler.php index 529064a..8243df5 100644 --- a/backend/src/Administration/Application/Query/GetSubjects/GetSubjectsHandler.php +++ b/backend/src/Administration/Application/Query/GetSubjects/GetSubjectsHandler.php @@ -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 */ - 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, ); } } diff --git a/backend/src/Administration/Application/Query/GetSubjects/GetSubjectsQuery.php b/backend/src/Administration/Application/Query/GetSubjects/GetSubjectsQuery.php index a089c90..4c281e8 100644 --- a/backend/src/Administration/Application/Query/GetSubjects/GetSubjectsQuery.php +++ b/backend/src/Administration/Application/Query/GetSubjects/GetSubjectsQuery.php @@ -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; } } diff --git a/backend/src/Administration/Application/Query/GetUsers/GetUsersHandler.php b/backend/src/Administration/Application/Query/GetUsers/GetUsersHandler.php index f7d21b7..97172ec 100644 --- a/backend/src/Administration/Application/Query/GetUsers/GetUsersHandler.php +++ b/backend/src/Administration/Application/Query/GetUsers/GetUsersHandler.php @@ -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 */ - 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, + ); } } diff --git a/backend/src/Administration/Application/Query/GetUsers/GetUsersQuery.php b/backend/src/Administration/Application/Query/GetUsers/GetUsersQuery.php index c99bc01..d01c8b2 100644 --- a/backend/src/Administration/Application/Query/GetUsers/GetUsersQuery.php +++ b/backend/src/Administration/Application/Query/GetUsers/GetUsersQuery.php @@ -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; } } diff --git a/backend/src/Administration/Domain/Repository/ClassRepository.php b/backend/src/Administration/Domain/Repository/ClassRepository.php index cb123ff..937a194 100644 --- a/backend/src/Administration/Domain/Repository/ClassRepository.php +++ b/backend/src/Administration/Domain/Repository/ClassRepository.php @@ -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. */ diff --git a/backend/src/Administration/Domain/Repository/SubjectRepository.php b/backend/src/Administration/Domain/Repository/SubjectRepository.php index aeed2aa..4e6515a 100644 --- a/backend/src/Administration/Domain/Repository/SubjectRepository.php +++ b/backend/src/Administration/Domain/Repository/SubjectRepository.php @@ -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. */ diff --git a/backend/src/Administration/Domain/Repository/TeacherAssignmentRepository.php b/backend/src/Administration/Domain/Repository/TeacherAssignmentRepository.php index 94304c2..0102371 100644 --- a/backend/src/Administration/Domain/Repository/TeacherAssignmentRepository.php +++ b/backend/src/Administration/Domain/Repository/TeacherAssignmentRepository.php @@ -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; } diff --git a/backend/src/Administration/Infrastructure/Api/Provider/ClassCollectionProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/ClassCollectionProvider.php index a50aac5..284fbf4 100644 --- a/backend/src/Administration/Infrastructure/Api/Provider/ClassCollectionProvider.php +++ b/backend/src/Administration/Infrastructure/Api/Provider/ClassCollectionProvider.php @@ -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 */ @@ -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 $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, ); } } diff --git a/backend/src/Administration/Infrastructure/Api/Provider/SubjectCollectionProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/SubjectCollectionProvider.php index d6e0889..f941ec7 100644 --- a/backend/src/Administration/Infrastructure/Api/Provider/SubjectCollectionProvider.php +++ b/backend/src/Administration/Infrastructure/Api/Provider/SubjectCollectionProvider.php @@ -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 */ @@ -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 $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, + ); } } diff --git a/backend/src/Administration/Infrastructure/Api/Provider/TeacherAssignmentsCollectionProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/TeacherAssignmentsCollectionProvider.php new file mode 100644 index 0000000..94fbf97 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Provider/TeacherAssignmentsCollectionProvider.php @@ -0,0 +1,95 @@ + + */ +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 $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, + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Provider/UserCollectionProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/UserCollectionProvider.php index 3e3f768..8298505 100644 --- a/backend/src/Administration/Infrastructure/Api/Provider/UserCollectionProvider.php +++ b/backend/src/Administration/Infrastructure/Api/Provider/UserCollectionProvider.php @@ -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 */ @@ -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 $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, + ); } } diff --git a/backend/src/Administration/Infrastructure/Api/Resource/TeacherAssignmentResource.php b/backend/src/Administration/Infrastructure/Api/Resource/TeacherAssignmentResource.php index 4f8013c..6820e2c 100644 --- a/backend/src/Administration/Infrastructure/Api/Resource/TeacherAssignmentResource.php +++ b/backend/src/Administration/Infrastructure/Api/Resource/TeacherAssignmentResource.php @@ -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(); diff --git a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineClassRepository.php b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineClassRepository.php index d44042a..da7f8a6 100644 --- a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineClassRepository.php +++ b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineClassRepository.php @@ -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 { diff --git a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineSubjectRepository.php b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineSubjectRepository.php index ea91ae4..05ac261 100644 --- a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineSubjectRepository.php +++ b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineSubjectRepository.php @@ -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 { diff --git a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineTeacherAssignmentRepository.php b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineTeacherAssignmentRepository.php index 9202a63..3157c97 100644 --- a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineTeacherAssignmentRepository.php +++ b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineTeacherAssignmentRepository.php @@ -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 $row */ diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryClassRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryClassRepository.php index f534132..3687ad3 100644 --- a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryClassRepository.php +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryClassRepository.php @@ -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 { diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemorySubjectRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemorySubjectRepository.php index 58afafb..2abb792 100644 --- a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemorySubjectRepository.php +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemorySubjectRepository.php @@ -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 { diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryTeacherAssignmentRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryTeacherAssignmentRepository.php index b4a200c..cf0ce47 100644 --- a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryTeacherAssignmentRepository.php +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryTeacherAssignmentRepository.php @@ -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; + } } diff --git a/backend/tests/Unit/Administration/Application/Query/GetAllAssignments/GetAllAssignmentsHandlerTest.php b/backend/tests/Unit/Administration/Application/Query/GetAllAssignments/GetAllAssignmentsHandlerTest.php new file mode 100644 index 0000000..67ca821 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Query/GetAllAssignments/GetAllAssignmentsHandlerTest.php @@ -0,0 +1,368 @@ +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); + } +} diff --git a/backend/tests/Unit/Administration/Application/Query/GetClasses/GetClassesHandlerTest.php b/backend/tests/Unit/Administration/Application/Query/GetClasses/GetClassesHandlerTest.php new file mode 100644 index 0000000..97caac5 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Query/GetClasses/GetClassesHandlerTest.php @@ -0,0 +1,215 @@ +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, + )); + } +} diff --git a/backend/tests/Unit/Administration/Application/Query/GetSubjects/GetSubjectsHandlerTest.php b/backend/tests/Unit/Administration/Application/Query/GetSubjects/GetSubjectsHandlerTest.php new file mode 100644 index 0000000..aa7efca --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Query/GetSubjects/GetSubjectsHandlerTest.php @@ -0,0 +1,209 @@ +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, + )); + } +} diff --git a/backend/tests/Unit/Administration/Application/Query/GetUsers/GetUsersHandlerTest.php b/backend/tests/Unit/Administration/Application/Query/GetUsers/GetUsersHandlerTest.php index 1add1cb..8e7bb63 100644 --- a/backend/tests/Unit/Administration/Application/Query/GetUsers/GetUsersHandlerTest.php +++ b/backend/tests/Unit/Administration/Application/Query/GetUsers/GetUsersHandlerTest.php @@ -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'), diff --git a/frontend/e2e/activation-parent-link.spec.ts b/frontend/e2e/activation-parent-link.spec.ts index ac5f6ce..eae019e 100644 --- a/frontend/e2e/activation-parent-link.spec.ts +++ b/frontend/e2e/activation-parent-link.spec.ts @@ -96,7 +96,7 @@ test.describe('Activation with Parent-Child Auto-Link', () => { await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); await Promise.all([ - page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.waitForURL(/\/dashboard/, { timeout: 30000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); diff --git a/frontend/e2e/admin-search-pagination.spec.ts b/frontend/e2e/admin-search-pagination.spec.ts new file mode 100644 index 0000000..2adff13 --- /dev/null +++ b/frontend/e2e/admin-search-pagination.spec.ts @@ -0,0 +1,339 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const ADMIN_EMAIL = 'e2e-search-admin@example.com'; +const ADMIN_PASSWORD = 'SearchTest123'; + +const projectRoot = join(__dirname, '../..'); +const composeFile = join(projectRoot, 'compose.yaml'); + +test.describe('Admin Search & Pagination (Story 2.8b)', () => { + test.beforeAll(async () => { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); + }); + + async function loginAsAdmin(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(ADMIN_EMAIL); + await page.locator('#password').fill(ADMIN_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 30000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); + } + + // ============================================================================ + // USERS PAGE - Search & Pagination + // ============================================================================ + test.describe('Users Page', () => { + test('displays search input on users page', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/users`); + await page.waitForLoadState('networkidle'); + + const searchInput = page.locator('input[type="search"]'); + await expect(searchInput).toBeVisible({ timeout: 10000 }); + await expect(searchInput).toHaveAttribute('placeholder', /rechercher/i); + }); + + test('search filters users and shows results', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/users`); + await page.waitForLoadState('networkidle'); + + // Wait for initial load + await expect( + page.locator('.users-table, .empty-state') + ).toBeVisible({ timeout: 10000 }); + + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill('e2e-search-admin'); + + // Wait for debounce + API response + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + + // Should find our test admin user + await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 }); + }); + + test('search with no results shows empty state', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/users`); + await page.waitForLoadState('networkidle'); + + await expect( + page.locator('.users-table, .empty-state') + ).toBeVisible({ timeout: 10000 }); + + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill('zzz-nonexistent-user-xyz'); + + // Wait for debounce + API response + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + + // Should show "Aucun résultat" empty state + await expect(page.locator('.empty-state')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.empty-state')).toContainText(/aucun résultat/i); + }); + + test('search term is synced to URL', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/users`); + await page.waitForLoadState('networkidle'); + + await expect( + page.locator('.users-table, .empty-state') + ).toBeVisible({ timeout: 10000 }); + + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill('test-search'); + + // Wait for debounce + await page.waitForTimeout(500); + + // URL should contain search param + await expect(page).toHaveURL(/[?&]search=test-search/); + }); + + test('search term from URL is restored on page load', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/users?search=admin`); + await page.waitForLoadState('networkidle'); + + const searchInput = page.locator('input[type="search"]'); + await expect(searchInput).toHaveValue('admin'); + }); + + test('clear search button resets results', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/users`); + await page.waitForLoadState('networkidle'); + + await expect( + page.locator('.users-table, .empty-state') + ).toBeVisible({ timeout: 10000 }); + + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill('test'); + + // Wait for debounce + await page.waitForTimeout(500); + + // Clear button should appear + const clearButton = page.locator('.search-clear'); + await expect(clearButton).toBeVisible(); + await clearButton.click(); + + // Search input should be empty + await expect(searchInput).toHaveValue(''); + }); + + test('Escape key clears search', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/users`); + await page.waitForLoadState('networkidle'); + + await expect( + page.locator('.users-table, .empty-state') + ).toBeVisible({ timeout: 10000 }); + + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill('test'); + await page.waitForTimeout(500); + + await searchInput.press('Escape'); + await expect(searchInput).toHaveValue(''); + }); + + test('filters work together with search', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/users`); + await page.waitForLoadState('networkidle'); + + await expect( + page.locator('.users-table, .empty-state') + ).toBeVisible({ timeout: 10000 }); + + // Apply a role filter + await page.locator('#filter-role').selectOption('ROLE_ADMIN'); + await page.getByRole('button', { name: /filtrer/i }).click(); + await page.waitForLoadState('networkidle'); + + // Then search + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill('e2e-search'); + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + + // URL should have both params + await expect(page).toHaveURL(/search=e2e-search/); + }); + }); + + // ============================================================================ + // CLASSES PAGE - Search & Pagination + // ============================================================================ + test.describe('Classes Page', () => { + test('displays search input on classes page', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + await page.waitForLoadState('networkidle'); + + const searchInput = page.locator('input[type="search"]'); + await expect(searchInput).toBeVisible({ timeout: 10000 }); + await expect(searchInput).toHaveAttribute('placeholder', /rechercher/i); + }); + + test('search with no results shows empty state', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + await page.waitForLoadState('networkidle'); + + // Wait for initial load + await expect( + page.locator('.classes-grid, .empty-state, .loading-state') + ).toBeVisible({ timeout: 10000 }); + + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill('zzz-nonexistent-class-xyz'); + + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('.empty-state')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.empty-state')).toContainText(/aucun résultat/i); + }); + + test('search term is synced to URL', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + await page.waitForLoadState('networkidle'); + + await expect( + page.locator('.classes-grid, .empty-state, .loading-state') + ).toBeVisible({ timeout: 10000 }); + + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill('6eme'); + await page.waitForTimeout(500); + + await expect(page).toHaveURL(/[?&]search=6eme/); + }); + }); + + // ============================================================================ + // SUBJECTS PAGE - Search & Pagination + // ============================================================================ + test.describe('Subjects Page', () => { + test('displays search input on subjects page', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/subjects`); + await page.waitForLoadState('networkidle'); + + const searchInput = page.locator('input[type="search"]'); + await expect(searchInput).toBeVisible({ timeout: 10000 }); + await expect(searchInput).toHaveAttribute('placeholder', /rechercher/i); + }); + + test('search with no results shows empty state', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/subjects`); + await page.waitForLoadState('networkidle'); + + await expect( + page.locator('.subjects-grid, .empty-state, .loading-state') + ).toBeVisible({ timeout: 10000 }); + + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill('zzz-nonexistent-subject-xyz'); + + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('.empty-state')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.empty-state')).toContainText(/aucun résultat/i); + }); + + test('search term is synced to URL', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/subjects`); + await page.waitForLoadState('networkidle'); + + await expect( + page.locator('.subjects-grid, .empty-state, .loading-state') + ).toBeVisible({ timeout: 10000 }); + + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill('MATH'); + await page.waitForTimeout(500); + + await expect(page).toHaveURL(/[?&]search=MATH/); + }); + }); + + // ============================================================================ + // ASSIGNMENTS PAGE - Search & Pagination + // ============================================================================ + test.describe('Assignments Page', () => { + test('displays search input on assignments page', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/assignments`); + await page.waitForLoadState('networkidle'); + + const searchInput = page.locator('input[type="search"]'); + await expect(searchInput).toBeVisible({ timeout: 15000 }); + await expect(searchInput).toHaveAttribute('placeholder', /rechercher/i); + }); + + test('search with no results shows empty state', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/assignments`); + await page.waitForLoadState('networkidle'); + + // Wait for initial load + await expect( + page.locator('.table-container, .empty-state, .loading-state') + ).toBeVisible({ timeout: 15000 }); + + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill('zzz-nonexistent-teacher-xyz'); + + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('.empty-state')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.empty-state')).toContainText(/aucun résultat/i); + }); + + test('search term is synced to URL', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/assignments`); + await page.waitForLoadState('networkidle'); + + await expect( + page.locator('.table-container, .empty-state, .loading-state') + ).toBeVisible({ timeout: 15000 }); + + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill('Dupont'); + await page.waitForTimeout(500); + + await expect(page).toHaveURL(/[?&]search=Dupont/); + }); + }); +}); diff --git a/frontend/e2e/child-selector.spec.ts b/frontend/e2e/child-selector.spec.ts index 9157de2..1f71f7b 100644 --- a/frontend/e2e/child-selector.spec.ts +++ b/frontend/e2e/child-selector.spec.ts @@ -37,7 +37,7 @@ async function loginAsAdmin(page: Page) { await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); await Promise.all([ - page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.waitForURL(/\/dashboard/, { timeout: 30000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } @@ -132,7 +132,7 @@ test.describe('Child Selector', () => { await page.locator('#email').fill(PARENT_EMAIL); await page.locator('#password').fill(PARENT_PASSWORD); await Promise.all([ - page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.waitForURL(/\/dashboard/, { timeout: 30000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } diff --git a/frontend/e2e/class-detail.spec.ts b/frontend/e2e/class-detail.spec.ts index a543616..f317e87 100644 --- a/frontend/e2e/class-detail.spec.ts +++ b/frontend/e2e/class-detail.spec.ts @@ -38,7 +38,7 @@ test.describe('Admin Class Detail Page [P1]', () => { await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); await Promise.all([ - page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.waitForURL(/\/dashboard/, { timeout: 30000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } diff --git a/frontend/e2e/classes.spec.ts b/frontend/e2e/classes.spec.ts index f16ad4e..b38eb63 100644 --- a/frontend/e2e/classes.spec.ts +++ b/frontend/e2e/classes.spec.ts @@ -42,7 +42,7 @@ test.describe('Classes Management (Story 2.1)', () => { await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); await Promise.all([ - page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.waitForURL(/\/dashboard/, { timeout: 30000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } diff --git a/frontend/e2e/dashboard.spec.ts b/frontend/e2e/dashboard.spec.ts index 54ce74e..7a56e3a 100644 --- a/frontend/e2e/dashboard.spec.ts +++ b/frontend/e2e/dashboard.spec.ts @@ -427,7 +427,7 @@ test.describe('Dashboard', () => { await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); await Promise.all([ - page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.waitForURL(/\/dashboard/, { timeout: 30000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } diff --git a/frontend/e2e/guardian-management.spec.ts b/frontend/e2e/guardian-management.spec.ts index d86903f..75e9a07 100644 --- a/frontend/e2e/guardian-management.spec.ts +++ b/frontend/e2e/guardian-management.spec.ts @@ -94,7 +94,7 @@ test.describe('Guardian Management', () => { await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); await Promise.all([ - page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.waitForURL(/\/dashboard/, { timeout: 30000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } diff --git a/frontend/e2e/login.spec.ts b/frontend/e2e/login.spec.ts index f56be53..0a3becb 100644 --- a/frontend/e2e/login.spec.ts +++ b/frontend/e2e/login.spec.ts @@ -59,7 +59,7 @@ test.describe('Login Flow', () => { // Submit and wait for navigation to dashboard await Promise.all([ - page.waitForURL('/dashboard', { timeout: 10000 }), + page.waitForURL('/dashboard', { timeout: 30000 }), submitButton.click() ]); @@ -350,7 +350,7 @@ test.describe('Login Flow', () => { await submitButton.click(); // Should redirect to dashboard (successful login) - await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); + await expect(page).toHaveURL(/\/dashboard/, { timeout: 30000 }); }); test('user cannot login on different tenant', async ({ page }) => { @@ -383,7 +383,7 @@ test.describe('Login Flow', () => { await submitButton.click(); // Should redirect to dashboard (successful login) - await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); + await expect(page).toHaveURL(/\/dashboard/, { timeout: 30000 }); }); }); }); diff --git a/frontend/e2e/pedagogy.spec.ts b/frontend/e2e/pedagogy.spec.ts index b7c1ac4..ef1ae7b 100644 --- a/frontend/e2e/pedagogy.spec.ts +++ b/frontend/e2e/pedagogy.spec.ts @@ -46,7 +46,7 @@ test.describe('Pedagogy - Grading Mode Configuration (Story 2.4)', () => { await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); await Promise.all([ - page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.waitForURL(/\/dashboard/, { timeout: 30000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } diff --git a/frontend/e2e/periods.spec.ts b/frontend/e2e/periods.spec.ts index bfd1631..532e6d7 100644 --- a/frontend/e2e/periods.spec.ts +++ b/frontend/e2e/periods.spec.ts @@ -39,7 +39,7 @@ test.describe('Periods Management (Story 2.3)', () => { await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); await Promise.all([ - page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.waitForURL(/\/dashboard/, { timeout: 30000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } diff --git a/frontend/e2e/role-access-control.spec.ts b/frontend/e2e/role-access-control.spec.ts new file mode 100644 index 0000000..4914d53 --- /dev/null +++ b/frontend/e2e/role-access-control.spec.ts @@ -0,0 +1,212 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Extract port from PLAYWRIGHT_BASE_URL or use default (4173 matches playwright.config.ts) +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +// Test credentials per role +const ADMIN_EMAIL = 'e2e-rbac-admin@example.com'; +const ADMIN_PASSWORD = 'RbacAdmin123'; +const TEACHER_EMAIL = 'e2e-rbac-teacher@example.com'; +const TEACHER_PASSWORD = 'RbacTeacher123'; +const PARENT_EMAIL = 'e2e-rbac-parent@example.com'; +const PARENT_PASSWORD = 'RbacParent123'; + +test.describe('Role-Based Access Control [P0]', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async () => { + const projectRoot = join(__dirname, '../..'); + const composeFile = join(projectRoot, 'compose.yaml'); + + // Create admin user + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); + + // Create teacher user + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`, + { encoding: 'utf-8' } + ); + + // Create parent user + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT_EMAIL} --password=${PARENT_PASSWORD} --role=ROLE_PARENT 2>&1`, + { encoding: 'utf-8' } + ); + }); + + async function loginAs( + page: import('@playwright/test').Page, + email: string, + password: string + ) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(email); + await page.locator('#password').fill(password); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 30000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); + } + + // ============================================================================ + // Admin access - should have access to all /admin pages + // ============================================================================ + test.describe('Admin Access', () => { + test('[P0] admin user can access /admin/users page', async ({ page }) => { + await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD); + await page.goto(`${ALPHA_URL}/admin/users`); + + // Admin should see the users management page + await expect(page).toHaveURL(/\/admin\/users/); + await expect( + page.locator('.users-table, .empty-state') + ).toBeVisible({ timeout: 10000 }); + }); + + test('[P0] admin user can access /admin/classes page', async ({ page }) => { + await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD); + await page.goto(`${ALPHA_URL}/admin/classes`); + + await expect(page).toHaveURL(/\/admin\/classes/); + await expect( + page.getByRole('heading', { name: /gestion des classes/i }) + ).toBeVisible({ timeout: 10000 }); + }); + + test('[P0] admin user can access /admin/pedagogy page', async ({ page }) => { + await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD); + await page.goto(`${ALPHA_URL}/admin/pedagogy`); + + await expect(page).toHaveURL(/\/admin\/pedagogy/); + }); + + test('[P0] admin user can access /admin page', async ({ page }) => { + await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD); + await page.goto(`${ALPHA_URL}/admin`); + + await expect(page).toHaveURL(/\/admin/); + await expect( + page.getByRole('heading', { name: /administration/i }) + ).toBeVisible({ timeout: 10000 }); + }); + }); + + // ============================================================================ + // Teacher access - should NOT have access to /admin pages + // ============================================================================ + test.describe('Teacher Access Restrictions', () => { + test('[P0] teacher cannot access /admin/users page', async ({ page }) => { + await loginAs(page, TEACHER_EMAIL, TEACHER_PASSWORD); + await page.goto(`${ALPHA_URL}/admin/users`); + + // Admin guard redirects non-admin users to /dashboard + await page.waitForURL(/\/dashboard/, { timeout: 30000 }); + expect(page.url()).toContain('/dashboard'); + }); + + test('[P0] teacher cannot access /admin page', async ({ page }) => { + await loginAs(page, TEACHER_EMAIL, TEACHER_PASSWORD); + await page.goto(`${ALPHA_URL}/admin`); + + // Admin guard redirects non-admin users to /dashboard + await page.waitForURL(/\/dashboard/, { timeout: 30000 }); + expect(page.url()).toContain('/dashboard'); + }); + }); + + // ============================================================================ + // Parent access - should NOT have access to /admin pages + // ============================================================================ + test.describe('Parent Access Restrictions', () => { + test('[P0] parent cannot access /admin/users page', async ({ page }) => { + await loginAs(page, PARENT_EMAIL, PARENT_PASSWORD); + await page.goto(`${ALPHA_URL}/admin/users`); + + // Admin guard redirects non-admin users to /dashboard + await page.waitForURL(/\/dashboard/, { timeout: 30000 }); + expect(page.url()).toContain('/dashboard'); + }); + + test('[P0] parent cannot access /admin/classes page', async ({ page }) => { + await loginAs(page, PARENT_EMAIL, PARENT_PASSWORD); + await page.goto(`${ALPHA_URL}/admin/classes`); + + // Admin guard redirects non-admin users to /dashboard + await page.waitForURL(/\/dashboard/, { timeout: 30000 }); + expect(page.url()).toContain('/dashboard'); + }); + + test('[P0] parent cannot access /admin page', async ({ page }) => { + await loginAs(page, PARENT_EMAIL, PARENT_PASSWORD); + await page.goto(`${ALPHA_URL}/admin`); + + // Admin guard redirects non-admin users to /dashboard + await page.waitForURL(/\/dashboard/, { timeout: 30000 }); + expect(page.url()).toContain('/dashboard'); + }); + }); + + // ============================================================================ + // Unauthenticated user - should be redirected to /login + // ============================================================================ + test.describe('Unauthenticated Access', () => { + test('[P0] unauthenticated user is redirected from /settings/sessions to /login', async ({ page }) => { + // Clear any existing session + await page.context().clearCookies(); + + await page.goto(`${ALPHA_URL}/settings/sessions`); + + // Should be redirected to login + await expect(page).toHaveURL(/\/login/, { timeout: 10000 }); + }); + + test('[P0] unauthenticated user is redirected from /admin/users to /login', async ({ page }) => { + await page.context().clearCookies(); + + await page.goto(`${ALPHA_URL}/admin/users`); + + // Should be redirected away from /admin/users (to /login or /dashboard) + await page.waitForURL((url) => !url.toString().includes('/admin/users'), { timeout: 10000 }); + expect(page.url()).not.toContain('/admin/users'); + }); + }); + + // ============================================================================ + // Navigation reflects role permissions + // ============================================================================ + test.describe('Navigation Reflects Permissions', () => { + test('[P0] admin layout shows admin navigation links', async ({ page }) => { + await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD); + await page.goto(`${ALPHA_URL}/admin`); + + // Admin layout should show navigation links (scoped to header nav to avoid action cards) + const nav = page.locator('.header-nav'); + await expect(nav.getByRole('link', { name: 'Utilisateurs' })).toBeVisible({ timeout: 15000 }); + await expect(nav.getByRole('link', { name: 'Classes' })).toBeVisible(); + }); + + test('[P0] teacher sees dashboard without admin navigation', async ({ page }) => { + await loginAs(page, TEACHER_EMAIL, TEACHER_PASSWORD); + + // Teacher should be on dashboard + await expect(page).toHaveURL(/\/dashboard/); + + // Teacher should not see admin-specific navigation in the dashboard layout + // The dashboard header should not have admin links like "Utilisateurs" + const adminUsersLink = page.locator('.header-nav').getByRole('link', { name: 'Utilisateurs' }); + await expect(adminUsersLink).not.toBeVisible(); + }); + }); +}); diff --git a/frontend/e2e/sessions.spec.ts b/frontend/e2e/sessions.spec.ts index 728fc11..d6780cf 100644 --- a/frontend/e2e/sessions.spec.ts +++ b/frontend/e2e/sessions.spec.ts @@ -49,7 +49,7 @@ async function login(page: import('@playwright/test').Page, email: string) { await page.locator('#email').fill(email); await page.locator('#password').fill(TEST_PASSWORD); await page.getByRole('button', { name: /se connecter/i }).click(); - await page.waitForURL(getTenantUrl('/dashboard'), { timeout: 10000 }); + await page.waitForURL(getTenantUrl('/dashboard'), { timeout: 30000 }); } test.describe('Sessions Management', () => { diff --git a/frontend/e2e/settings.spec.ts b/frontend/e2e/settings.spec.ts index 9dd0a78..df3ab90 100644 --- a/frontend/e2e/settings.spec.ts +++ b/frontend/e2e/settings.spec.ts @@ -57,7 +57,7 @@ test.describe('Settings Page [P1]', () => { await page.locator('#email').fill(email); await page.locator('#password').fill(USER_PASSWORD); await Promise.all([ - page.waitForURL(getTenantUrl('/dashboard'), { timeout: 10000 }), + page.waitForURL(getTenantUrl('/dashboard'), { timeout: 30000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } @@ -197,6 +197,6 @@ test.describe('Settings Page [P1]', () => { // Click the logo button await page.locator('.logo-button').click(); - await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); + await expect(page).toHaveURL(/\/dashboard/, { timeout: 30000 }); }); }); diff --git a/frontend/e2e/students.spec.ts b/frontend/e2e/students.spec.ts index 8c64106..33e9222 100644 --- a/frontend/e2e/students.spec.ts +++ b/frontend/e2e/students.spec.ts @@ -86,7 +86,7 @@ test.describe('Student Management', () => { await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); await Promise.all([ - page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.waitForURL(/\/dashboard/, { timeout: 30000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } @@ -100,11 +100,11 @@ test.describe('Student Management', () => { * Waiting for one of these ensures the component is interactive. */ async function waitForGuardianSection(page: import('@playwright/test').Page) { - await expect(page.locator('.guardian-section')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.guardian-section')).toBeVisible({ timeout: 15000 }); await expect( page.getByText(/aucun parent\/tuteur lié/i) .or(page.locator('.guardian-list')) - ).toBeVisible({ timeout: 10000 }); + ).toBeVisible({ timeout: 15000 }); } // ============================================================================ @@ -151,8 +151,20 @@ test.describe('Student Management', () => { await waitForGuardianSection(page); + // Clean up guardians if any exist (cross-browser interference: parallel + // browser projects share the same DB, so another browser may have added + // guardians between our beforeAll cleanup and this test). + while (await page.locator('.guardian-item').count() > 0) { + const item = page.locator('.guardian-item').first(); + await item.getByRole('button', { name: /retirer/i }).click(); + await expect(item.getByText(/confirmer/i)).toBeVisible({ timeout: 5000 }); + await item.getByRole('button', { name: /oui/i }).click(); + await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); + await page.waitForTimeout(500); + } + // Should show the empty state - await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible(); + await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible({ timeout: 15000 }); // The "add guardian" button should be visible await expect(page.getByRole('button', { name: /ajouter un parent/i })).toBeVisible(); @@ -342,6 +354,12 @@ test.describe('Student Management', () => { page.locator('.users-table, .empty-state') ).toBeVisible({ timeout: 10000 }); + // Search for the student user (pagination may hide them beyond page 1) + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill(STUDENT_EMAIL); + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + // The student email should appear in the users table await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 }); const studentRow = page.locator('tr', { has: page.locator(`text=${STUDENT_EMAIL}`) }); @@ -355,6 +373,12 @@ test.describe('Student Management', () => { // Wait for users table await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 }); + // Search for the student user (pagination may hide them beyond page 1) + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill(STUDENT_EMAIL); + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + // Find the student row and verify role const studentRow = page.locator('tr', { has: page.locator(`text=${STUDENT_EMAIL}`) }); await expect(studentRow).toContainText(/élève/i); diff --git a/frontend/e2e/subjects.spec.ts b/frontend/e2e/subjects.spec.ts index bd475ef..bc7bdeb 100644 --- a/frontend/e2e/subjects.spec.ts +++ b/frontend/e2e/subjects.spec.ts @@ -42,7 +42,7 @@ test.describe('Subjects Management (Story 2.2)', () => { await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); await Promise.all([ - page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.waitForURL(/\/dashboard/, { timeout: 30000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } diff --git a/frontend/e2e/teacher-assignments.spec.ts b/frontend/e2e/teacher-assignments.spec.ts index 0970f5b..f4bbb05 100644 --- a/frontend/e2e/teacher-assignments.spec.ts +++ b/frontend/e2e/teacher-assignments.spec.ts @@ -49,7 +49,7 @@ async function loginAsAdmin(page: import('@playwright/test').Page) { await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); await Promise.all([ - page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.waitForURL(/\/dashboard/, { timeout: 30000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } diff --git a/frontend/e2e/user-blocking-session.spec.ts b/frontend/e2e/user-blocking-session.spec.ts index 0be287c..35ff0eb 100644 --- a/frontend/e2e/user-blocking-session.spec.ts +++ b/frontend/e2e/user-blocking-session.spec.ts @@ -51,7 +51,7 @@ test.describe('User Blocking Mid-Session [P1]', () => { await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); await Promise.all([ - page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.waitForURL(/\/dashboard/, { timeout: 30000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } @@ -61,7 +61,7 @@ test.describe('User Blocking Mid-Session [P1]', () => { await page.locator('#email').fill(TARGET_EMAIL); await page.locator('#password').fill(TARGET_PASSWORD); await Promise.all([ - page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.waitForURL(/\/dashboard/, { timeout: 30000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } @@ -70,6 +70,12 @@ test.describe('User Blocking Mid-Session [P1]', () => { await page.goto(`${ALPHA_URL}/admin/users`); await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 }); + // Search for the target user (pagination may hide them beyond page 1) + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill(TARGET_EMAIL); + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) }); await expect(targetRow).toBeVisible(); @@ -93,6 +99,12 @@ test.describe('User Blocking Mid-Session [P1]', () => { await page.goto(`${ALPHA_URL}/admin/users`); await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 }); + // Search for the target user (pagination may hide them beyond page 1) + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill(TARGET_EMAIL); + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) }); await expect(targetRow).toBeVisible(); @@ -181,7 +193,7 @@ test.describe('User Blocking Mid-Session [P1]', () => { await userPage.getByRole('button', { name: /se connecter/i }).click(); // Should redirect to dashboard (successful login) - await expect(userPage).toHaveURL(/\/dashboard/, { timeout: 10000 }); + await expect(userPage).toHaveURL(/\/dashboard/, { timeout: 30000 }); } finally { await userContext.close(); } @@ -196,6 +208,12 @@ test.describe('User Blocking Mid-Session [P1]', () => { await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 }); + // Search for the admin user (pagination may hide them beyond page 1) + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill(ADMIN_EMAIL); + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + // Find the admin's own row const adminRow = page.locator('tr', { has: page.locator(`text=${ADMIN_EMAIL}`) }); await expect(adminRow).toBeVisible(); diff --git a/frontend/e2e/user-blocking.spec.ts b/frontend/e2e/user-blocking.spec.ts index d337ed3..c888938 100644 --- a/frontend/e2e/user-blocking.spec.ts +++ b/frontend/e2e/user-blocking.spec.ts @@ -41,7 +41,7 @@ test.describe('User Blocking', () => { await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); await Promise.all([ - page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.waitForURL(/\/dashboard/, { timeout: 30000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } @@ -53,6 +53,12 @@ test.describe('User Blocking', () => { // Wait for users table to load await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 }); + // Search for the target user (pagination may hide them beyond page 1) + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill(TARGET_EMAIL); + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + // Find the target user row const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) }); await expect(targetRow).toBeVisible(); @@ -86,6 +92,12 @@ test.describe('User Blocking', () => { await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 }); + // Search for the target user (pagination may hide them beyond page 1) + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill(TARGET_EMAIL); + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + // Find the suspended target user row const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) }); await expect(targetRow).toBeVisible(); @@ -111,6 +123,12 @@ test.describe('User Blocking', () => { await page.goto(`${ALPHA_URL}/admin/users`); await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 }); + // Search for the target user (pagination may hide them beyond page 1) + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill(TARGET_EMAIL); + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) }); await expect(async () => { await targetRow.getByRole('button', { name: /bloquer/i }).click(); @@ -141,6 +159,12 @@ test.describe('User Blocking', () => { await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 }); + // Search for the admin user (pagination may hide them beyond page 1) + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill(ADMIN_EMAIL); + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + // Find the admin's own row const adminRow = page.locator('tr', { has: page.locator(`text=${ADMIN_EMAIL}`) }); await expect(adminRow).toBeVisible(); diff --git a/frontend/e2e/user-creation.spec.ts b/frontend/e2e/user-creation.spec.ts index df45753..39054c9 100644 --- a/frontend/e2e/user-creation.spec.ts +++ b/frontend/e2e/user-creation.spec.ts @@ -34,7 +34,7 @@ test.describe('User Creation', () => { await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); await Promise.all([ - page.waitForURL(/\/dashboard/, { timeout: 10000 }), + page.waitForURL(/\/dashboard/, { timeout: 30000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } @@ -71,6 +71,12 @@ test.describe('User Creation', () => { await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.alert-success')).toContainText(INVITED_EMAIL); + // Search for the newly created user (pagination may hide them beyond page 1) + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill(INVITED_EMAIL); + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); + // Verify the user appears in the table await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 }); const newUserRow = page.locator('tr', { has: page.locator(`text=${INVITED_EMAIL}`) }); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 69df5a3..165c036 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -80,7 +80,13 @@ export default tseslint.config( HTMLDivElement: 'readonly', setInterval: 'readonly', clearInterval: 'readonly', - URLSearchParams: 'readonly' + URLSearchParams: 'readonly', + HTMLInputElement: 'readonly', + KeyboardEvent: 'readonly', + AbortController: 'readonly', + DOMException: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly' } }, plugins: { diff --git a/frontend/src/lib/components/molecules/Pagination/Pagination.svelte b/frontend/src/lib/components/molecules/Pagination/Pagination.svelte new file mode 100644 index 0000000..a2d96da --- /dev/null +++ b/frontend/src/lib/components/molecules/Pagination/Pagination.svelte @@ -0,0 +1,167 @@ + + +{#if totalPages > 1} + +{/if} + + diff --git a/frontend/src/lib/components/molecules/SearchInput/SearchInput.svelte b/frontend/src/lib/components/molecules/SearchInput/SearchInput.svelte new file mode 100644 index 0000000..4a782ae --- /dev/null +++ b/frontend/src/lib/components/molecules/SearchInput/SearchInput.svelte @@ -0,0 +1,133 @@ + + +
+ + + {#if inputValue} + + {/if} +
+ + diff --git a/frontend/src/routes/admin/assignments/+page.svelte b/frontend/src/routes/admin/assignments/+page.svelte index fc9bf6e..378f6f5 100644 --- a/frontend/src/routes/admin/assignments/+page.svelte +++ b/frontend/src/routes/admin/assignments/+page.svelte @@ -1,7 +1,11 @@ @@ -282,17 +312,29 @@ {/if} + + {#if isLoading} -
+

Chargement des affectations...

- {:else if activeAssignments.length === 0} + {:else if assignments.length === 0}
📋 -

Aucune affectation

-

Commencez par affecter un enseignant à une classe et une matière

- + {#if searchTerm} +

Aucun résultat

+

Aucune affectation ne correspond à votre recherche

+ + {:else} +

Aucune affectation

+

Commencez par affecter un enseignant à une classe et une matière

+ + {/if}
{:else}
@@ -308,22 +350,22 @@ - {#each activeAssignments as assignment (assignment.id)} + {#each assignments as assignment (assignment.id)} - {getTeacherName(assignment.teacherId)} + {assignment.teacherFirstName} {assignment.teacherLastName} - {getClassName(assignment.classId)} + {assignment.className} {#if getSubjectColor(assignment.subjectId)} - {getSubjectName(assignment.subjectId)} + {assignment.subjectName} {:else} - {getSubjectName(assignment.subjectId)} + {assignment.subjectName} {/if} @@ -345,6 +387,7 @@
+ {/if}
@@ -449,9 +492,9 @@