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 @@