diff --git a/backend/config/packages/cache.yaml b/backend/config/packages/cache.yaml index 190fa6f..96acb7e 100644 --- a/backend/config/packages/cache.yaml +++ b/backend/config/packages/cache.yaml @@ -39,6 +39,12 @@ framework: adapter: cache.adapter.filesystem default_lifetime: 604800 # 7 jours + # Pool dédié au cache des requêtes paginées (1h TTL, tag-aware) + paginated_queries.cache: + adapter: cache.adapter.filesystem + default_lifetime: 3600 # 1 heure + tags: true + # Test environment uses Redis to avoid filesystem cache timing issues in E2E tests # (CLI creates tokens, FrankenPHP must see them immediately) when@test: @@ -73,6 +79,10 @@ when@test: adapter: cache.adapter.redis provider: '%env(REDIS_URL)%' default_lifetime: 604800 + paginated_queries.cache: + adapter: cache.adapter.redis_tag_aware + provider: '%env(REDIS_URL)%' + default_lifetime: 3600 when@prod: framework: @@ -110,3 +120,7 @@ when@prod: adapter: cache.adapter.redis provider: '%env(REDIS_URL)%' default_lifetime: 604800 # 7 jours + paginated_queries.cache: + adapter: cache.adapter.redis_tag_aware + provider: '%env(REDIS_URL)%' + default_lifetime: 3600 # 1 heure diff --git a/backend/config/packages/messenger.yaml b/backend/config/packages/messenger.yaml index 6a73bca..991806f 100644 --- a/backend/config/packages/messenger.yaml +++ b/backend/config/packages/messenger.yaml @@ -25,6 +25,7 @@ framework: middleware: - App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware - App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware + - App\Administration\Infrastructure\Middleware\PaginatedCacheInvalidationMiddleware - App\Shared\Infrastructure\Messenger\MessengerMetricsMiddleware transports: diff --git a/backend/config/services.yaml b/backend/config/services.yaml index be4b36a..5c440b6 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -25,6 +25,8 @@ services: Psr\Cache\CacheItemPoolInterface $sessionsCache: '@sessions.cache' # Bind student guardians cache pool (no TTL - persistent data) Psr\Cache\CacheItemPoolInterface $studentGuardiansCache: '@student_guardians.cache' + # Bind paginated queries cache pool (1h TTL, tag-aware) + Symfony\Contracts\Cache\TagAwareCacheInterface $paginatedQueriesCache: '@paginated_queries.cache' # Bind named message buses Symfony\Component\Messenger\MessageBusInterface $eventBus: '@event.bus' Symfony\Component\Messenger\MessageBusInterface $commandBus: '@command.bus' @@ -237,6 +239,25 @@ services: App\Administration\Domain\Repository\StudentGuardianRepository: alias: App\Administration\Infrastructure\Persistence\Cache\CacheStudentGuardianRepository + # Paginated Read Model Ports + App\Administration\Application\Port\PaginatedUsersReader: + alias: App\Administration\Infrastructure\ReadModel\DbalPaginatedUsersReader + + App\Administration\Application\Port\PaginatedClassesReader: + alias: App\Administration\Infrastructure\ReadModel\DbalPaginatedClassesReader + + App\Administration\Application\Port\PaginatedSubjectsReader: + alias: App\Administration\Infrastructure\ReadModel\DbalPaginatedSubjectsReader + + App\Administration\Application\Port\PaginatedAssignmentsReader: + alias: App\Administration\Infrastructure\ReadModel\DbalPaginatedAssignmentsReader + + App\Administration\Application\Port\PaginatedParentInvitationsReader: + alias: App\Administration\Infrastructure\ReadModel\DbalPaginatedParentInvitationsReader + + App\Administration\Application\Port\PaginatedStudentImageRightsReader: + alias: App\Administration\Infrastructure\ReadModel\DbalPaginatedStudentImageRightsReader + # GradeExistenceChecker (stub until Notes module exists) App\Administration\Application\Port\GradeExistenceChecker: alias: App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker diff --git a/backend/src/Administration/Application/Port/PaginatedAssignmentsReader.php b/backend/src/Administration/Application/Port/PaginatedAssignmentsReader.php new file mode 100644 index 0000000..450af2a --- /dev/null +++ b/backend/src/Administration/Application/Port/PaginatedAssignmentsReader.php @@ -0,0 +1,26 @@ + + */ +interface PaginatedAssignmentsReader +{ + /** + * @return PaginatedResult + */ + public function findPaginated( + string $tenantId, + ?string $search, + int $page, + int $limit, + ): PaginatedResult; +} diff --git a/backend/src/Administration/Application/Port/PaginatedClassesReader.php b/backend/src/Administration/Application/Port/PaginatedClassesReader.php new file mode 100644 index 0000000..1ff36b3 --- /dev/null +++ b/backend/src/Administration/Application/Port/PaginatedClassesReader.php @@ -0,0 +1,27 @@ + + */ +interface PaginatedClassesReader +{ + /** + * @return PaginatedResult + */ + public function findPaginated( + string $tenantId, + string $academicYearId, + ?string $search, + int $page, + int $limit, + ): PaginatedResult; +} diff --git a/backend/src/Administration/Application/Port/PaginatedParentInvitationsReader.php b/backend/src/Administration/Application/Port/PaginatedParentInvitationsReader.php new file mode 100644 index 0000000..7a33cf8 --- /dev/null +++ b/backend/src/Administration/Application/Port/PaginatedParentInvitationsReader.php @@ -0,0 +1,28 @@ + + */ +interface PaginatedParentInvitationsReader +{ + /** + * @return PaginatedResult + */ + public function findPaginated( + string $tenantId, + ?string $status, + ?string $studentId, + ?string $search, + int $page, + int $limit, + ): PaginatedResult; +} diff --git a/backend/src/Administration/Application/Port/PaginatedStudentImageRightsReader.php b/backend/src/Administration/Application/Port/PaginatedStudentImageRightsReader.php new file mode 100644 index 0000000..6332585 --- /dev/null +++ b/backend/src/Administration/Application/Port/PaginatedStudentImageRightsReader.php @@ -0,0 +1,37 @@ + + */ +interface PaginatedStudentImageRightsReader +{ + /** + * @return PaginatedResult + */ + public function findPaginated( + string $tenantId, + ?string $status, + ?string $search, + int $page, + int $limit, + ): PaginatedResult; + + /** + * Returns all students (no pagination) for export purposes. + * + * @return StudentImageRightsDto[] + */ + public function findAll( + string $tenantId, + ?string $status, + ): array; +} diff --git a/backend/src/Administration/Application/Port/PaginatedSubjectsReader.php b/backend/src/Administration/Application/Port/PaginatedSubjectsReader.php new file mode 100644 index 0000000..dd2ea52 --- /dev/null +++ b/backend/src/Administration/Application/Port/PaginatedSubjectsReader.php @@ -0,0 +1,27 @@ + + */ +interface PaginatedSubjectsReader +{ + /** + * @return PaginatedResult + */ + public function findPaginated( + string $tenantId, + string $schoolId, + ?string $search, + int $page, + int $limit, + ): PaginatedResult; +} diff --git a/backend/src/Administration/Application/Port/PaginatedUsersReader.php b/backend/src/Administration/Application/Port/PaginatedUsersReader.php new file mode 100644 index 0000000..eee1e7b --- /dev/null +++ b/backend/src/Administration/Application/Port/PaginatedUsersReader.php @@ -0,0 +1,28 @@ + + */ +interface PaginatedUsersReader +{ + /** + * @return PaginatedResult + */ + public function findPaginated( + string $tenantId, + ?string $role, + ?string $statut, + ?string $search, + int $page, + int $limit, + ): PaginatedResult; +} diff --git a/backend/src/Administration/Application/Query/GetAllAssignments/GetAllAssignmentsHandler.php b/backend/src/Administration/Application/Query/GetAllAssignments/GetAllAssignmentsHandler.php index 3596b4a..783fb9e 100644 --- a/backend/src/Administration/Application/Query/GetAllAssignments/GetAllAssignmentsHandler.php +++ b/backend/src/Administration/Application/Query/GetAllAssignments/GetAllAssignmentsHandler.php @@ -5,27 +5,16 @@ declare(strict_types=1); namespace App\Administration\Application\Query\GetAllAssignments; use App\Administration\Application\Dto\PaginatedResult; -use App\Administration\Domain\Repository\ClassRepository; -use App\Administration\Domain\Repository\SubjectRepository; -use App\Administration\Domain\Repository\TeacherAssignmentRepository; -use App\Administration\Domain\Repository\UserRepository; -use App\Shared\Domain\Tenant\TenantId; - -use function array_slice; -use function count; - -use Psr\Log\LoggerInterface; +use App\Administration\Application\Port\PaginatedAssignmentsReader; +use App\Administration\Application\Service\Cache\PaginatedQueryCache; use Symfony\Component\Messenger\Attribute\AsMessageHandler; #[AsMessageHandler(bus: 'query.bus')] final readonly class GetAllAssignmentsHandler { public function __construct( - private TeacherAssignmentRepository $assignmentRepository, - private UserRepository $userRepository, - private ClassRepository $classRepository, - private SubjectRepository $subjectRepository, - private LoggerInterface $logger, + private PaginatedAssignmentsReader $reader, + private PaginatedQueryCache $cache, ) { } @@ -34,101 +23,29 @@ final readonly class GetAllAssignmentsHandler */ 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, + /* @var PaginatedResult */ + return $this->cache->getOrLoad( + 'assignments', + $query->tenantId, + $this->cacheParams($query), + fn (): PaginatedResult => $this->reader->findPaginated( + tenantId: $query->tenantId, + search: $query->search, + page: $query->page, + limit: $query->limit, + ), ); } + + /** + * @return array + */ + private function cacheParams(GetAllAssignmentsQuery $query): array + { + return [ + 'page' => $query->page, + 'limit' => $query->limit, + 'search' => $query->search, + ]; + } } diff --git a/backend/src/Administration/Application/Query/GetClasses/GetClassesHandler.php b/backend/src/Administration/Application/Query/GetClasses/GetClassesHandler.php index 0ee1f02..91f7fcf 100644 --- a/backend/src/Administration/Application/Query/GetClasses/GetClassesHandler.php +++ b/backend/src/Administration/Application/Query/GetClasses/GetClassesHandler.php @@ -5,20 +5,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 App\Administration\Application\Port\PaginatedClassesReader; +use App\Administration\Application\Service\Cache\PaginatedQueryCache; use Symfony\Component\Messenger\Attribute\AsMessageHandler; #[AsMessageHandler(bus: 'query.bus')] final readonly class GetClassesHandler { public function __construct( - private ClassRepository $classRepository, + private PaginatedClassesReader $reader, + private PaginatedQueryCache $cache, ) { } @@ -27,33 +23,31 @@ final readonly class GetClassesHandler */ public function __invoke(GetClassesQuery $query): PaginatedResult { - $classes = $this->classRepository->findActiveByTenantAndYear( - TenantId::fromString($query->tenantId), - AcademicYearId::fromString($query->academicYearId), - ); - - 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, + /* @var PaginatedResult */ + return $this->cache->getOrLoad( + 'classes', + $query->tenantId, + $this->cacheParams($query), + fn (): PaginatedResult => $this->reader->findPaginated( + tenantId: $query->tenantId, + academicYearId: $query->academicYearId, + search: $query->search, + page: $query->page, + limit: $query->limit, ), - total: $total, - page: $query->page, - limit: $query->limit, ); } + + /** + * @return array + */ + private function cacheParams(GetClassesQuery $query): array + { + return [ + 'page' => $query->page, + 'limit' => $query->limit, + 'academic_year_id' => $query->academicYearId, + 'search' => $query->search, + ]; + } } diff --git a/backend/src/Administration/Application/Query/GetParentInvitations/GetParentInvitationsHandler.php b/backend/src/Administration/Application/Query/GetParentInvitations/GetParentInvitationsHandler.php index 83c0382..665cdb8 100644 --- a/backend/src/Administration/Application/Query/GetParentInvitations/GetParentInvitationsHandler.php +++ b/backend/src/Administration/Application/Query/GetParentInvitations/GetParentInvitationsHandler.php @@ -5,29 +5,16 @@ declare(strict_types=1); namespace App\Administration\Application\Query\GetParentInvitations; use App\Administration\Application\Dto\PaginatedResult; -use App\Administration\Domain\Model\Invitation\InvitationStatus; -use App\Administration\Domain\Model\User\UserId; -use App\Administration\Domain\Repository\ParentInvitationRepository; -use App\Administration\Domain\Repository\UserRepository; -use App\Shared\Domain\Tenant\TenantId; - -use function array_filter; -use function array_map; -use function array_slice; -use function array_values; -use function count; -use function mb_strtolower; -use function str_contains; - +use App\Administration\Application\Port\PaginatedParentInvitationsReader; +use App\Administration\Application\Service\Cache\PaginatedQueryCache; use Symfony\Component\Messenger\Attribute\AsMessageHandler; -use Throwable; #[AsMessageHandler(bus: 'query.bus')] final readonly class GetParentInvitationsHandler { public function __construct( - private ParentInvitationRepository $invitationRepository, - private UserRepository $userRepository, + private PaginatedParentInvitationsReader $reader, + private PaginatedQueryCache $cache, ) { } @@ -36,97 +23,33 @@ final readonly class GetParentInvitationsHandler */ public function __invoke(GetParentInvitationsQuery $query): PaginatedResult { - $tenantId = TenantId::fromString($query->tenantId); - - $invitations = $this->invitationRepository->findAllByTenant($tenantId); - - if ($query->status !== null) { - $filterStatus = InvitationStatus::tryFrom($query->status); - if ($filterStatus !== null) { - $invitations = array_filter( - $invitations, - static fn ($inv) => $inv->status === $filterStatus, - ); - } - } - - if ($query->studentId !== null) { - $filterStudentId = UserId::fromString($query->studentId); - $invitations = array_filter( - $invitations, - static fn ($inv) => $inv->studentId->equals($filterStudentId), - ); - } - - // Build a student name cache for search and DTO enrichment - $studentNames = $this->loadStudentNames($invitations); - - if ($query->search !== null && $query->search !== '') { - $searchLower = mb_strtolower($query->search); - $invitations = array_filter( - $invitations, - static function ($inv) use ($searchLower, $studentNames) { - $studentId = (string) $inv->studentId; - $firstName = $studentNames[$studentId]['firstName'] ?? ''; - $lastName = $studentNames[$studentId]['lastName'] ?? ''; - - return str_contains(mb_strtolower((string) $inv->parentEmail), $searchLower) - || str_contains(mb_strtolower($firstName), $searchLower) - || str_contains(mb_strtolower($lastName), $searchLower); - }, - ); - } - - $invitations = array_values($invitations); - $total = count($invitations); - - $offset = ($query->page - 1) * $query->limit; - $items = array_slice($invitations, $offset, $query->limit); - - return new PaginatedResult( - items: array_map( - static function ($inv) use ($studentNames) { - $studentId = (string) $inv->studentId; - - return ParentInvitationDto::fromDomain( - $inv, - $studentNames[$studentId]['firstName'] ?? null, - $studentNames[$studentId]['lastName'] ?? null, - ); - }, - $items, + /* @var PaginatedResult */ + return $this->cache->getOrLoad( + 'parent_invitations', + $query->tenantId, + $this->cacheParams($query), + fn (): PaginatedResult => $this->reader->findPaginated( + tenantId: $query->tenantId, + status: $query->status, + studentId: $query->studentId, + search: $query->search, + page: $query->page, + limit: $query->limit, ), - total: $total, - page: $query->page, - limit: $query->limit, ); } /** - * @param iterable<\App\Administration\Domain\Model\Invitation\ParentInvitation> $invitations - * - * @return array + * @return array */ - private function loadStudentNames(iterable $invitations): array + private function cacheParams(GetParentInvitationsQuery $query): array { - $studentIds = []; - foreach ($invitations as $inv) { - $studentIds[(string) $inv->studentId] = true; - } - - $names = []; - foreach ($studentIds as $id => $_) { - try { - $student = $this->userRepository->get(UserId::fromString($id)); - $names[$id] = [ - 'firstName' => $student->firstName, - 'lastName' => $student->lastName, - ]; - } catch (Throwable) { - $names[$id] = ['firstName' => '', 'lastName' => '']; - } - } - - return $names; + return [ + 'page' => $query->page, + 'limit' => $query->limit, + 'status' => $query->status, + 'student_id' => $query->studentId, + 'search' => $query->search, + ]; } } diff --git a/backend/src/Administration/Application/Query/GetStudentsImageRights/GetStudentsImageRightsHandler.php b/backend/src/Administration/Application/Query/GetStudentsImageRights/GetStudentsImageRightsHandler.php index 8b615bb..7a5cbb2 100644 --- a/backend/src/Administration/Application/Query/GetStudentsImageRights/GetStudentsImageRightsHandler.php +++ b/backend/src/Administration/Application/Query/GetStudentsImageRights/GetStudentsImageRightsHandler.php @@ -4,41 +4,50 @@ declare(strict_types=1); namespace App\Administration\Application\Query\GetStudentsImageRights; -use App\Administration\Domain\Model\User\ImageRightsStatus; -use App\Administration\Domain\Repository\UserRepository; -use App\Shared\Domain\Tenant\TenantId; +use App\Administration\Application\Dto\PaginatedResult; +use App\Administration\Application\Port\PaginatedStudentImageRightsReader; +use App\Administration\Application\Service\Cache\PaginatedQueryCache; use Symfony\Component\Messenger\Attribute\AsMessageHandler; #[AsMessageHandler(bus: 'query.bus')] final readonly class GetStudentsImageRightsHandler { public function __construct( - private UserRepository $userRepository, + private PaginatedStudentImageRightsReader $reader, + private PaginatedQueryCache $cache, ) { } /** - * @return StudentImageRightsDto[] + * @return PaginatedResult */ - public function __invoke(GetStudentsImageRightsQuery $query): array + public function __invoke(GetStudentsImageRightsQuery $query): PaginatedResult { - $students = $this->userRepository->findStudentsByTenant( - TenantId::fromString($query->tenantId), + /* @var PaginatedResult */ + return $this->cache->getOrLoad( + 'students_image_rights', + $query->tenantId, + $this->cacheParams($query), + fn (): PaginatedResult => $this->reader->findPaginated( + tenantId: $query->tenantId, + status: $query->status, + search: $query->search, + page: $query->page, + limit: $query->limit, + ), ); + } - if ($query->status !== null) { - $filterStatus = ImageRightsStatus::tryFrom($query->status); - if ($filterStatus !== null) { - $students = array_filter( - $students, - static fn ($user) => $user->imageRightsStatus === $filterStatus, - ); - } - } - - return array_values(array_map( - static fn ($user) => StudentImageRightsDto::fromDomain($user), - $students, - )); + /** + * @return array + */ + private function cacheParams(GetStudentsImageRightsQuery $query): array + { + return [ + 'page' => $query->page, + 'limit' => $query->limit, + 'status' => $query->status, + 'search' => $query->search, + ]; } } diff --git a/backend/src/Administration/Application/Query/GetStudentsImageRights/GetStudentsImageRightsQuery.php b/backend/src/Administration/Application/Query/GetStudentsImageRights/GetStudentsImageRightsQuery.php index e88dcd4..9592934 100644 --- a/backend/src/Administration/Application/Query/GetStudentsImageRights/GetStudentsImageRightsQuery.php +++ b/backend/src/Administration/Application/Query/GetStudentsImageRights/GetStudentsImageRightsQuery.php @@ -4,11 +4,23 @@ declare(strict_types=1); namespace App\Administration\Application\Query\GetStudentsImageRights; +use App\Administration\Application\Dto\PaginatedResult; + final readonly class GetStudentsImageRightsQuery { + public int $page; + public int $limit; + public ?string $search; + public function __construct( public string $tenantId, public ?string $status = 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/Application/Query/GetSubjects/GetSubjectsHandler.php b/backend/src/Administration/Application/Query/GetSubjects/GetSubjectsHandler.php index 8243df5..824a546 100644 --- a/backend/src/Administration/Application/Query/GetSubjects/GetSubjectsHandler.php +++ b/backend/src/Administration/Application/Query/GetSubjects/GetSubjectsHandler.php @@ -5,20 +5,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_slice; -use function count; - +use App\Administration\Application\Port\PaginatedSubjectsReader; +use App\Administration\Application\Service\Cache\PaginatedQueryCache; use Symfony\Component\Messenger\Attribute\AsMessageHandler; #[AsMessageHandler(bus: 'query.bus')] final readonly class GetSubjectsHandler { public function __construct( - private SubjectRepository $subjectRepository, + private PaginatedSubjectsReader $reader, + private PaginatedQueryCache $cache, ) { } @@ -27,37 +23,31 @@ final readonly class GetSubjectsHandler */ public function __invoke(GetSubjectsQuery $query): PaginatedResult { - $subjects = $this->subjectRepository->findActiveByTenantAndSchool( - TenantId::fromString($query->tenantId), - SchoolId::fromString($query->schoolId), - ); - - 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); - } - - $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, + /* @var PaginatedResult */ + return $this->cache->getOrLoad( + 'subjects', + $query->tenantId, + $this->cacheParams($query), + fn (): PaginatedResult => $this->reader->findPaginated( + tenantId: $query->tenantId, + schoolId: $query->schoolId, + search: $query->search, + page: $query->page, + limit: $query->limit, ), - total: $total, - page: $query->page, - limit: $query->limit, ); } + + /** + * @return array + */ + private function cacheParams(GetSubjectsQuery $query): array + { + return [ + 'page' => $query->page, + 'limit' => $query->limit, + 'school_id' => $query->schoolId, + 'search' => $query->search, + ]; + } } diff --git a/backend/src/Administration/Application/Query/GetUsers/GetUsersHandler.php b/backend/src/Administration/Application/Query/GetUsers/GetUsersHandler.php index 97172ec..d1172bf 100644 --- a/backend/src/Administration/Application/Query/GetUsers/GetUsersHandler.php +++ b/backend/src/Administration/Application/Query/GetUsers/GetUsersHandler.php @@ -5,23 +5,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 App\Administration\Application\Port\PaginatedUsersReader; +use App\Administration\Application\Service\Cache\PaginatedQueryCache; use Symfony\Component\Messenger\Attribute\AsMessageHandler; #[AsMessageHandler(bus: 'query.bus')] final readonly class GetUsersHandler { public function __construct( - private UserRepository $userRepository, - private Clock $clock, + private PaginatedUsersReader $reader, + private PaginatedQueryCache $cache, ) { } @@ -30,54 +23,33 @@ final readonly class GetUsersHandler */ public function __invoke(GetUsersQuery $query): PaginatedResult { - $users = $this->userRepository->findAllByTenant( - TenantId::fromString($query->tenantId), - ); - - if ($query->role !== null) { - $filterRole = Role::tryFrom($query->role); - if ($filterRole !== null) { - $users = array_filter( - $users, - static fn ($user) => $user->aLeRole($filterRole), - ); - } - } - - if ($query->statut !== null) { - $filterStatut = StatutCompte::tryFrom($query->statut); - if ($filterStatut !== null) { - $users = array_filter( - $users, - static fn ($user) => $user->statut === $filterStatut, - ); - } - } - - 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, + /* @var PaginatedResult */ + return $this->cache->getOrLoad( + 'users', + $query->tenantId, + $this->cacheParams($query), + fn (): PaginatedResult => $this->reader->findPaginated( + tenantId: $query->tenantId, + role: $query->role, + statut: $query->statut, + search: $query->search, + page: $query->page, + limit: $query->limit, ), - total: $total, - page: $query->page, - limit: $query->limit, ); } + + /** + * @return array + */ + private function cacheParams(GetUsersQuery $query): array + { + return [ + 'page' => $query->page, + 'limit' => $query->limit, + 'role' => $query->role, + 'statut' => $query->statut, + 'search' => $query->search, + ]; + } } diff --git a/backend/src/Administration/Application/Service/Cache/PaginatedQueryCache.php b/backend/src/Administration/Application/Service/Cache/PaginatedQueryCache.php new file mode 100644 index 0000000..c1b9c8a --- /dev/null +++ b/backend/src/Administration/Application/Service/Cache/PaginatedQueryCache.php @@ -0,0 +1,77 @@ + $params Filtres + page + limit + * @param callable(): PaginatedResult $loader Fonction qui exécute la requête SQL + * + * @return PaginatedResult + */ + public function getOrLoad( + string $entityType, + string $tenantId, + array $params, + callable $loader, + ): PaginatedResult { + $key = $this->buildKey($entityType, $tenantId, $params); + $tag = sprintf('query_%s_%s', $entityType, $tenantId); + + /* @var PaginatedResult */ + return $this->paginatedQueriesCache->get( + $key, + static function (ItemInterface $item) use ($tag, $loader): PaginatedResult { + $item->tag([$tag]); + + return $loader(); + }, + ); + } + + public function invalidate(string $entityType, string $tenantId): void + { + $this->paginatedQueriesCache->invalidateTags( + [sprintf('query_%s_%s', $entityType, $tenantId)], + ); + } + + /** + * @param array $params + */ + private function buildKey(string $entityType, string $tenantId, array $params): string + { + ksort($params); + + return sprintf('query_%s_%s_%s', $entityType, $tenantId, md5(json_encode($params, JSON_THROW_ON_ERROR))); + } +} diff --git a/backend/src/Administration/Domain/Event/AffectationRetiree.php b/backend/src/Administration/Domain/Event/AffectationRetiree.php index f77af74..db81948 100644 --- a/backend/src/Administration/Domain/Event/AffectationRetiree.php +++ b/backend/src/Administration/Domain/Event/AffectationRetiree.php @@ -9,6 +9,7 @@ use App\Administration\Domain\Model\Subject\SubjectId; use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId; use App\Administration\Domain\Model\User\UserId; use App\Shared\Domain\DomainEvent; +use App\Shared\Domain\Tenant\TenantId; use DateTimeImmutable; use Override; use Ramsey\Uuid\UuidInterface; @@ -20,6 +21,7 @@ final readonly class AffectationRetiree implements DomainEvent public UserId $teacherId, public ClassId $classId, public SubjectId $subjectId, + public TenantId $tenantId, private DateTimeImmutable $occurredOn, ) { } diff --git a/backend/src/Administration/Domain/Event/EnseignantAffecte.php b/backend/src/Administration/Domain/Event/EnseignantAffecte.php index bd4ac30..1c65d9d 100644 --- a/backend/src/Administration/Domain/Event/EnseignantAffecte.php +++ b/backend/src/Administration/Domain/Event/EnseignantAffecte.php @@ -9,6 +9,7 @@ use App\Administration\Domain\Model\Subject\SubjectId; use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId; use App\Administration\Domain\Model\User\UserId; use App\Shared\Domain\DomainEvent; +use App\Shared\Domain\Tenant\TenantId; use DateTimeImmutable; use Override; use Ramsey\Uuid\UuidInterface; @@ -20,6 +21,7 @@ final readonly class EnseignantAffecte implements DomainEvent public UserId $teacherId, public ClassId $classId, public SubjectId $subjectId, + public TenantId $tenantId, private DateTimeImmutable $occurredOn, ) { } diff --git a/backend/src/Administration/Domain/Model/TeacherAssignment/TeacherAssignment.php b/backend/src/Administration/Domain/Model/TeacherAssignment/TeacherAssignment.php index 3f5df07..e573803 100644 --- a/backend/src/Administration/Domain/Model/TeacherAssignment/TeacherAssignment.php +++ b/backend/src/Administration/Domain/Model/TeacherAssignment/TeacherAssignment.php @@ -68,6 +68,7 @@ final class TeacherAssignment extends AggregateRoot teacherId: $assignment->teacherId, classId: $assignment->classId, subjectId: $assignment->subjectId, + tenantId: $assignment->tenantId, occurredOn: $createdAt, )); @@ -93,6 +94,7 @@ final class TeacherAssignment extends AggregateRoot teacherId: $this->teacherId, classId: $this->classId, subjectId: $this->subjectId, + tenantId: $this->tenantId, occurredOn: $at, )); } @@ -119,6 +121,7 @@ final class TeacherAssignment extends AggregateRoot teacherId: $this->teacherId, classId: $this->classId, subjectId: $this->subjectId, + tenantId: $this->tenantId, occurredOn: $at, )); } diff --git a/backend/src/Administration/Infrastructure/Api/Provider/ImageRightsCollectionProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/ImageRightsCollectionProvider.php index c692f48..b1beab3 100644 --- a/backend/src/Administration/Infrastructure/Api/Provider/ImageRightsCollectionProvider.php +++ b/backend/src/Administration/Infrastructure/Api/Provider/ImageRightsCollectionProvider.php @@ -5,12 +5,17 @@ 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\GetStudentsImageRights\GetStudentsImageRightsHandler; use App\Administration\Application\Query\GetStudentsImageRights\GetStudentsImageRightsQuery; use App\Administration\Infrastructure\Api\Resource\ImageRightsResource; use App\Administration\Infrastructure\Security\ImageRightsVoter; 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; @@ -28,11 +33,8 @@ final readonly class ImageRightsCollectionProvider implements ProviderInterface ) { } - /** - * @return ImageRightsResource[] - */ #[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(ImageRightsVoter::VIEW)) { throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à consulter les droits à l\'image.'); @@ -42,19 +44,30 @@ final readonly class ImageRightsCollectionProvider implements ProviderInterface 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 GetStudentsImageRightsQuery( - tenantId: (string) $this->tenantContext->getCurrentTenantId(), + tenantId: $tenantId, status: isset($filters['status']) ? (string) $filters['status'] : null, + page: $page, + limit: $itemsPerPage, + search: isset($filters['search']) ? (string) $filters['search'] : null, ); - $dtos = ($this->handler)($query); + $result = ($this->handler)($query); - return array_map( - static fn ($dto) => ImageRightsResource::fromDto($dto), - $dtos, + $resources = array_map(ImageRightsResource::fromDto(...), $result->items); + + return new TraversablePaginator( + new ArrayIterator($resources), + $result->page, + $result->limit, + $result->total, ); } } diff --git a/backend/src/Administration/Infrastructure/Api/Provider/ImageRightsExportProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/ImageRightsExportProvider.php index 6ffb9e7..4467870 100644 --- a/backend/src/Administration/Infrastructure/Api/Provider/ImageRightsExportProvider.php +++ b/backend/src/Administration/Infrastructure/Api/Provider/ImageRightsExportProvider.php @@ -6,8 +6,7 @@ namespace App\Administration\Infrastructure\Api\Provider; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProviderInterface; -use App\Administration\Application\Query\GetStudentsImageRights\GetStudentsImageRightsHandler; -use App\Administration\Application\Query\GetStudentsImageRights\GetStudentsImageRightsQuery; +use App\Administration\Application\Port\PaginatedStudentImageRightsReader; use App\Administration\Application\Service\ImageRightsExporter; use App\Administration\Infrastructure\Security\ImageRightsVoter; use App\Shared\Application\Port\AuditLogger; @@ -27,7 +26,7 @@ use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; final readonly class ImageRightsExportProvider implements ProviderInterface { public function __construct( - private GetStudentsImageRightsHandler $handler, + private PaginatedStudentImageRightsReader $reader, private ImageRightsExporter $exporter, private AuthorizationCheckerInterface $authorizationChecker, private TenantContext $tenantContext, @@ -46,15 +45,14 @@ final readonly class ImageRightsExportProvider implements ProviderInterface throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); } + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); /** @var array $filters */ $filters = $context['filters'] ?? []; - $query = new GetStudentsImageRightsQuery( - tenantId: (string) $this->tenantContext->getCurrentTenantId(), - status: isset($filters['status']) ? (string) $filters['status'] : null, + $dtos = $this->reader->findAll( + $tenantId, + isset($filters['status']) ? (string) $filters['status'] : null, ); - - $dtos = ($this->handler)($query); $csv = $this->exporter->export($dtos); $this->auditLogger->logExport( diff --git a/backend/src/Administration/Infrastructure/EventHandler/PaginatedQueryCacheInvalidator.php b/backend/src/Administration/Infrastructure/EventHandler/PaginatedQueryCacheInvalidator.php new file mode 100644 index 0000000..dd35d11 --- /dev/null +++ b/backend/src/Administration/Infrastructure/EventHandler/PaginatedQueryCacheInvalidator.php @@ -0,0 +1,219 @@ +cache->invalidate('users', (string) $event->tenantId); + } + + #[AsMessageHandler(bus: 'event.bus')] + public function onUtilisateurBloque(UtilisateurBloque $event): void + { + $this->cache->invalidate('users', (string) $event->tenantId); + } + + #[AsMessageHandler(bus: 'event.bus')] + public function onUtilisateurDebloque(UtilisateurDebloque $event): void + { + $this->cache->invalidate('users', (string) $event->tenantId); + } + + #[AsMessageHandler(bus: 'event.bus')] + public function onCompteActive(CompteActive $event): void + { + $this->cache->invalidate('users', (string) $event->tenantId); + } + + #[AsMessageHandler(bus: 'event.bus')] + public function onCompteCreated(CompteCreated $event): void + { + $this->cache->invalidate('users', (string) $event->tenantId); + } + + #[AsMessageHandler(bus: 'event.bus')] + public function onEleveInscrit(EleveInscrit $event): void + { + $tenantId = (string) $event->tenantId; + $this->cache->invalidate('users', $tenantId); + $this->cache->invalidate('students_image_rights', $tenantId); + } + + #[AsMessageHandler(bus: 'event.bus')] + public function onRoleAttribue(RoleAttribue $event): void + { + $this->cache->invalidate('users', (string) $event->tenantId); + } + + #[AsMessageHandler(bus: 'event.bus')] + public function onRoleRetire(RoleRetire $event): void + { + $this->cache->invalidate('users', (string) $event->tenantId); + } + + #[AsMessageHandler(bus: 'event.bus')] + public function onInvitationRenvoyee(InvitationRenvoyee $event): void + { + $this->cache->invalidate('users', (string) $event->tenantId); + } + + // === Classes (also invalidates assignments: class names appear in assignment list) === + + #[AsMessageHandler(bus: 'event.bus')] + public function onClasseCreee(ClasseCreee $event): void + { + $tenantId = (string) $event->tenantId; + $this->cache->invalidate('classes', $tenantId); + $this->cache->invalidate('assignments', $tenantId); + } + + #[AsMessageHandler(bus: 'event.bus')] + public function onClasseModifiee(ClasseModifiee $event): void + { + $tenantId = (string) $event->tenantId; + $this->cache->invalidate('classes', $tenantId); + $this->cache->invalidate('assignments', $tenantId); + } + + #[AsMessageHandler(bus: 'event.bus')] + public function onClasseArchivee(ClasseArchivee $event): void + { + $tenantId = (string) $event->tenantId; + $this->cache->invalidate('classes', $tenantId); + $this->cache->invalidate('assignments', $tenantId); + } + + // === Subjects (also invalidates assignments: subject names appear in assignment list) === + + #[AsMessageHandler(bus: 'event.bus')] + public function onMatiereCreee(MatiereCreee $event): void + { + $tenantId = (string) $event->tenantId; + $this->cache->invalidate('subjects', $tenantId); + $this->cache->invalidate('assignments', $tenantId); + } + + #[AsMessageHandler(bus: 'event.bus')] + public function onMatiereModifiee(MatiereModifiee $event): void + { + $tenantId = (string) $event->tenantId; + $this->cache->invalidate('subjects', $tenantId); + $this->cache->invalidate('assignments', $tenantId); + } + + #[AsMessageHandler(bus: 'event.bus')] + public function onMatiereSupprimee(MatiereSupprimee $event): void + { + $tenantId = (string) $event->tenantId; + $this->cache->invalidate('subjects', $tenantId); + $this->cache->invalidate('assignments', $tenantId); + } + + // === Assignments === + + #[AsMessageHandler(bus: 'event.bus')] + public function onEnseignantAffecte(EnseignantAffecte $event): void + { + $this->cache->invalidate('assignments', (string) $event->tenantId); + } + + #[AsMessageHandler(bus: 'event.bus')] + public function onAffectationRetiree(AffectationRetiree $event): void + { + $this->cache->invalidate('assignments', (string) $event->tenantId); + } + + // === Parent invitations === + + #[AsMessageHandler(bus: 'event.bus')] + public function onInvitationParentEnvoyee(InvitationParentEnvoyee $event): void + { + $this->cache->invalidate('parent_invitations', (string) $event->tenantId); + } + + #[AsMessageHandler(bus: 'event.bus')] + public function onInvitationParentActivee(InvitationParentActivee $event): void + { + $tenantId = (string) $event->tenantId; + $this->cache->invalidate('parent_invitations', $tenantId); + $this->cache->invalidate('users', $tenantId); + } + + #[AsMessageHandler(bus: 'event.bus')] + public function onParentLieAEleve(ParentLieAEleve $event): void + { + $this->cache->invalidate('parent_invitations', (string) $event->tenantId); + } + + #[AsMessageHandler(bus: 'event.bus')] + public function onParentDelieDEleve(ParentDelieDEleve $event): void + { + $this->cache->invalidate('parent_invitations', (string) $event->tenantId); + } + + // === Image rights === + + #[AsMessageHandler(bus: 'event.bus')] + public function onDroitImageModifie(DroitImageModifie $event): void + { + $this->cache->invalidate('students_image_rights', (string) $event->tenantId); + } + + // === Imports (invalidate multiple caches) === + + #[AsMessageHandler(bus: 'event.bus')] + public function onImportElevesTermine(ImportElevesTermine $event): void + { + $tenantId = (string) $event->tenantId; + $this->cache->invalidate('users', $tenantId); + $this->cache->invalidate('students_image_rights', $tenantId); + $this->cache->invalidate('classes', $tenantId); + } + + #[AsMessageHandler(bus: 'event.bus')] + public function onImportEnseignantsTermine(ImportEnseignantsTermine $event): void + { + $tenantId = (string) $event->tenantId; + $this->cache->invalidate('users', $tenantId); + $this->cache->invalidate('assignments', $tenantId); + } +} diff --git a/backend/src/Administration/Infrastructure/Middleware/PaginatedCacheInvalidationMiddleware.php b/backend/src/Administration/Infrastructure/Middleware/PaginatedCacheInvalidationMiddleware.php new file mode 100644 index 0000000..d277389 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Middleware/PaginatedCacheInvalidationMiddleware.php @@ -0,0 +1,66 @@ +last(ReceivedStamp::class) === null) { + $this->invalidateIfNeeded($envelope->getMessage()); + } + + return $stack->next()->handle($envelope, $stack); + } + + private function invalidateIfNeeded(object $message): void + { + match (true) { + $message instanceof UtilisateurInvite, + $message instanceof CompteActive, + $message instanceof InvitationRenvoyee => $this->cache->invalidate('users', (string) $message->tenantId), + + $message instanceof InvitationParentEnvoyee => $this->cache->invalidate('parent_invitations', (string) $message->tenantId), + + $message instanceof InvitationParentActivee => $this->invalidateParentActivee($message), + + default => null, + }; + } + + private function invalidateParentActivee(InvitationParentActivee $event): void + { + $tenantId = (string) $event->tenantId; + $this->cache->invalidate('parent_invitations', $tenantId); + $this->cache->invalidate('users', $tenantId); + } +} diff --git a/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedAssignmentsReader.php b/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedAssignmentsReader.php new file mode 100644 index 0000000..6cc88d2 --- /dev/null +++ b/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedAssignmentsReader.php @@ -0,0 +1,123 @@ + + */ + public function findPaginated( + string $tenantId, + ?string $search, + int $page, + int $limit, + ): PaginatedResult { + $params = ['tenant_id' => $tenantId]; + $whereClause = 'ta.tenant_id = :tenant_id AND ta.status = :status'; + $params['status'] = 'active'; + + if ($search !== null && $search !== '') { + $whereClause .= ' AND (u.first_name ILIKE :search OR u.last_name ILIKE :search OR sc.name ILIKE :search OR s.name ILIKE :search)'; + $params['search'] = '%' . $search . '%'; + } + + $fromClause = <<connection->fetchOne($countSql, $params); + $total = (int) $totalRaw; + + $offset = ($page - 1) * $limit; + + $selectSql = <<connection->fetchAllAssociative($selectSql, $params); + + $items = array_map(static function (array $row): AssignmentWithNamesDto { + /** @var string $id */ + $id = $row['id']; + /** @var string $teacherId */ + $teacherId = $row['teacher_id']; + /** @var string $teacherFirstName */ + $teacherFirstName = $row['teacher_first_name']; + /** @var string $teacherLastName */ + $teacherLastName = $row['teacher_last_name']; + /** @var string $classId */ + $classId = $row['class_id']; + /** @var string $className */ + $className = $row['class_name']; + /** @var string $subjectId */ + $subjectId = $row['subject_id']; + /** @var string $subjectName */ + $subjectName = $row['subject_name']; + /** @var string $academicYearId */ + $academicYearId = $row['academic_year_id']; + /** @var string $status */ + $status = $row['status']; + /** @var string $startDate */ + $startDate = $row['start_date']; + /** @var string|null $endDate */ + $endDate = $row['end_date']; + /** @var string $createdAt */ + $createdAt = $row['created_at']; + + return new AssignmentWithNamesDto( + id: $id, + teacherId: $teacherId, + teacherFirstName: $teacherFirstName, + teacherLastName: $teacherLastName, + classId: $classId, + className: $className, + subjectId: $subjectId, + subjectName: $subjectName, + academicYearId: $academicYearId, + status: $status, + startDate: new DateTimeImmutable($startDate), + endDate: $endDate !== null ? new DateTimeImmutable($endDate) : null, + createdAt: new DateTimeImmutable($createdAt), + ); + }, $rows); + + return new PaginatedResult( + items: $items, + total: $total, + page: $page, + limit: $limit, + ); + } +} diff --git a/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedClassesReader.php b/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedClassesReader.php new file mode 100644 index 0000000..483a2a5 --- /dev/null +++ b/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedClassesReader.php @@ -0,0 +1,102 @@ + + */ + public function findPaginated( + string $tenantId, + string $academicYearId, + ?string $search, + int $page, + int $limit, + ): PaginatedResult { + $params = [ + 'tenant_id' => $tenantId, + 'academic_year_id' => $academicYearId, + 'status' => ClassStatus::ACTIVE->value, + ]; + $whereClause = 'sc.tenant_id = :tenant_id AND sc.academic_year_id = :academic_year_id AND sc.status = :status'; + + if ($search !== null && $search !== '') { + $whereClause .= ' AND (sc.name ILIKE :search OR sc.level ILIKE :search)'; + $params['search'] = '%' . $search . '%'; + } + + $countSql = "SELECT COUNT(*) FROM school_classes sc WHERE {$whereClause}"; + + /** @var int|string|false $totalRaw */ + $totalRaw = $this->connection->fetchOne($countSql, $params); + $total = (int) $totalRaw; + + $offset = ($page - 1) * $limit; + + $selectSql = <<connection->fetchAllAssociative($selectSql, $params); + + $items = array_map(static function (array $row): ClassDto { + /** @var string $id */ + $id = $row['id']; + /** @var string $name */ + $name = $row['name']; + /** @var string|null $level */ + $level = $row['level']; + /** @var int|string|null $capacityRaw */ + $capacityRaw = $row['capacity']; + $capacity = $capacityRaw !== null ? (int) $capacityRaw : null; + /** @var string $status */ + $status = $row['status']; + /** @var string|null $description */ + $description = $row['description']; + /** @var string $createdAt */ + $createdAt = $row['created_at']; + /** @var string $updatedAt */ + $updatedAt = $row['updated_at']; + + return new ClassDto( + id: $id, + name: $name, + level: $level, + capacity: $capacity, + status: $status, + description: $description, + createdAt: new DateTimeImmutable($createdAt), + updatedAt: new DateTimeImmutable($updatedAt), + ); + }, $rows); + + return new PaginatedResult( + items: $items, + total: $total, + page: $page, + limit: $limit, + ); + } +} diff --git a/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedParentInvitationsReader.php b/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedParentInvitationsReader.php new file mode 100644 index 0000000..2ea10a5 --- /dev/null +++ b/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedParentInvitationsReader.php @@ -0,0 +1,124 @@ + + */ + public function findPaginated( + string $tenantId, + ?string $status, + ?string $studentId, + ?string $search, + int $page, + int $limit, + ): PaginatedResult { + $params = ['tenant_id' => $tenantId]; + $whereClause = 'pi.tenant_id = :tenant_id'; + + if ($status !== null) { + $whereClause .= ' AND pi.status = :status'; + $params['status'] = $status; + } + + if ($studentId !== null) { + $whereClause .= ' AND pi.student_id = :student_id'; + $params['student_id'] = $studentId; + } + + if ($search !== null && $search !== '') { + $whereClause .= ' AND (pi.parent_email ILIKE :search OR u.first_name ILIKE :search OR u.last_name ILIKE :search)'; + $params['search'] = '%' . $search . '%'; + } + + $fromClause = <<connection->fetchOne($countSql, $params); + $total = (int) $totalRaw; + + $offset = ($page - 1) * $limit; + + $selectSql = <<connection->fetchAllAssociative($selectSql, $params); + + $items = array_map(static function (array $row): ParentInvitationDto { + /** @var string $id */ + $id = $row['id']; + /** @var string $studentId */ + $studentId = $row['student_id']; + /** @var string $parentEmail */ + $parentEmail = $row['parent_email']; + /** @var string $status */ + $status = $row['status']; + /** @var string $createdAt */ + $createdAt = $row['created_at']; + /** @var string $expiresAt */ + $expiresAt = $row['expires_at']; + /** @var string|null $sentAt */ + $sentAt = $row['sent_at']; + /** @var string|null $activatedAt */ + $activatedAt = $row['activated_at']; + /** @var string|null $activatedUserId */ + $activatedUserId = $row['activated_user_id']; + /** @var string|null $studentFirstName */ + $studentFirstName = $row['student_first_name']; + /** @var string|null $studentLastName */ + $studentLastName = $row['student_last_name']; + + return new ParentInvitationDto( + id: $id, + studentId: $studentId, + parentEmail: $parentEmail, + status: $status, + createdAt: new DateTimeImmutable($createdAt), + expiresAt: new DateTimeImmutable($expiresAt), + sentAt: $sentAt !== null ? new DateTimeImmutable($sentAt) : null, + activatedAt: $activatedAt !== null ? new DateTimeImmutable($activatedAt) : null, + activatedUserId: $activatedUserId, + studentFirstName: $studentFirstName, + studentLastName: $studentLastName, + ); + }, $rows); + + return new PaginatedResult( + items: $items, + total: $total, + page: $page, + limit: $limit, + ); + } +} diff --git a/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedStudentImageRightsReader.php b/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedStudentImageRightsReader.php new file mode 100644 index 0000000..607a5a2 --- /dev/null +++ b/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedStudentImageRightsReader.php @@ -0,0 +1,176 @@ + + */ + public function findPaginated( + string $tenantId, + ?string $status, + ?string $search, + int $page, + int $limit, + ): PaginatedResult { + $params = $this->buildBaseParams($tenantId); + $whereClause = $this->buildBaseWhere(); + + if ($status !== null && $status !== '') { + $whereClause .= ' AND u.image_rights_status = :status'; + $params['status'] = $status; + } + + if ($search !== null && $search !== '') { + $whereClause .= ' AND (u.first_name ILIKE :search OR u.last_name ILIKE :search OR u.email ILIKE :search)'; + $params['search'] = '%' . $search . '%'; + } + + $countSql = "SELECT COUNT(*) FROM users u WHERE {$whereClause}"; + + /** @var int|string|false $totalRaw */ + $totalRaw = $this->connection->fetchOne($countSql, $params); + $total = (int) $totalRaw; + + $offset = ($page - 1) * $limit; + + $selectSql = <<connection->fetchAllAssociative($selectSql, $params); + + $items = array_map(self::mapRowToDto(...), $rows); + + return new PaginatedResult( + items: $items, + total: $total, + page: $page, + limit: $limit, + ); + } + + /** + * @return StudentImageRightsDto[] + */ + public function findAll( + string $tenantId, + ?string $status, + ): array { + $params = $this->buildBaseParams($tenantId); + $whereClause = $this->buildBaseWhere(); + + if ($status !== null && $status !== '') { + $whereClause .= ' AND u.image_rights_status = :status'; + $params['status'] = $status; + } + + $sql = <<connection->fetchAllAssociative($sql, $params); + + return array_map(self::mapRowToDto(...), $rows); + } + + /** + * @return array + */ + private function buildBaseParams(string $tenantId): array + { + return [ + 'tenant_id' => $tenantId, + 'role' => json_encode([Role::ELEVE->value], JSON_THROW_ON_ERROR), + ]; + } + + private function buildBaseWhere(): string + { + return 'u.tenant_id = :tenant_id AND u.roles::jsonb @> :role::jsonb'; + } + + /** + * @param array $row + */ + private static function mapRowToDto(array $row): StudentImageRightsDto + { + /** @var string $id */ + $id = $row['id']; + /** @var string $firstName */ + $firstName = $row['first_name']; + /** @var string $lastName */ + $lastName = $row['last_name']; + /** @var string|null $email */ + $email = $row['email']; + /** @var string $imageRightsStatusValue */ + $imageRightsStatusValue = $row['image_rights_status']; + /** @var string|null $imageRightsUpdatedAt */ + $imageRightsUpdatedAt = $row['image_rights_updated_at']; + /** @var string|null $className */ + $className = $row['class_name'] ?? null; + + $statusEnum = ImageRightsStatus::from($imageRightsStatusValue); + + return new StudentImageRightsDto( + id: $id, + firstName: $firstName, + lastName: $lastName, + email: $email ?? '', + imageRightsStatus: $statusEnum->value, + imageRightsStatusLabel: $statusEnum->label(), + imageRightsUpdatedAt: $imageRightsUpdatedAt !== null ? new DateTimeImmutable($imageRightsUpdatedAt) : null, + className: $className, + ); + } +} diff --git a/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedSubjectsReader.php b/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedSubjectsReader.php new file mode 100644 index 0000000..5eb074e --- /dev/null +++ b/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedSubjectsReader.php @@ -0,0 +1,110 @@ + + */ + public function findPaginated( + string $tenantId, + string $schoolId, + ?string $search, + int $page, + int $limit, + ): PaginatedResult { + $params = [ + 'tenant_id' => $tenantId, + 'school_id' => $schoolId, + 'status' => 'active', + ]; + $whereClause = 's.tenant_id = :tenant_id AND s.school_id = :school_id AND s.status = :status AND s.deleted_at IS NULL'; + + if ($search !== null && $search !== '') { + $whereClause .= ' AND (s.name ILIKE :search OR s.code ILIKE :search)'; + $params['search'] = '%' . $search . '%'; + } + + $countSql = "SELECT COUNT(*) FROM subjects s WHERE {$whereClause}"; + + /** @var int|string|false $totalRaw */ + $totalRaw = $this->connection->fetchOne($countSql, $params); + $total = (int) $totalRaw; + + $offset = ($page - 1) * $limit; + + $selectSql = <<connection->fetchAllAssociative($selectSql, $params); + + $items = array_map(static function (array $row): SubjectDto { + /** @var string $id */ + $id = $row['id']; + /** @var string $name */ + $name = $row['name']; + /** @var string $code */ + $code = $row['code']; + /** @var string|null $color */ + $color = $row['color']; + /** @var string|null $description */ + $description = $row['description']; + /** @var string $status */ + $status = $row['status']; + /** @var string $createdAt */ + $createdAt = $row['created_at']; + /** @var string $updatedAt */ + $updatedAt = $row['updated_at']; + /** @var int|string $teacherCountRaw */ + $teacherCountRaw = $row['teacher_count'] ?? 0; + /** @var int|string $classCountRaw */ + $classCountRaw = $row['class_count'] ?? 0; + + return new SubjectDto( + id: $id, + name: $name, + code: $code, + color: $color, + description: $description, + status: $status, + createdAt: new DateTimeImmutable($createdAt), + updatedAt: new DateTimeImmutable($updatedAt), + teacherCount: (int) $teacherCountRaw, + classCount: (int) $classCountRaw, + ); + }, $rows); + + return new PaginatedResult( + items: $items, + total: $total, + page: $page, + limit: $limit, + ); + } +} diff --git a/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedUsersReader.php b/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedUsersReader.php new file mode 100644 index 0000000..589e87e --- /dev/null +++ b/backend/src/Administration/Infrastructure/ReadModel/DbalPaginatedUsersReader.php @@ -0,0 +1,154 @@ + + */ + public function findPaginated( + string $tenantId, + ?string $role, + ?string $statut, + ?string $search, + int $page, + int $limit, + ): PaginatedResult { + $params = ['tenant_id' => $tenantId]; + $whereClause = 'u.tenant_id = :tenant_id'; + + if ($role !== null) { + $filterRole = Role::tryFrom($role); + if ($filterRole !== null) { + $whereClause .= ' AND u.roles::jsonb @> :role::jsonb'; + $params['role'] = json_encode([$filterRole->value], JSON_THROW_ON_ERROR); + } + } + + if ($statut !== null) { + $whereClause .= ' AND u.statut = :statut'; + $params['statut'] = $statut; + } + + if ($search !== null && $search !== '') { + $whereClause .= ' AND (u.first_name ILIKE :search OR u.last_name ILIKE :search OR u.email ILIKE :search)'; + $params['search'] = '%' . $search . '%'; + } + + $countSql = "SELECT COUNT(*) FROM users u WHERE {$whereClause}"; + + /** @var int|string|false $totalRaw */ + $totalRaw = $this->connection->fetchOne($countSql, $params); + $total = (int) $totalRaw; + + $offset = ($page - 1) * $limit; + + $selectSql = <<connection->fetchAllAssociative($selectSql, $params); + + $now = $this->clock->now(); + $items = array_map( + static fn (array $row): UserDto => self::mapRowToDto($row, $now), + $rows, + ); + + return new PaginatedResult( + items: $items, + total: $total, + page: $page, + limit: $limit, + ); + } + + /** + * @param array $row + */ + private static function mapRowToDto(array $row, DateTimeImmutable $now): UserDto + { + /** @var string $id */ + $id = $row['id']; + /** @var string|null $email */ + $email = $row['email']; + /** @var string $rolesJson */ + $rolesJson = $row['roles']; + /** @var string $firstName */ + $firstName = $row['first_name']; + /** @var string $lastName */ + $lastName = $row['last_name']; + /** @var string $statut */ + $statut = $row['statut']; + /** @var string $createdAt */ + $createdAt = $row['created_at']; + /** @var string|null $invitedAt */ + $invitedAt = $row['invited_at']; + /** @var string|null $activatedAt */ + $activatedAt = $row['activated_at']; + /** @var string|null $blockedAt */ + $blockedAt = $row['blocked_at']; + /** @var string|null $blockedReason */ + $blockedReason = $row['blocked_reason']; + + /** @var string[] $roleValues */ + $roleValues = json_decode($rolesJson, true, 512, JSON_THROW_ON_ERROR); + $primaryRole = Role::from($roleValues[0] ?? Role::ELEVE->value); + + $invitedAtDate = $invitedAt !== null ? new DateTimeImmutable($invitedAt) : null; + $invitationExpiree = $invitedAtDate !== null + && $activatedAt === null + && $invitedAtDate->modify('+' . self::INVITATION_EXPIRY_DAYS . ' days') < $now; + + return new UserDto( + id: $id, + email: $email ?? '', + role: $primaryRole->value, + roleLabel: $primaryRole->label(), + roles: $roleValues, + firstName: $firstName, + lastName: $lastName, + statut: $statut, + createdAt: new DateTimeImmutable($createdAt), + invitedAt: $invitedAtDate, + activatedAt: $activatedAt !== null ? new DateTimeImmutable($activatedAt) : null, + blockedAt: $blockedAt !== null ? new DateTimeImmutable($blockedAt) : null, + blockedReason: $blockedReason, + invitationExpiree: $invitationExpiree, + ); + } +} diff --git a/backend/tests/Functional/Administration/Api/ImageRightsEndpointsTest.php b/backend/tests/Functional/Administration/Api/ImageRightsEndpointsTest.php index 4ba48a1..288c34a 100644 --- a/backend/tests/Functional/Administration/Api/ImageRightsEndpointsTest.php +++ b/backend/tests/Functional/Administration/Api/ImageRightsEndpointsTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Tests\Functional\Administration\Api; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use App\Administration\Application\Service\Cache\PaginatedQueryCache; use App\Administration\Domain\Model\User\Email; use App\Administration\Domain\Model\User\ImageRightsStatus; use App\Administration\Domain\Model\User\Role; @@ -43,6 +44,10 @@ final class ImageRightsEndpointsTest extends ApiTestCase $connection->executeStatement('DELETE FROM users WHERE id = :id', ['id' => self::STUDENT_2_ID]); $connection->executeStatement('DELETE FROM users WHERE id = :id', ['id' => self::USER_ID]); + /** @var PaginatedQueryCache $paginatedCache */ + $paginatedCache = $container->get(PaginatedQueryCache::class); + $paginatedCache->invalidate('students_image_rights', self::TENANT_ID); + parent::tearDown(); } diff --git a/backend/tests/Unit/Administration/Application/Query/GetAllAssignments/GetAllAssignmentsHandlerTest.php b/backend/tests/Unit/Administration/Application/Query/GetAllAssignments/GetAllAssignmentsHandlerTest.php index 67ca821..9ea86ef 100644 --- a/backend/tests/Unit/Administration/Application/Query/GetAllAssignments/GetAllAssignmentsHandlerTest.php +++ b/backend/tests/Unit/Administration/Application/Query/GetAllAssignments/GetAllAssignmentsHandlerTest.php @@ -4,365 +4,140 @@ declare(strict_types=1); namespace App\Tests\Unit\Administration\Application\Query\GetAllAssignments; +use App\Administration\Application\Dto\PaginatedResult; +use App\Administration\Application\Port\PaginatedAssignmentsReader; +use App\Administration\Application\Query\GetAllAssignments\AssignmentWithNamesDto; use App\Administration\Application\Query\GetAllAssignments\GetAllAssignmentsHandler; use App\Administration\Application\Query\GetAllAssignments\GetAllAssignmentsQuery; -use App\Administration\Domain\Model\SchoolClass\AcademicYearId; -use App\Administration\Domain\Model\SchoolClass\ClassName; -use App\Administration\Domain\Model\SchoolClass\SchoolClass; -use App\Administration\Domain\Model\SchoolClass\SchoolId; -use App\Administration\Domain\Model\SchoolClass\SchoolLevel; -use App\Administration\Domain\Model\Subject\Subject; -use App\Administration\Domain\Model\Subject\SubjectCode; -use App\Administration\Domain\Model\Subject\SubjectName; -use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment; -use App\Administration\Domain\Model\User\Email; -use App\Administration\Domain\Model\User\Role; -use App\Administration\Domain\Model\User\User; -use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository; -use App\Administration\Infrastructure\Persistence\InMemory\InMemorySubjectRepository; -use App\Administration\Infrastructure\Persistence\InMemory\InMemoryTeacherAssignmentRepository; -use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository; -use App\Shared\Domain\Tenant\TenantId; - -use function count; - +use App\Administration\Application\Service\Cache\PaginatedQueryCache; use DateTimeImmutable; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; -use Psr\Log\NullLogger; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Adapter\TagAwareAdapter; final class GetAllAssignmentsHandlerTest extends TestCase { - private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002'; - private const string OTHER_TENANT_ID = '550e8400-e29b-41d4-a716-446655440099'; - - private InMemoryTeacherAssignmentRepository $assignmentRepository; - private InMemoryUserRepository $userRepository; - private InMemoryClassRepository $classRepository; - private InMemorySubjectRepository $subjectRepository; + private PaginatedAssignmentsReader $reader; + private PaginatedQueryCache $cache; private GetAllAssignmentsHandler $handler; protected function setUp(): void { - $this->assignmentRepository = new InMemoryTeacherAssignmentRepository(); - $this->userRepository = new InMemoryUserRepository(); - $this->classRepository = new InMemoryClassRepository(); - $this->subjectRepository = new InMemorySubjectRepository(); - $this->handler = new GetAllAssignmentsHandler( - $this->assignmentRepository, - $this->userRepository, - $this->classRepository, - $this->subjectRepository, - new NullLogger(), + $this->reader = $this->createMock(PaginatedAssignmentsReader::class); + $this->cache = new PaginatedQueryCache( + new TagAwareAdapter(new ArrayAdapter()), ); + $this->handler = new GetAllAssignmentsHandler($this->reader, $this->cache); } #[Test] - public function returnsAllActiveAssignmentsWithNames(): void + public function returnsItemsForTenant(): 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', + $dto = $this->createAssignmentDto(); + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30), ); - $result = ($this->handler)($query); + + $result = ($this->handler)(new GetAllAssignmentsQuery(tenantId: 'tenant-1')); self::assertCount(1, $result->items); - self::assertSame('Jean', $result->items[0]->teacherFirstName); + self::assertSame(1, $result->total); } #[Test] - public function searchesByClassName(): void + public function mapsDtoFields(): void { - $this->seedData(); - - $query = new GetAllAssignmentsQuery( - tenantId: self::TENANT_ID, - search: '6ème', + $dto = $this->createAssignmentDto(); + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30), ); - $result = ($this->handler)($query); - self::assertGreaterThanOrEqual(1, count($result->items)); - self::assertStringContainsString('6ème', $result->items[0]->className); - } + $result = ($this->handler)(new GetAllAssignmentsQuery(tenantId: 'tenant-1')); - #[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); + $item = $result->items[0]; + self::assertSame('assign-1', $item->id); + self::assertSame('teacher-1', $item->teacherId); + self::assertSame('Jean', $item->teacherFirstName); + self::assertSame('Dupont', $item->teacherLastName); + self::assertSame('class-1', $item->classId); + self::assertSame('6eme A', $item->className); + self::assertSame('subj-1', $item->subjectId); + self::assertSame('Mathematiques', $item->subjectName); + self::assertSame('year-1', $item->academicYearId); + self::assertSame('active', $item->status); + self::assertNull($item->endDate); } #[Test] public function paginatesResults(): void { - $this->seedData(); - - $query = new GetAllAssignmentsQuery( - tenantId: self::TENANT_ID, - page: 1, - limit: 1, + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [], total: 50, page: 2, limit: 10), ); - $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()); - } + $result = ($this->handler)(new GetAllAssignmentsQuery(tenantId: 'tenant-1', page: 2, limit: 10)); - #[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(50, $result->total); self::assertSame(2, $result->page); + self::assertSame(10, $result->limit); } #[Test] - public function returnsEmptyWhenNoMatches(): void + public function cachesResult(): void { - $this->seedData(); - - $query = new GetAllAssignmentsQuery( - tenantId: self::TENANT_ID, - search: 'nonexistent', + $dto = $this->createAssignmentDto(); + $this->reader->expects(self::once())->method('findPaginated')->willReturn( + new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30), ); + + $query = new GetAllAssignmentsQuery(tenantId: 'tenant-1'); + ($this->handler)($query); $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 + public function clampsPageToMinimumOne(): void { - $query = new GetAllAssignmentsQuery( - tenantId: self::TENANT_ID, - page: -1, + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [], total: 0, page: 1, limit: 30), ); - self::assertSame(1, $query->page); + $result = ($this->handler)(new GetAllAssignmentsQuery(tenantId: 'tenant-1', page: -5)); + + self::assertSame(1, $result->page); } #[Test] - public function clampsExcessiveLimitToMax(): void + public function clampsLimitToMaximumHundred(): void { - $query = new GetAllAssignmentsQuery( - tenantId: self::TENANT_ID, - limit: 999, + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [], total: 0, page: 1, limit: 100), ); - self::assertSame(100, $query->limit); + $result = ($this->handler)(new GetAllAssignmentsQuery(tenantId: 'tenant-1', limit: 500)); + + self::assertSame(100, $result->limit); } - private function seedData(): void + private function createAssignmentDto(): AssignmentWithNamesDto { - $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, + return new AssignmentWithNamesDto( + id: 'assign-1', + teacherId: 'teacher-1', + teacherFirstName: 'Jean', + teacherLastName: 'Dupont', + classId: 'class-1', + className: '6eme A', + subjectId: 'subj-1', + subjectName: 'Mathematiques', + academicYearId: 'year-1', + status: 'active', + startDate: new DateTimeImmutable('2026-02-01'), + endDate: null, + createdAt: new DateTimeImmutable('2026-01-15'), ); - $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 index 97caac5..f86f446 100644 --- a/backend/tests/Unit/Administration/Application/Query/GetClasses/GetClassesHandlerTest.php +++ b/backend/tests/Unit/Administration/Application/Query/GetClasses/GetClassesHandlerTest.php @@ -4,212 +4,130 @@ declare(strict_types=1); namespace App\Tests\Unit\Administration\Application\Query\GetClasses; +use App\Administration\Application\Dto\PaginatedResult; +use App\Administration\Application\Port\PaginatedClassesReader; +use App\Administration\Application\Query\GetClasses\ClassDto; use App\Administration\Application\Query\GetClasses\GetClassesHandler; use App\Administration\Application\Query\GetClasses\GetClassesQuery; -use App\Administration\Domain\Model\SchoolClass\AcademicYearId; -use App\Administration\Domain\Model\SchoolClass\ClassName; -use App\Administration\Domain\Model\SchoolClass\SchoolClass; -use App\Administration\Domain\Model\SchoolClass\SchoolId; -use App\Administration\Domain\Model\SchoolClass\SchoolLevel; -use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository; -use App\Shared\Domain\Tenant\TenantId; +use App\Administration\Application\Service\Cache\PaginatedQueryCache; use DateTimeImmutable; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Adapter\TagAwareAdapter; final class GetClassesHandlerTest extends TestCase { - private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002'; - private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440010'; - - private InMemoryClassRepository $classRepository; + private PaginatedClassesReader $reader; + private PaginatedQueryCache $cache; private GetClassesHandler $handler; protected function setUp(): void { - $this->classRepository = new InMemoryClassRepository(); - $this->handler = new GetClassesHandler($this->classRepository); + $this->reader = $this->createMock(PaginatedClassesReader::class); + $this->cache = new PaginatedQueryCache( + new TagAwareAdapter(new ArrayAdapter()), + ); + $this->handler = new GetClassesHandler($this->reader, $this->cache); } #[Test] - public function returnsAllActiveClassesForTenantAndYear(): void + public function returnsItemsForTenant(): void { - $this->seedClasses(); - - $query = new GetClassesQuery( - tenantId: self::TENANT_ID, - academicYearId: self::ACADEMIC_YEAR_ID, + $dto = $this->createClassDto(); + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30), ); - $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); + $result = ($this->handler)(new GetClassesQuery(tenantId: 'tenant-1', academicYearId: 'year-1')); self::assertCount(1, $result->items); - self::assertSame('6ème A', $result->items[0]->name); + self::assertSame(1, $result->total); } #[Test] - public function filtersClassesByLevel(): void + public function mapsDtoFields(): void { - $this->seedClasses(); - - $query = new GetClassesQuery( - tenantId: self::TENANT_ID, - academicYearId: self::ACADEMIC_YEAR_ID, - search: SchoolLevel::CM2->value, + $dto = $this->createClassDto(); + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30), ); - $result = ($this->handler)($query); - self::assertCount(1, $result->items); - self::assertSame('CM2 B', $result->items[0]->name); - } + $result = ($this->handler)(new GetClassesQuery(tenantId: 'tenant-1', academicYearId: 'year-1')); - #[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); + $item = $result->items[0]; + self::assertSame('class-1', $item->id); + self::assertSame('6eme A', $item->name); + self::assertSame('sixieme', $item->level); + self::assertSame(30, $item->capacity); + self::assertSame('active', $item->status); + self::assertSame('Description test', $item->description); } #[Test] public function paginatesResults(): void { - $this->seedClasses(); - - $query = new GetClassesQuery( - tenantId: self::TENANT_ID, - academicYearId: self::ACADEMIC_YEAR_ID, - page: 1, - limit: 2, + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [], total: 50, page: 2, limit: 10), ); - $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()); + $result = ($this->handler)(new GetClassesQuery(tenantId: 'tenant-1', academicYearId: 'year-1', page: 2, limit: 10)); + + self::assertSame(50, $result->total); + self::assertSame(2, $result->page); + self::assertSame(10, $result->limit); } #[Test] - public function returnsSecondPage(): void + public function cachesResult(): void { - $this->seedClasses(); - - $query = new GetClassesQuery( - tenantId: self::TENANT_ID, - academicYearId: self::ACADEMIC_YEAR_ID, - page: 2, - limit: 2, + $dto = $this->createClassDto(); + $this->reader->expects(self::once())->method('findPaginated')->willReturn( + new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30), ); + + $query = new GetClassesQuery(tenantId: 'tenant-1', academicYearId: 'year-1'); + ($this->handler)($query); $result = ($this->handler)($query); self::assertCount(1, $result->items); - self::assertSame(3, $result->total); - self::assertSame(2, $result->page); } #[Test] - public function returnsEmptyWhenNoMatches(): void + public function clampsPageToMinimumOne(): void { - $this->seedClasses(); - - $query = new GetClassesQuery( - tenantId: self::TENANT_ID, - academicYearId: self::ACADEMIC_YEAR_ID, - search: 'nonexistent', + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [], total: 0, page: 1, limit: 30), ); - $result = ($this->handler)($query); - self::assertCount(0, $result->items); - self::assertSame(0, $result->total); + $result = ($this->handler)(new GetClassesQuery(tenantId: 'tenant-1', academicYearId: 'year-1', page: -5)); + + self::assertSame(1, $result->page); } #[Test] - public function clampsInvalidPageToOne(): void + public function clampsLimitToMaximumHundred(): void { - $this->seedClasses(); - - $query = new GetClassesQuery( - tenantId: self::TENANT_ID, - academicYearId: self::ACADEMIC_YEAR_ID, - page: -1, + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [], total: 0, page: 1, limit: 100), ); - self::assertSame(1, $query->page); + $result = ($this->handler)(new GetClassesQuery(tenantId: 'tenant-1', academicYearId: 'year-1', limit: 500)); + + self::assertSame(100, $result->limit); } - #[Test] - public function clampsExcessiveLimitToMax(): void + private function createClassDto(): ClassDto { - $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, + return new ClassDto( + id: 'class-1', + name: '6eme A', + level: '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, - )); + status: 'active', + description: 'Description test', + createdAt: new DateTimeImmutable('2026-01-15'), + updatedAt: new DateTimeImmutable('2026-01-15'), + ); } } diff --git a/backend/tests/Unit/Administration/Application/Query/GetParentInvitations/GetParentInvitationsHandlerTest.php b/backend/tests/Unit/Administration/Application/Query/GetParentInvitations/GetParentInvitationsHandlerTest.php index 896f733..0253b38 100644 --- a/backend/tests/Unit/Administration/Application/Query/GetParentInvitations/GetParentInvitationsHandlerTest.php +++ b/backend/tests/Unit/Administration/Application/Query/GetParentInvitations/GetParentInvitationsHandlerTest.php @@ -4,204 +4,135 @@ declare(strict_types=1); namespace App\Tests\Unit\Administration\Application\Query\GetParentInvitations; +use App\Administration\Application\Dto\PaginatedResult; +use App\Administration\Application\Port\PaginatedParentInvitationsReader; use App\Administration\Application\Query\GetParentInvitations\GetParentInvitationsHandler; use App\Administration\Application\Query\GetParentInvitations\GetParentInvitationsQuery; -use App\Administration\Domain\Model\Invitation\InvitationCode; -use App\Administration\Domain\Model\Invitation\ParentInvitation; -use App\Administration\Domain\Model\User\Email; -use App\Administration\Domain\Model\User\Role; -use App\Administration\Domain\Model\User\User; -use App\Administration\Domain\Model\User\UserId; -use App\Administration\Infrastructure\Persistence\InMemory\InMemoryParentInvitationRepository; -use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository; -use App\Shared\Domain\Tenant\TenantId; +use App\Administration\Application\Query\GetParentInvitations\ParentInvitationDto; +use App\Administration\Application\Service\Cache\PaginatedQueryCache; use DateTimeImmutable; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Adapter\TagAwareAdapter; final class GetParentInvitationsHandlerTest extends TestCase { - private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002'; - private const string OTHER_TENANT_ID = '550e8400-e29b-41d4-a716-446655440099'; - - private InMemoryParentInvitationRepository $invitationRepository; - private InMemoryUserRepository $userRepository; + private PaginatedParentInvitationsReader $reader; + private PaginatedQueryCache $cache; private GetParentInvitationsHandler $handler; protected function setUp(): void { - $this->invitationRepository = new InMemoryParentInvitationRepository(); - $this->userRepository = new InMemoryUserRepository(); - $this->handler = new GetParentInvitationsHandler( - $this->invitationRepository, - $this->userRepository, + $this->reader = $this->createMock(PaginatedParentInvitationsReader::class); + $this->cache = new PaginatedQueryCache( + new TagAwareAdapter(new ArrayAdapter()), ); + $this->handler = new GetParentInvitationsHandler($this->reader, $this->cache); } #[Test] - public function itReturnsAllInvitationsForTenant(): void + public function returnsItemsForTenant(): void { - $student = $this->createAndSaveStudent('Alice', 'Dupont'); - $this->createAndSaveInvitation($student->id, 'parent1@example.com'); - $this->createAndSaveInvitation($student->id, 'parent2@example.com'); + $dto = $this->createInvitationDto(); + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30), + ); - $result = ($this->handler)(new GetParentInvitationsQuery( - tenantId: self::TENANT_ID, - )); + $result = ($this->handler)(new GetParentInvitationsQuery(tenantId: 'tenant-1')); - self::assertSame(2, $result->total); - self::assertCount(2, $result->items); + self::assertCount(1, $result->items); + self::assertSame(1, $result->total); } #[Test] - public function itFiltersInvitationsByStatus(): void + public function mapsDtoFields(): void { - $student = $this->createAndSaveStudent('Bob', 'Martin'); - $invitation = $this->createAndSaveInvitation($student->id, 'parent@example.com'); - $this->createPendingInvitation($student->id, 'parent2@example.com'); + $dto = $this->createInvitationDto(); + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30), + ); - $result = ($this->handler)(new GetParentInvitationsQuery( - tenantId: self::TENANT_ID, + $result = ($this->handler)(new GetParentInvitationsQuery(tenantId: 'tenant-1')); + + $item = $result->items[0]; + self::assertSame('inv-1', $item->id); + self::assertSame('student-1', $item->studentId); + self::assertSame('parent@test.com', $item->parentEmail); + self::assertSame('sent', $item->status); + self::assertSame('Alice', $item->studentFirstName); + self::assertSame('Dupont', $item->studentLastName); + self::assertNull($item->activatedAt); + self::assertNull($item->activatedUserId); + } + + #[Test] + public function paginatesResults(): void + { + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [], total: 50, page: 2, limit: 10), + ); + + $result = ($this->handler)(new GetParentInvitationsQuery(tenantId: 'tenant-1', page: 2, limit: 10)); + + self::assertSame(50, $result->total); + self::assertSame(2, $result->page); + self::assertSame(10, $result->limit); + } + + #[Test] + public function cachesResult(): void + { + $dto = $this->createInvitationDto(); + $this->reader->expects(self::once())->method('findPaginated')->willReturn( + new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30), + ); + + $query = new GetParentInvitationsQuery(tenantId: 'tenant-1'); + ($this->handler)($query); + $result = ($this->handler)($query); + + self::assertCount(1, $result->items); + } + + #[Test] + public function clampsPageToMinimumOne(): void + { + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [], total: 0, page: 1, limit: 30), + ); + + $result = ($this->handler)(new GetParentInvitationsQuery(tenantId: 'tenant-1', page: -5)); + + self::assertSame(1, $result->page); + } + + #[Test] + public function clampsLimitToMaximumHundred(): void + { + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [], total: 0, page: 1, limit: 100), + ); + + $result = ($this->handler)(new GetParentInvitationsQuery(tenantId: 'tenant-1', limit: 500)); + + self::assertSame(100, $result->limit); + } + + private function createInvitationDto(): ParentInvitationDto + { + return new ParentInvitationDto( + id: 'inv-1', + studentId: 'student-1', + parentEmail: 'parent@test.com', status: 'sent', - )); - - self::assertSame(1, $result->total); - self::assertSame('parent@example.com', $result->items[0]->parentEmail); - } - - #[Test] - public function itFiltersInvitationsByStudentId(): void - { - $student1 = $this->createAndSaveStudent('Alice', 'Dupont'); - $student2 = $this->createAndSaveStudent('Bob', 'Martin'); - $this->createAndSaveInvitation($student1->id, 'parent1@example.com'); - $this->createAndSaveInvitation($student2->id, 'parent2@example.com'); - - $result = ($this->handler)(new GetParentInvitationsQuery( - tenantId: self::TENANT_ID, - studentId: (string) $student1->id, - )); - - self::assertSame(1, $result->total); - self::assertSame('parent1@example.com', $result->items[0]->parentEmail); - } - - #[Test] - public function itSearchesByParentEmailOrStudentName(): void - { - $student = $this->createAndSaveStudent('Alice', 'Dupont'); - $this->createAndSaveInvitation($student->id, 'parent@example.com'); - $this->createAndSaveInvitation($student->id, 'other@example.com'); - - $result = ($this->handler)(new GetParentInvitationsQuery( - tenantId: self::TENANT_ID, - search: 'Alice', - )); - - self::assertSame(2, $result->total); - - $result = ($this->handler)(new GetParentInvitationsQuery( - tenantId: self::TENANT_ID, - search: 'parent@', - )); - - self::assertSame(1, $result->total); - } - - #[Test] - public function itPaginatesResults(): void - { - $student = $this->createAndSaveStudent('Alice', 'Dupont'); - for ($i = 0; $i < 5; ++$i) { - $this->createAndSaveInvitation($student->id, "parent{$i}@example.com"); - } - - $result = ($this->handler)(new GetParentInvitationsQuery( - tenantId: self::TENANT_ID, - page: 1, - limit: 2, - )); - - self::assertSame(5, $result->total); - self::assertCount(2, $result->items); - } - - #[Test] - public function itEnrichesResultsWithStudentNames(): void - { - $student = $this->createAndSaveStudent('Alice', 'Dupont'); - $this->createAndSaveInvitation($student->id, 'parent@example.com'); - - $result = ($this->handler)(new GetParentInvitationsQuery( - tenantId: self::TENANT_ID, - )); - - self::assertSame('Alice', $result->items[0]->studentFirstName); - self::assertSame('Dupont', $result->items[0]->studentLastName); - } - - #[Test] - public function itIsolatesByTenant(): void - { - $student = $this->createAndSaveStudent('Alice', 'Dupont'); - $this->createAndSaveInvitation($student->id, 'parent@example.com'); - - $result = ($this->handler)(new GetParentInvitationsQuery( - tenantId: self::OTHER_TENANT_ID, - )); - - self::assertSame(0, $result->total); - } - - private function createAndSaveStudent(string $firstName, string $lastName): User - { - $student = User::inviter( - email: new Email($firstName . '@example.com'), - role: Role::ELEVE, - tenantId: TenantId::fromString(self::TENANT_ID), - schoolName: 'École Alpha', - firstName: $firstName, - lastName: $lastName, - invitedAt: new DateTimeImmutable('2026-02-07 10:00:00'), + createdAt: new DateTimeImmutable('2026-02-07'), + expiresAt: new DateTimeImmutable('2026-03-07'), + sentAt: new DateTimeImmutable('2026-02-07'), + activatedAt: null, + activatedUserId: null, + studentFirstName: 'Alice', + studentLastName: 'Dupont', ); - $student->pullDomainEvents(); - $this->userRepository->save($student); - - return $student; - } - - private function createAndSaveInvitation(UserId $studentId, string $parentEmail): ParentInvitation - { - $code = bin2hex(random_bytes(16)); - $invitation = ParentInvitation::creer( - tenantId: TenantId::fromString(self::TENANT_ID), - studentId: $studentId, - parentEmail: new Email($parentEmail), - code: new InvitationCode($code), - createdAt: new DateTimeImmutable('2026-02-07 10:00:00'), - createdBy: UserId::generate(), - ); - $invitation->envoyer(new DateTimeImmutable('2026-02-07 10:00:00')); - $invitation->pullDomainEvents(); - $this->invitationRepository->save($invitation); - - return $invitation; - } - - private function createPendingInvitation(UserId $studentId, string $parentEmail): ParentInvitation - { - $code = bin2hex(random_bytes(16)); - $invitation = ParentInvitation::creer( - tenantId: TenantId::fromString(self::TENANT_ID), - studentId: $studentId, - parentEmail: new Email($parentEmail), - code: new InvitationCode($code), - createdAt: new DateTimeImmutable('2026-02-07 10:00:00'), - createdBy: UserId::generate(), - ); - $invitation->pullDomainEvents(); - $this->invitationRepository->save($invitation); - - return $invitation; } } diff --git a/backend/tests/Unit/Administration/Application/Query/GetStudentsImageRights/GetStudentsImageRightsHandlerTest.php b/backend/tests/Unit/Administration/Application/Query/GetStudentsImageRights/GetStudentsImageRightsHandlerTest.php index e62d154..01fd04f 100644 --- a/backend/tests/Unit/Administration/Application/Query/GetStudentsImageRights/GetStudentsImageRightsHandlerTest.php +++ b/backend/tests/Unit/Administration/Application/Query/GetStudentsImageRights/GetStudentsImageRightsHandlerTest.php @@ -4,141 +4,131 @@ declare(strict_types=1); namespace App\Tests\Unit\Administration\Application\Query\GetStudentsImageRights; +use App\Administration\Application\Dto\PaginatedResult; +use App\Administration\Application\Port\PaginatedStudentImageRightsReader; use App\Administration\Application\Query\GetStudentsImageRights\GetStudentsImageRightsHandler; use App\Administration\Application\Query\GetStudentsImageRights\GetStudentsImageRightsQuery; -use App\Administration\Domain\Model\User\Email; -use App\Administration\Domain\Model\User\ImageRightsStatus; -use App\Administration\Domain\Model\User\Role; -use App\Administration\Domain\Model\User\User; -use App\Administration\Domain\Model\User\UserId; -use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository; -use App\Shared\Domain\Tenant\TenantId; +use App\Administration\Application\Query\GetStudentsImageRights\StudentImageRightsDto; +use App\Administration\Application\Service\Cache\PaginatedQueryCache; use DateTimeImmutable; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Adapter\TagAwareAdapter; final class GetStudentsImageRightsHandlerTest extends TestCase { - private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002'; - - private InMemoryUserRepository $userRepository; + private PaginatedStudentImageRightsReader $reader; + private PaginatedQueryCache $cache; private GetStudentsImageRightsHandler $handler; protected function setUp(): void { - $this->userRepository = new InMemoryUserRepository(); - $this->handler = new GetStudentsImageRightsHandler($this->userRepository); - } - - #[Test] - public function returnsOnlyStudents(): void - { - $this->seedStudentsAndParent(); - - $query = new GetStudentsImageRightsQuery(tenantId: self::TENANT_ID); - $result = ($this->handler)($query); - - self::assertCount(2, $result); - } - - #[Test] - public function filtersStudentsByStatus(): void - { - $this->seedStudentsAndParent(); - - $query = new GetStudentsImageRightsQuery( - tenantId: self::TENANT_ID, - status: 'authorized', + $this->reader = $this->createMock(PaginatedStudentImageRightsReader::class); + $this->cache = new PaginatedQueryCache( + new TagAwareAdapter(new ArrayAdapter()), ); - $result = ($this->handler)($query); - - self::assertCount(1, $result); - self::assertSame('authorized', $result[0]->imageRightsStatus); + $this->handler = new GetStudentsImageRightsHandler($this->reader, $this->cache); } #[Test] - public function returnsEmptyForNoStudents(): void + public function returnsItemsForTenant(): void { - $query = new GetStudentsImageRightsQuery(tenantId: self::TENANT_ID); - $result = ($this->handler)($query); - - self::assertCount(0, $result); - } - - #[Test] - public function doesNotReturnStudentsFromOtherTenant(): void - { - $this->seedStudentsAndParent(); - - $query = new GetStudentsImageRightsQuery( - tenantId: '550e8400-e29b-41d4-a716-446655440099', + $dto = $this->createStudentImageRightsDto(); + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30), ); - $result = ($this->handler)($query); - self::assertCount(0, $result); + $result = ($this->handler)(new GetStudentsImageRightsQuery(tenantId: 'tenant-1')); + + self::assertCount(1, $result->items); + self::assertSame(1, $result->total); } #[Test] - public function returnsDtoWithCorrectFields(): void + public function mapsDtoFields(): void { - $this->seedStudentsAndParent(); - - $query = new GetStudentsImageRightsQuery( - tenantId: self::TENANT_ID, - status: 'authorized', + $dto = $this->createStudentImageRightsDto(); + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30), ); - $result = ($this->handler)($query); - self::assertCount(1, $result); - $dto = $result[0]; - self::assertSame('Alice', $dto->firstName); - self::assertSame('Dupont', $dto->lastName); - self::assertSame('authorized', $dto->imageRightsStatus); - self::assertSame('Autorisé', $dto->imageRightsStatusLabel); + $result = ($this->handler)(new GetStudentsImageRightsQuery(tenantId: 'tenant-1')); + + $item = $result->items[0]; + self::assertSame('student-1', $item->id); + self::assertSame('Alice', $item->firstName); + self::assertSame('Dupont', $item->lastName); + self::assertSame('alice@test.com', $item->email); + self::assertSame('authorized', $item->imageRightsStatus); + self::assertSame('Autorise', $item->imageRightsStatusLabel); + self::assertSame('6eme A', $item->className); } - private function seedStudentsAndParent(): void + #[Test] + public function paginatesResults(): void { - $student1 = User::inviter( - email: new Email('alice@example.com'), - role: Role::ELEVE, - tenantId: TenantId::fromString(self::TENANT_ID), - schoolName: 'École Alpha', + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [], total: 50, page: 2, limit: 10), + ); + + $result = ($this->handler)(new GetStudentsImageRightsQuery(tenantId: 'tenant-1', page: 2, limit: 10)); + + self::assertSame(50, $result->total); + self::assertSame(2, $result->page); + self::assertSame(10, $result->limit); + } + + #[Test] + public function cachesResult(): void + { + $dto = $this->createStudentImageRightsDto(); + $this->reader->expects(self::once())->method('findPaginated')->willReturn( + new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30), + ); + + $query = new GetStudentsImageRightsQuery(tenantId: 'tenant-1'); + ($this->handler)($query); + $result = ($this->handler)($query); + + self::assertCount(1, $result->items); + } + + #[Test] + public function clampsPageToMinimumOne(): void + { + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [], total: 0, page: 1, limit: 30), + ); + + $result = ($this->handler)(new GetStudentsImageRightsQuery(tenantId: 'tenant-1', page: -5)); + + self::assertSame(1, $result->page); + } + + #[Test] + public function clampsLimitToMaximumHundred(): void + { + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [], total: 0, page: 1, limit: 100), + ); + + $result = ($this->handler)(new GetStudentsImageRightsQuery(tenantId: 'tenant-1', limit: 500)); + + self::assertSame(100, $result->limit); + } + + private function createStudentImageRightsDto(): StudentImageRightsDto + { + return new StudentImageRightsDto( + id: 'student-1', firstName: 'Alice', lastName: 'Dupont', - invitedAt: new DateTimeImmutable('2026-01-15'), - dateNaissance: new DateTimeImmutable('2012-06-15'), + email: 'alice@test.com', + imageRightsStatus: 'authorized', + imageRightsStatusLabel: 'Autorise', + imageRightsUpdatedAt: new DateTimeImmutable('2026-02-01'), + className: '6eme A', ); - $student1->modifierDroitImage( - ImageRightsStatus::AUTHORIZED, - UserId::fromString('550e8400-e29b-41d4-a716-446655440099'), - new DateTimeImmutable('2026-02-01'), - ); - - $student2 = User::inviter( - email: new Email('bob@example.com'), - role: Role::ELEVE, - tenantId: TenantId::fromString(self::TENANT_ID), - schoolName: 'École Alpha', - firstName: 'Bob', - lastName: 'Martin', - invitedAt: new DateTimeImmutable('2026-01-15'), - dateNaissance: new DateTimeImmutable('2013-03-20'), - ); - // Bob has default NOT_SPECIFIED - - $parent = User::inviter( - email: new Email('parent@example.com'), - role: Role::PARENT, - tenantId: TenantId::fromString(self::TENANT_ID), - schoolName: 'École Alpha', - firstName: 'Pierre', - lastName: 'Dupont', - invitedAt: new DateTimeImmutable('2026-01-15'), - ); - - $this->userRepository->save($student1); - $this->userRepository->save($student2); - $this->userRepository->save($parent); } } diff --git a/backend/tests/Unit/Administration/Application/Query/GetSubjects/GetSubjectsHandlerTest.php b/backend/tests/Unit/Administration/Application/Query/GetSubjects/GetSubjectsHandlerTest.php index aa7efca..978fa8a 100644 --- a/backend/tests/Unit/Administration/Application/Query/GetSubjects/GetSubjectsHandlerTest.php +++ b/backend/tests/Unit/Administration/Application/Query/GetSubjects/GetSubjectsHandlerTest.php @@ -4,206 +4,134 @@ declare(strict_types=1); namespace App\Tests\Unit\Administration\Application\Query\GetSubjects; +use App\Administration\Application\Dto\PaginatedResult; +use App\Administration\Application\Port\PaginatedSubjectsReader; use App\Administration\Application\Query\GetSubjects\GetSubjectsHandler; use App\Administration\Application\Query\GetSubjects\GetSubjectsQuery; -use App\Administration\Domain\Model\SchoolClass\SchoolId; -use App\Administration\Domain\Model\Subject\Subject; -use App\Administration\Domain\Model\Subject\SubjectCode; -use App\Administration\Domain\Model\Subject\SubjectColor; -use App\Administration\Domain\Model\Subject\SubjectName; -use App\Administration\Infrastructure\Persistence\InMemory\InMemorySubjectRepository; -use App\Shared\Domain\Tenant\TenantId; +use App\Administration\Application\Query\GetSubjects\SubjectDto; +use App\Administration\Application\Service\Cache\PaginatedQueryCache; use DateTimeImmutable; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Adapter\TagAwareAdapter; final class GetSubjectsHandlerTest extends TestCase { - private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002'; - private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440020'; - - private InMemorySubjectRepository $subjectRepository; + private PaginatedSubjectsReader $reader; + private PaginatedQueryCache $cache; private GetSubjectsHandler $handler; protected function setUp(): void { - $this->subjectRepository = new InMemorySubjectRepository(); - $this->handler = new GetSubjectsHandler($this->subjectRepository); + $this->reader = $this->createMock(PaginatedSubjectsReader::class); + $this->cache = new PaginatedQueryCache( + new TagAwareAdapter(new ArrayAdapter()), + ); + $this->handler = new GetSubjectsHandler($this->reader, $this->cache); } #[Test] - public function returnsAllActiveSubjectsForTenantAndSchool(): void + public function returnsItemsForTenant(): void { - $this->seedSubjects(); - - $query = new GetSubjectsQuery( - tenantId: self::TENANT_ID, - schoolId: self::SCHOOL_ID, + $dto = $this->createSubjectDto(); + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30), ); - $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); + $result = ($this->handler)(new GetSubjectsQuery(tenantId: 'tenant-1', schoolId: 'school-1')); self::assertCount(1, $result->items); - self::assertSame('Mathématiques', $result->items[0]->name); + self::assertSame(1, $result->total); } #[Test] - public function filtersSubjectsByCode(): void + public function mapsDtoFields(): void { - $this->seedSubjects(); - - $query = new GetSubjectsQuery( - tenantId: self::TENANT_ID, - schoolId: self::SCHOOL_ID, - search: 'FR', + $dto = $this->createSubjectDto(); + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30), ); - $result = ($this->handler)($query); - self::assertCount(1, $result->items); - self::assertSame('FR', $result->items[0]->code); - } + $result = ($this->handler)(new GetSubjectsQuery(tenantId: 'tenant-1', schoolId: 'school-1')); - #[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); + $item = $result->items[0]; + self::assertSame('subject-1', $item->id); + self::assertSame('Mathematiques', $item->name); + self::assertSame('MATH', $item->code); + self::assertSame('#3B82F6', $item->color); + self::assertSame('Maths avancees', $item->description); + self::assertSame('active', $item->status); + self::assertSame(2, $item->teacherCount); + self::assertSame(1, $item->classCount); } #[Test] public function paginatesResults(): void { - $this->seedSubjects(); - - $query = new GetSubjectsQuery( - tenantId: self::TENANT_ID, - schoolId: self::SCHOOL_ID, - page: 1, - limit: 2, + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [], total: 50, page: 2, limit: 10), ); - $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()); + $result = ($this->handler)(new GetSubjectsQuery(tenantId: 'tenant-1', schoolId: 'school-1', page: 2, limit: 10)); + + self::assertSame(50, $result->total); + self::assertSame(2, $result->page); + self::assertSame(10, $result->limit); } #[Test] - public function returnsSecondPage(): void + public function cachesResult(): void { - $this->seedSubjects(); - - $query = new GetSubjectsQuery( - tenantId: self::TENANT_ID, - schoolId: self::SCHOOL_ID, - page: 2, - limit: 2, + $dto = $this->createSubjectDto(); + $this->reader->expects(self::once())->method('findPaginated')->willReturn( + new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30), ); + + $query = new GetSubjectsQuery(tenantId: 'tenant-1', schoolId: 'school-1'); + ($this->handler)($query); $result = ($this->handler)($query); self::assertCount(1, $result->items); - self::assertSame(3, $result->total); - self::assertSame(2, $result->page); } #[Test] - public function returnsEmptyWhenNoMatches(): void + public function clampsPageToMinimumOne(): void { - $this->seedSubjects(); - - $query = new GetSubjectsQuery( - tenantId: self::TENANT_ID, - schoolId: self::SCHOOL_ID, - search: 'nonexistent', + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [], total: 0, page: 1, limit: 30), ); - $result = ($this->handler)($query); - self::assertCount(0, $result->items); - self::assertSame(0, $result->total); + $result = ($this->handler)(new GetSubjectsQuery(tenantId: 'tenant-1', schoolId: 'school-1', page: -5)); + + self::assertSame(1, $result->page); } #[Test] - public function clampsInvalidPageToOne(): void + public function clampsLimitToMaximumHundred(): void { - $query = new GetSubjectsQuery( - tenantId: self::TENANT_ID, - schoolId: self::SCHOOL_ID, - page: -1, + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [], total: 0, page: 1, limit: 100), ); - self::assertSame(1, $query->page); + $result = ($this->handler)(new GetSubjectsQuery(tenantId: 'tenant-1', schoolId: 'school-1', limit: 500)); + + self::assertSame(100, $result->limit); } - #[Test] - public function clampsExcessiveLimitToMax(): void + private function createSubjectDto(): SubjectDto { - $query = new GetSubjectsQuery( - tenantId: self::TENANT_ID, - schoolId: self::SCHOOL_ID, - limit: 999, + return new SubjectDto( + id: 'subject-1', + name: 'Mathematiques', + code: 'MATH', + color: '#3B82F6', + description: 'Maths avancees', + status: 'active', + createdAt: new DateTimeImmutable('2026-01-15'), + updatedAt: new DateTimeImmutable('2026-01-15'), + teacherCount: 2, + classCount: 1, ); - - 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 8e7bb63..49853db 100644 --- a/backend/tests/Unit/Administration/Application/Query/GetUsers/GetUsersHandlerTest.php +++ b/backend/tests/Unit/Administration/Application/Query/GetUsers/GetUsersHandlerTest.php @@ -4,337 +4,134 @@ declare(strict_types=1); namespace App\Tests\Unit\Administration\Application\Query\GetUsers; +use App\Administration\Application\Dto\PaginatedResult; +use App\Administration\Application\Port\PaginatedUsersReader; use App\Administration\Application\Query\GetUsers\GetUsersHandler; use App\Administration\Application\Query\GetUsers\GetUsersQuery; -use App\Administration\Domain\Model\User\Email; -use App\Administration\Domain\Model\User\Role; -use App\Administration\Domain\Model\User\User; -use App\Administration\Domain\Policy\ConsentementParentalPolicy; -use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository; -use App\Shared\Domain\Clock; -use App\Shared\Domain\Tenant\TenantId; +use App\Administration\Application\Query\GetUsers\UserDto; +use App\Administration\Application\Service\Cache\PaginatedQueryCache; use DateTimeImmutable; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Adapter\TagAwareAdapter; final class GetUsersHandlerTest extends TestCase { - private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002'; - private const string OTHER_TENANT_ID = '550e8400-e29b-41d4-a716-446655440099'; - - private InMemoryUserRepository $userRepository; - private Clock $clock; + private PaginatedUsersReader $reader; + private PaginatedQueryCache $cache; private GetUsersHandler $handler; protected function setUp(): void { - $this->userRepository = new InMemoryUserRepository(); - $this->clock = new class implements Clock { - public function now(): DateTimeImmutable - { - return new DateTimeImmutable('2026-02-07 10:00:00'); - } - }; - $this->handler = new GetUsersHandler($this->userRepository, $this->clock); - } - - #[Test] - public function returnsAllUsersForTenant(): void - { - $this->seedUsers(); - - $query = new GetUsersQuery(tenantId: self::TENANT_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 filtersUsersByRole(): void - { - $this->seedUsers(); - - $query = new GetUsersQuery( - tenantId: self::TENANT_ID, - role: Role::PROF->value, + $this->reader = $this->createMock(PaginatedUsersReader::class); + $this->cache = new PaginatedQueryCache( + new TagAwareAdapter(new ArrayAdapter()), ); - $result = ($this->handler)($query); - - self::assertCount(2, $result->items); - self::assertSame(2, $result->total); - foreach ($result->items as $dto) { - self::assertSame(Role::PROF->value, $dto->role); - } + $this->handler = new GetUsersHandler($this->reader, $this->cache); } #[Test] - public function filtersUsersByStatut(): void + public function returnsUsersForTenant(): void { - $this->seedUsers(); - - $query = new GetUsersQuery( - tenantId: self::TENANT_ID, - statut: 'pending', + $dto = $this->createUserDto(); + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30), ); - $result = ($this->handler)($query); - self::assertCount(2, $result->items); - self::assertSame(2, $result->total); - foreach ($result->items as $dto) { - self::assertSame('pending', $dto->statut); - } - } - - #[Test] - public function excludesUsersFromOtherTenants(): void - { - $this->seedUsers(); - - $otherUser = User::inviter( - email: new Email('other@example.com'), - role: Role::ADMIN, - tenantId: TenantId::fromString(self::OTHER_TENANT_ID), - schoolName: 'Autre École', - firstName: 'Autre', - lastName: 'User', - invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'), - ); - $this->userRepository->save($otherUser); - - $query = new GetUsersQuery(tenantId: self::TENANT_ID); - $result = ($this->handler)($query); - - self::assertCount(3, $result->items); - self::assertSame(3, $result->total); - } - - #[Test] - public function calculatesInvitationExpiree(): void - { - $user = User::inviter( - email: new Email('old@example.com'), - role: Role::PROF, - tenantId: TenantId::fromString(self::TENANT_ID), - schoolName: 'École Alpha', - firstName: 'Old', - lastName: 'Invitation', - invitedAt: new DateTimeImmutable('2026-01-25 10:00:00'), - ); - $this->userRepository->save($user); - - $query = new GetUsersQuery(tenantId: self::TENANT_ID); - $result = ($this->handler)($query); + $result = ($this->handler)(new GetUsersQuery(tenantId: 'tenant-1')); self::assertCount(1, $result->items); - self::assertTrue($result->items[0]->invitationExpiree); + self::assertSame(1, $result->total); + } + + #[Test] + public function mapsDtoFields(): void + { + $dto = $this->createUserDto(); + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30), + ); + + $result = ($this->handler)(new GetUsersQuery(tenantId: 'tenant-1')); + + $item = $result->items[0]; + self::assertSame('user-1', $item->id); + self::assertSame('prof@test.com', $item->email); + self::assertSame('ROLE_PROF', $item->role); + self::assertSame('Dupont', $item->lastName); } #[Test] public function paginatesResults(): void { - $this->seedUsers(); - - $query = new GetUsersQuery( - tenantId: self::TENANT_ID, - page: 1, - limit: 2, + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [], total: 50, page: 2, limit: 10), ); - $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()); - } + $result = ($this->handler)(new GetUsersQuery(tenantId: 'tenant-1', page: 2, limit: 10)); - #[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(50, $result->total); self::assertSame(2, $result->page); + self::assertSame(10, $result->limit); } #[Test] - public function searchesByFirstName(): void + public function cachesResult(): void { - $this->seedUsers(); - - $query = new GetUsersQuery( - tenantId: self::TENANT_ID, - search: 'Jean', + $dto = $this->createUserDto(); + $this->reader->expects(self::once())->method('findPaginated')->willReturn( + new PaginatedResult(items: [$dto], total: 1, page: 1, limit: 30), ); + + $query = new GetUsersQuery(tenantId: 'tenant-1'); + ($this->handler)($query); $result = ($this->handler)($query); self::assertCount(1, $result->items); - self::assertSame('Jean', $result->items[0]->firstName); } #[Test] - public function searchesByLastName(): void + public function clampsPageToMinimumOne(): void { - $this->seedUsers(); - - $query = new GetUsersQuery( - tenantId: self::TENANT_ID, - search: 'Martin', + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [], total: 0, page: 1, limit: 30), ); - $result = ($this->handler)($query); - self::assertCount(1, $result->items); - self::assertSame('Martin', $result->items[0]->lastName); + $result = ($this->handler)(new GetUsersQuery(tenantId: 'tenant-1', page: -5)); + + self::assertSame(1, $result->page); } #[Test] - public function searchesByEmail(): void + public function clampsLimitToMaximumHundred(): void { - $this->seedUsers(); - - $query = new GetUsersQuery( - tenantId: self::TENANT_ID, - search: 'parent@', + $this->reader->method('findPaginated')->willReturn( + new PaginatedResult(items: [], total: 0, page: 1, limit: 100), ); - $result = ($this->handler)($query); - self::assertCount(1, $result->items); - self::assertSame('parent@example.com', $result->items[0]->email); + $result = ($this->handler)(new GetUsersQuery(tenantId: 'tenant-1', limit: 500)); + + self::assertSame(100, $result->limit); } - #[Test] - public function searchIsCaseInsensitive(): void + private function createUserDto(): UserDto { - $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 - { - $tenantId = TenantId::fromString(self::TENANT_ID); - - $teacher1 = User::inviter( - email: new Email('teacher1@example.com'), - role: Role::PROF, - tenantId: $tenantId, - schoolName: 'École Alpha', + return new UserDto( + id: 'user-1', + email: 'prof@test.com', + role: 'ROLE_PROF', + roleLabel: 'Enseignant', + roles: ['ROLE_PROF'], firstName: 'Jean', lastName: 'Dupont', - invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'), + statut: 'actif', + createdAt: new DateTimeImmutable('2026-01-15'), + invitedAt: new DateTimeImmutable('2026-01-10'), + activatedAt: new DateTimeImmutable('2026-01-12'), + blockedAt: null, + blockedReason: null, + invitationExpiree: false, ); - $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: new DateTimeImmutable('2026-02-01 10:00:00'), - ); - $teacher2->activer( - '$argon2id$hashed', - new DateTimeImmutable('2026-02-02 10:00:00'), - new ConsentementParentalPolicy($this->clock), - ); - $this->userRepository->save($teacher2); - - $parent = User::inviter( - email: new Email('parent@example.com'), - role: Role::PARENT, - tenantId: $tenantId, - schoolName: 'École Alpha', - firstName: 'Pierre', - lastName: 'Parent', - invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'), - ); - $this->userRepository->save($parent); } } diff --git a/backend/tests/Unit/Administration/Application/Service/Cache/PaginatedQueryCacheInvalidatorTest.php b/backend/tests/Unit/Administration/Application/Service/Cache/PaginatedQueryCacheInvalidatorTest.php new file mode 100644 index 0000000..cc8822a --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Service/Cache/PaginatedQueryCacheInvalidatorTest.php @@ -0,0 +1,348 @@ +cache = new PaginatedQueryCache( + new TagAwareAdapter(new ArrayAdapter()), + ); + $this->invalidator = new PaginatedQueryCacheInvalidator($this->cache); + } + + // === Users === + + #[Test] + public function utilisateurInviteInvalidatesUsersCache(): void + { + $this->warmCache('users', self::TENANT_ID); + + $this->invalidator->onUtilisateurInvite($this->createUtilisateurInvite(self::TENANT_ID)); + + $this->assertCacheWasInvalidated('users', self::TENANT_ID); + } + + #[Test] + public function invitationRenvoyeeInvalidatesUsersCache(): void + { + $this->warmCache('users', self::TENANT_ID); + + $event = new InvitationRenvoyee( + userId: UserId::generate(), + email: 'test@example.com', + tenantId: TenantId::fromString(self::TENANT_ID), + occurredOn: new DateTimeImmutable(), + ); + + $this->invalidator->onInvitationRenvoyee($event); + + $this->assertCacheWasInvalidated('users', self::TENANT_ID); + } + + #[Test] + public function eleveInscritInvalidatesUsersAndImageRightsCache(): void + { + $this->warmCache('users', self::TENANT_ID); + $this->warmCache('students_image_rights', self::TENANT_ID); + + $event = new EleveInscrit( + userId: UserId::generate(), + firstName: 'Alice', + lastName: 'Martin', + tenantId: TenantId::fromString(self::TENANT_ID), + occurredOn: new DateTimeImmutable(), + ); + + $this->invalidator->onEleveInscrit($event); + + $this->assertCacheWasInvalidated('users', self::TENANT_ID); + $this->assertCacheWasInvalidated('students_image_rights', self::TENANT_ID); + } + + // === Classes === + + #[Test] + public function classeCreeeInvalidatesClassesAndAssignmentsCache(): void + { + $this->warmCache('classes', self::TENANT_ID); + $this->warmCache('assignments', self::TENANT_ID); + + $event = new ClasseCreee( + classId: ClassId::generate(), + tenantId: TenantId::fromString(self::TENANT_ID), + name: new ClassName('6ème A'), + level: null, + occurredOn: new DateTimeImmutable(), + ); + + $this->invalidator->onClasseCreee($event); + + $this->assertCacheWasInvalidated('classes', self::TENANT_ID); + $this->assertCacheWasInvalidated('assignments', self::TENANT_ID); + } + + // === Subjects === + + #[Test] + public function matiereCreeeInvalidatesSubjectsAndAssignmentsCache(): void + { + $this->warmCache('subjects', self::TENANT_ID); + $this->warmCache('assignments', self::TENANT_ID); + + $event = new MatiereCreee( + subjectId: SubjectId::generate(), + tenantId: TenantId::fromString(self::TENANT_ID), + name: new SubjectName('Mathématiques'), + code: new SubjectCode('MATH'), + occurredOn: new DateTimeImmutable(), + ); + + $this->invalidator->onMatiereCreee($event); + + $this->assertCacheWasInvalidated('subjects', self::TENANT_ID); + $this->assertCacheWasInvalidated('assignments', self::TENANT_ID); + } + + // === Assignments === + + #[Test] + public function enseignantAffecteInvalidatesAssignmentsCache(): void + { + $this->warmCache('assignments', self::TENANT_ID); + + $event = new EnseignantAffecte( + assignmentId: TeacherAssignmentId::generate(), + teacherId: UserId::generate(), + classId: ClassId::generate(), + subjectId: SubjectId::generate(), + tenantId: TenantId::fromString(self::TENANT_ID), + occurredOn: new DateTimeImmutable(), + ); + + $this->invalidator->onEnseignantAffecte($event); + + $this->assertCacheWasInvalidated('assignments', self::TENANT_ID); + } + + #[Test] + public function affectationRetireeInvalidatesAssignmentsCache(): void + { + $this->warmCache('assignments', self::TENANT_ID); + + $event = new AffectationRetiree( + assignmentId: TeacherAssignmentId::generate(), + teacherId: UserId::generate(), + classId: ClassId::generate(), + subjectId: SubjectId::generate(), + tenantId: TenantId::fromString(self::TENANT_ID), + occurredOn: new DateTimeImmutable(), + ); + + $this->invalidator->onAffectationRetiree($event); + + $this->assertCacheWasInvalidated('assignments', self::TENANT_ID); + } + + // === Parent invitations === + + #[Test] + public function invitationParentActiveeInvalidatesParentInvitationsAndUsersCache(): void + { + $this->warmCache('parent_invitations', self::TENANT_ID); + $this->warmCache('users', self::TENANT_ID); + + $event = new InvitationParentActivee( + invitationId: ParentInvitationId::generate(), + studentId: UserId::generate(), + parentUserId: UserId::generate(), + tenantId: TenantId::fromString(self::TENANT_ID), + occurredOn: new DateTimeImmutable(), + ); + + $this->invalidator->onInvitationParentActivee($event); + + $this->assertCacheWasInvalidated('parent_invitations', self::TENANT_ID); + $this->assertCacheWasInvalidated('users', self::TENANT_ID); + } + + // === Image rights === + + #[Test] + public function droitImageModifieInvalidatesImageRightsCache(): void + { + $this->warmCache('students_image_rights', self::TENANT_ID); + + $event = new DroitImageModifie( + userId: UserId::generate(), + email: 'alice@example.com', + ancienStatut: ImageRightsStatus::NOT_SPECIFIED, + nouveauStatut: ImageRightsStatus::AUTHORIZED, + modifiePar: UserId::generate(), + tenantId: TenantId::fromString(self::TENANT_ID), + occurredOn: new DateTimeImmutable(), + ); + + $this->invalidator->onDroitImageModifie($event); + + $this->assertCacheWasInvalidated('students_image_rights', self::TENANT_ID); + } + + // === Imports === + + #[Test] + public function importElevesTermineInvalidatesUsersImageRightsAndClassesCache(): void + { + $this->warmCache('users', self::TENANT_ID); + $this->warmCache('students_image_rights', self::TENANT_ID); + $this->warmCache('classes', self::TENANT_ID); + + $event = new ImportElevesTermine( + batchId: ImportBatchId::generate(), + tenantId: TenantId::fromString(self::TENANT_ID), + importedCount: 10, + errorCount: 0, + occurredOn: new DateTimeImmutable(), + ); + + $this->invalidator->onImportElevesTermine($event); + + $this->assertCacheWasInvalidated('users', self::TENANT_ID); + $this->assertCacheWasInvalidated('students_image_rights', self::TENANT_ID); + $this->assertCacheWasInvalidated('classes', self::TENANT_ID); + } + + #[Test] + public function importEnseignantsTermineInvalidatesUsersAndAssignmentsCache(): void + { + $this->warmCache('users', self::TENANT_ID); + $this->warmCache('assignments', self::TENANT_ID); + + $event = new ImportEnseignantsTermine( + batchId: ImportBatchId::generate(), + tenantId: TenantId::fromString(self::TENANT_ID), + importedCount: 5, + errorCount: 0, + occurredOn: new DateTimeImmutable(), + ); + + $this->invalidator->onImportEnseignantsTermine($event); + + $this->assertCacheWasInvalidated('users', self::TENANT_ID); + $this->assertCacheWasInvalidated('assignments', self::TENANT_ID); + } + + // === Tenant isolation === + + #[Test] + public function invalidationDoesNotAffectOtherTenants(): void + { + $otherTenantId = '550e8400-e29b-41d4-a716-446655440099'; + + $this->warmCache('users', self::TENANT_ID); + $this->warmCache('users', $otherTenantId); + + $this->invalidator->onUtilisateurInvite($this->createUtilisateurInvite(self::TENANT_ID)); + + $this->assertCacheWasInvalidated('users', self::TENANT_ID); + $this->assertCacheStillValid('users', $otherTenantId); + } + + #[Test] + public function invalidationDoesNotAffectOtherEntityTypes(): void + { + $this->warmCache('users', self::TENANT_ID); + $this->warmCache('classes', self::TENANT_ID); + + $this->invalidator->onUtilisateurInvite($this->createUtilisateurInvite(self::TENANT_ID)); + + $this->assertCacheWasInvalidated('users', self::TENANT_ID); + $this->assertCacheStillValid('classes', self::TENANT_ID); + } + + private function warmCache(string $entityType, string $tenantId): void + { + $this->cache->getOrLoad( + $entityType, + $tenantId, + ['page' => 1, 'limit' => 30], + static fn (): PaginatedResult => new PaginatedResult(items: ['original'], total: 1, page: 1, limit: 30), + ); + } + + private function assertCacheWasInvalidated(string $entityType, string $tenantId): void + { + $result = $this->cache->getOrLoad( + $entityType, + $tenantId, + ['page' => 1, 'limit' => 30], + static fn (): PaginatedResult => new PaginatedResult(items: ['fresh'], total: 1, page: 1, limit: 30), + ); + + self::assertSame(['fresh'], $result->items, "Cache for {$entityType}/{$tenantId} should have been invalidated"); + } + + private function assertCacheStillValid(string $entityType, string $tenantId): void + { + $result = $this->cache->getOrLoad( + $entityType, + $tenantId, + ['page' => 1, 'limit' => 30], + static fn (): PaginatedResult => new PaginatedResult(items: ['fresh'], total: 1, page: 1, limit: 30), + ); + + self::assertSame(['original'], $result->items, "Cache for {$entityType}/{$tenantId} should still contain original data"); + } + + private function createUtilisateurInvite(string $tenantId): UtilisateurInvite + { + return new UtilisateurInvite( + userId: UserId::generate(), + email: 'test@example.com', + role: 'ROLE_PROF', + firstName: 'Jean', + lastName: 'Dupont', + tenantId: TenantId::fromString($tenantId), + occurredOn: new DateTimeImmutable(), + ); + } +} diff --git a/backend/tests/Unit/Administration/Application/Service/Cache/PaginatedQueryCacheTest.php b/backend/tests/Unit/Administration/Application/Service/Cache/PaginatedQueryCacheTest.php new file mode 100644 index 0000000..bd19756 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Service/Cache/PaginatedQueryCacheTest.php @@ -0,0 +1,166 @@ +cache = new PaginatedQueryCache( + new TagAwareAdapter(new ArrayAdapter()), + ); + } + + #[Test] + public function loadsFromCallableOnCacheMiss(): void + { + $expected = new PaginatedResult(items: ['item1'], total: 1, page: 1, limit: 30); + $callCount = 0; + + $result = $this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], static function () use ($expected, &$callCount): PaginatedResult { + ++$callCount; + + return $expected; + }); + + self::assertSame($expected, $result); + self::assertSame(1, $callCount); + } + + #[Test] + public function returnsCachedResultOnHit(): void + { + $expected = new PaginatedResult(items: ['item1'], total: 1, page: 1, limit: 30); + $callCount = 0; + + $loader = static function () use ($expected, &$callCount): PaginatedResult { + ++$callCount; + + return $expected; + }; + + $this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], $loader); + $result = $this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], $loader); + + self::assertSame(1, $callCount); + self::assertEquals($expected, $result); + } + + #[Test] + public function differentParamsProduceDifferentCacheEntries(): void + { + $result1 = new PaginatedResult(items: ['page1'], total: 2, page: 1, limit: 1); + $result2 = new PaginatedResult(items: ['page2'], total: 2, page: 2, limit: 1); + + $this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], static fn (): PaginatedResult => $result1); + $actual = $this->cache->getOrLoad('users', 'tenant-1', ['page' => 2], static fn (): PaginatedResult => $result2); + + self::assertEquals($result2, $actual); + } + + #[Test] + public function invalidatesCacheByEntityTypeAndTenant(): void + { + $callCount = 0; + $loader = static function () use (&$callCount): PaginatedResult { + ++$callCount; + + return new PaginatedResult(items: [], total: 0, page: 1, limit: 30); + }; + + $this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], $loader); + self::assertSame(1, $callCount); + + $this->cache->invalidate('users', 'tenant-1'); + + $this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], $loader); + self::assertSame(2, $callCount); + } + + #[Test] + public function invalidationDoesNotAffectOtherEntityTypes(): void + { + $usersCallCount = 0; + $classesCallCount = 0; + + $usersLoader = static function () use (&$usersCallCount): PaginatedResult { + ++$usersCallCount; + + return new PaginatedResult(items: [], total: 0, page: 1, limit: 30); + }; + + $classesLoader = static function () use (&$classesCallCount): PaginatedResult { + ++$classesCallCount; + + return new PaginatedResult(items: [], total: 0, page: 1, limit: 30); + }; + + $this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], $usersLoader); + $this->cache->getOrLoad('classes', 'tenant-1', ['page' => 1], $classesLoader); + + $this->cache->invalidate('users', 'tenant-1'); + + $this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], $usersLoader); + $this->cache->getOrLoad('classes', 'tenant-1', ['page' => 1], $classesLoader); + + self::assertSame(2, $usersCallCount); + self::assertSame(1, $classesCallCount); + } + + #[Test] + public function invalidationDoesNotAffectOtherTenants(): void + { + $tenant1Count = 0; + $tenant2Count = 0; + + $tenant1Loader = static function () use (&$tenant1Count): PaginatedResult { + ++$tenant1Count; + + return new PaginatedResult(items: [], total: 0, page: 1, limit: 30); + }; + + $tenant2Loader = static function () use (&$tenant2Count): PaginatedResult { + ++$tenant2Count; + + return new PaginatedResult(items: [], total: 0, page: 1, limit: 30); + }; + + $this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], $tenant1Loader); + $this->cache->getOrLoad('users', 'tenant-2', ['page' => 1], $tenant2Loader); + + $this->cache->invalidate('users', 'tenant-1'); + + $this->cache->getOrLoad('users', 'tenant-1', ['page' => 1], $tenant1Loader); + $this->cache->getOrLoad('users', 'tenant-2', ['page' => 1], $tenant2Loader); + + self::assertSame(2, $tenant1Count); + self::assertSame(1, $tenant2Count); + } + + #[Test] + public function paramOrderDoesNotAffectCacheKey(): void + { + $callCount = 0; + $loader = static function () use (&$callCount): PaginatedResult { + ++$callCount; + + return new PaginatedResult(items: [], total: 0, page: 1, limit: 30); + }; + + $this->cache->getOrLoad('users', 'tenant-1', ['page' => 1, 'role' => 'admin'], $loader); + $this->cache->getOrLoad('users', 'tenant-1', ['role' => 'admin', 'page' => 1], $loader); + + self::assertSame(1, $callCount); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Middleware/PaginatedCacheInvalidationMiddlewareTest.php b/backend/tests/Unit/Administration/Infrastructure/Middleware/PaginatedCacheInvalidationMiddlewareTest.php new file mode 100644 index 0000000..0e3b4a1 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Middleware/PaginatedCacheInvalidationMiddlewareTest.php @@ -0,0 +1,224 @@ +cache = new PaginatedQueryCache( + new TagAwareAdapter(new ArrayAdapter()), + ); + $this->middleware = new PaginatedCacheInvalidationMiddleware($this->cache); + } + + #[Test] + public function invalidatesUsersCacheOnUtilisateurInvite(): void + { + $this->warmCache('users', self::TENANT_ID); + + $event = new UtilisateurInvite( + userId: UserId::generate(), + email: 'test@example.com', + role: 'ROLE_PROF', + firstName: 'Jean', + lastName: 'Dupont', + tenantId: TenantId::fromString(self::TENANT_ID), + occurredOn: new DateTimeImmutable(), + ); + + $this->middleware->handle(new Envelope($event), $this->createPassthroughStack()); + + $this->assertCacheWasInvalidated('users', self::TENANT_ID); + } + + #[Test] + public function invalidatesUsersCacheOnCompteActive(): void + { + $this->warmCache('users', self::TENANT_ID); + + $event = new CompteActive( + userId: Uuid::uuid4()->toString(), + email: 'test@example.com', + tenantId: TenantId::fromString(self::TENANT_ID), + role: 'ROLE_PROF', + occurredOn: new DateTimeImmutable(), + aggregateId: Uuid::uuid4(), + ); + + $this->middleware->handle(new Envelope($event), $this->createPassthroughStack()); + + $this->assertCacheWasInvalidated('users', self::TENANT_ID); + } + + #[Test] + public function invalidatesUsersCacheOnInvitationRenvoyee(): void + { + $this->warmCache('users', self::TENANT_ID); + + $event = new InvitationRenvoyee( + userId: UserId::generate(), + email: 'test@example.com', + tenantId: TenantId::fromString(self::TENANT_ID), + occurredOn: new DateTimeImmutable(), + ); + + $this->middleware->handle(new Envelope($event), $this->createPassthroughStack()); + + $this->assertCacheWasInvalidated('users', self::TENANT_ID); + } + + #[Test] + public function invalidatesParentInvitationsCacheOnInvitationParentEnvoyee(): void + { + $this->warmCache('parent_invitations', self::TENANT_ID); + + $event = new InvitationParentEnvoyee( + invitationId: ParentInvitationId::generate(), + studentId: UserId::generate(), + parentEmail: new Email('parent@example.com'), + tenantId: TenantId::fromString(self::TENANT_ID), + occurredOn: new DateTimeImmutable(), + ); + + $this->middleware->handle(new Envelope($event), $this->createPassthroughStack()); + + $this->assertCacheWasInvalidated('parent_invitations', self::TENANT_ID); + } + + #[Test] + public function invalidatesParentInvitationsAndUsersCacheOnInvitationParentActivee(): void + { + $this->warmCache('parent_invitations', self::TENANT_ID); + $this->warmCache('users', self::TENANT_ID); + + $event = new InvitationParentActivee( + invitationId: ParentInvitationId::generate(), + studentId: UserId::generate(), + parentUserId: UserId::generate(), + tenantId: TenantId::fromString(self::TENANT_ID), + occurredOn: new DateTimeImmutable(), + ); + + $this->middleware->handle(new Envelope($event), $this->createPassthroughStack()); + + $this->assertCacheWasInvalidated('parent_invitations', self::TENANT_ID); + $this->assertCacheWasInvalidated('users', self::TENANT_ID); + } + + #[Test] + public function doesNotInvalidateOnReceivedStamp(): void + { + $this->warmCache('users', self::TENANT_ID); + + $event = new UtilisateurInvite( + userId: UserId::generate(), + email: 'test@example.com', + role: 'ROLE_PROF', + firstName: 'Jean', + lastName: 'Dupont', + tenantId: TenantId::fromString(self::TENANT_ID), + occurredOn: new DateTimeImmutable(), + ); + + $envelope = new Envelope($event, [new ReceivedStamp('async')]); + $this->middleware->handle($envelope, $this->createPassthroughStack()); + + $this->assertCacheStillValid('users', self::TENANT_ID); + } + + #[Test] + public function doesNotInvalidateOnUnrelatedEvent(): void + { + $this->warmCache('users', self::TENANT_ID); + + $event = new UtilisateurBloque( + userId: UserId::generate(), + email: 'test@example.com', + reason: 'test', + tenantId: TenantId::fromString(self::TENANT_ID), + occurredOn: new DateTimeImmutable(), + ); + + $this->middleware->handle(new Envelope($event), $this->createPassthroughStack()); + + $this->assertCacheStillValid('users', self::TENANT_ID); + } + + private function warmCache(string $entityType, string $tenantId): void + { + $this->cache->getOrLoad( + $entityType, + $tenantId, + ['page' => 1, 'limit' => 30], + static fn (): PaginatedResult => new PaginatedResult(items: ['original'], total: 1, page: 1, limit: 30), + ); + } + + private function assertCacheWasInvalidated(string $entityType, string $tenantId): void + { + $result = $this->cache->getOrLoad( + $entityType, + $tenantId, + ['page' => 1, 'limit' => 30], + static fn (): PaginatedResult => new PaginatedResult(items: ['fresh'], total: 1, page: 1, limit: 30), + ); + + self::assertSame(['fresh'], $result->items, "Cache for {$entityType}/{$tenantId} should have been invalidated"); + } + + private function assertCacheStillValid(string $entityType, string $tenantId): void + { + $result = $this->cache->getOrLoad( + $entityType, + $tenantId, + ['page' => 1, 'limit' => 30], + static fn (): PaginatedResult => new PaginatedResult(items: ['fresh'], total: 1, page: 1, limit: 30), + ); + + self::assertSame(['original'], $result->items, "Cache for {$entityType}/{$tenantId} should still contain original data"); + } + + private function createPassthroughStack(): StackInterface + { + $stack = $this->createMock(StackInterface::class); + $nextMiddleware = $this->createMock(\Symfony\Component\Messenger\Middleware\MiddlewareInterface::class); + + $stack->method('next')->willReturn($nextMiddleware); + $nextMiddleware->method('handle')->willReturnCallback( + static fn (Envelope $envelope): Envelope => $envelope, + ); + + return $stack; + } +} diff --git a/frontend/src/routes/admin/image-rights/+page.svelte b/frontend/src/routes/admin/image-rights/+page.svelte index 7bda8b3..73359f6 100644 --- a/frontend/src/routes/admin/image-rights/+page.svelte +++ b/frontend/src/routes/admin/image-rights/+page.svelte @@ -3,8 +3,9 @@ import { page } from '$app/state'; import { getApiBaseUrl } from '$lib/api/config'; import { authenticatedFetch } from '$lib/auth'; + import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte'; import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte'; - import { untrack } from 'svelte'; + import { onMount } from 'svelte'; interface StudentImageRights { id: string; @@ -23,32 +24,27 @@ { value: 'not_specified', label: 'Non renseigné' } ]; + const itemsPerPage = 30; + // State let students = $state([]); let isLoading = $state(true); let error = $state(null); let successMessage = $state(null); + let totalItems = $state(0); + let currentPage = $state(Number(page.url.searchParams.get('page')) || 1); // Filters let filterStatus = $state(page.url.searchParams.get('status') ?? ''); let searchTerm = $state(page.url.searchParams.get('search') ?? ''); - // Derived groups - let filteredStudents = $derived.by(() => { - if (!searchTerm) return students; - const term = searchTerm.toLowerCase(); - return students.filter( - (s) => - s.firstName.toLowerCase().includes(term) || - s.lastName.toLowerCase().includes(term) || - s.email.toLowerCase().includes(term) - ); - }); + // Derived + let totalPages = $derived(Math.ceil(totalItems / itemsPerPage)); let authorizedStudents = $derived( - filteredStudents.filter((s) => s.imageRightsStatus === 'authorized') + students.filter((s) => s.imageRightsStatus === 'authorized') ); let unauthorizedStudents = $derived( - filteredStudents.filter((s) => s.imageRightsStatus !== 'authorized') + students.filter((s) => s.imageRightsStatus !== 'authorized') ); // Updating state @@ -57,8 +53,8 @@ let loadAbortController: AbortController | null = null; - $effect(() => { - untrack(() => loadStudents()); + onMount(() => { + loadStudents(); }); async function loadStudents() { @@ -71,23 +67,28 @@ error = null; const apiUrl = getApiBaseUrl(); const params = new URLSearchParams(); + params.set('page', String(currentPage)); + params.set('itemsPerPage', String(itemsPerPage)); if (filterStatus) params.set('status', filterStatus); + if (searchTerm) params.set('search', searchTerm); const query = params.toString(); - const url = `${apiUrl}/students/image-rights${query ? `?${query}` : ''}`; + const url = `${apiUrl}/students/image-rights?${query}`; const response = await authenticatedFetch(url, { signal: controller.signal }); if (controller.signal.aborted) return; if (!response.ok) { - throw new Error('Erreur lors du chargement des droits à l\'image'); + throw new Error("Erreur lors du chargement des droits à l'image"); } const data = await response.json(); - students = Array.isArray(data) ? data : data['member'] ?? data['hydra:member'] ?? []; + students = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []); + totalItems = data['hydra:totalItems'] ?? data['totalItems'] ?? students.length; } catch (e) { if (e instanceof DOMException && e.name === 'AbortError') return; error = e instanceof Error ? e.message : 'Erreur inconnue'; students = []; + totalItems = 0; } finally { if (!controller.signal.aborted) { isLoading = false; @@ -97,6 +98,7 @@ function updateUrl() { const params = new URLSearchParams(); + if (currentPage > 1) params.set('page', String(currentPage)); if (filterStatus) params.set('status', filterStatus); if (searchTerm) params.set('search', searchTerm); const query = params.toString(); @@ -105,10 +107,19 @@ function handleSearch(value: string) { searchTerm = value; + currentPage = 1; updateUrl(); + loadStudents(); + } + + function handlePageChange(newPage: number) { + currentPage = newPage; + updateUrl(); + loadStudents(); } function applyFilters() { + currentPage = 1; updateUrl(); loadStudents(); } @@ -116,6 +127,7 @@ function resetFilters() { filterStatus = ''; searchTerm = ''; + currentPage = 1; updateUrl(); loadStudents(); } @@ -158,7 +170,7 @@ : s ); successMessage = 'Statut mis à jour avec succès.'; - setTimeout(() => (successMessage = null), 3000); + window.setTimeout(() => (successMessage = null), 3000); } catch (e) { error = e instanceof Error ? e.message : 'Erreur lors de la mise à jour'; } finally { @@ -178,7 +190,7 @@ const response = await authenticatedFetch(url); if (!response.ok) { - throw new Error('Erreur lors de l\'export'); + throw new Error("Erreur lors de l'export"); } const blob = await response.blob(); @@ -192,9 +204,9 @@ URL.revokeObjectURL(downloadUrl); successMessage = 'Export CSV téléchargé.'; - setTimeout(() => (successMessage = null), 3000); + window.setTimeout(() => (successMessage = null), 3000); } catch (e) { - error = e instanceof Error ? e.message : 'Erreur lors de l\'export'; + error = e instanceof Error ? e.message : "Erreur lors de l'export"; } finally { isExporting = false; } @@ -264,14 +276,14 @@

Chargement...

- {:else if students.length === 0} + {:else if students.length === 0 && !searchTerm && !filterStatus}
📷

Aucun élève inscrit

Commencez par créer des comptes élèves pour pouvoir gérer leurs autorisations de droit à l'image.

Gérer les utilisateurs
- {:else if filteredStudents.length === 0} + {:else if students.length === 0}
🔍

Aucun résultat

@@ -281,19 +293,13 @@ {:else}
- {authorizedStudents.length} autorisé{authorizedStudents.length > 1 ? 's' : ''} - - - {unauthorizedStudents.length} non autorisé{unauthorizedStudents.length > 1 ? 's' : ''} - - - {filteredStudents.length} total + {totalItems} élève{totalItems > 1 ? 's' : ''} au total
-
-

Élèves autorisés ({authorizedStudents.length})

- {#if authorizedStudents.length > 0} + {#if authorizedStudents.length > 0} +
+

Élèves autorisés ({authorizedStudents.length})

@@ -332,14 +338,12 @@
- {:else} -

Aucun élève autorisé.

- {/if} -
+
+ {/if} -
-

Élèves non autorisés ({unauthorizedStudents.length})

- {#if unauthorizedStudents.length > 0} + {#if unauthorizedStudents.length > 0} +
+

Élèves non autorisés ({unauthorizedStudents.length})

@@ -378,10 +382,10 @@
- {:else} -

Tous les élèves sont autorisés.

- {/if} -
+
+ {/if} + + {/if}
@@ -539,11 +543,6 @@ .stat-count { font-weight: 700; - color: #166534; - } - - .stat-count.stat-danger { - color: #991b1b; } .stat-count.stat-total { @@ -679,12 +678,6 @@ } } - .empty-section { - padding: 1rem; - color: var(--text-secondary, #999); - font-style: italic; - } - @media (max-width: 768px) { .page-header { flex-direction: column;