feat: Optimiser la pagination avec cache-aside et ports de lecture dédiés

Les listes paginées (utilisateurs, classes, matières, affectations,
invitations parents, droits à l'image) effectuaient des requêtes SQL
complètes à chaque chargement de page, sans aucun cache. Sur les
établissements avec plusieurs centaines d'enregistrements, cela causait
des temps de réponse perceptibles et une charge inutile sur PostgreSQL.

Cette refactorisation introduit un cache tag-aware (Redis en prod,
filesystem en dev) avec invalidation événementielle, et extrait les
requêtes de lecture dans des ports Application / implémentations DBAL
conformes à l'architecture hexagonale. Un middleware Messenger garantit
l'invalidation synchrone du cache même pour les événements routés en
asynchrone (envoi d'emails), évitant ainsi toute donnée périmée côté UI.
This commit is contained in:
2026-03-01 14:33:56 +01:00
parent ce05207c64
commit 23dd7177f2
41 changed files with 2854 additions and 1584 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Query\GetAllAssignments\AssignmentWithNamesDto;
/**
* Read-model port for paginated assignment queries (CQRS read side).
*
* @phpstan-type Result = PaginatedResult<AssignmentWithNamesDto>
*/
interface PaginatedAssignmentsReader
{
/**
* @return PaginatedResult<AssignmentWithNamesDto>
*/
public function findPaginated(
string $tenantId,
?string $search,
int $page,
int $limit,
): PaginatedResult;
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Query\GetClasses\ClassDto;
/**
* Read-model port for paginated class queries (CQRS read side).
*
* @phpstan-type Result = PaginatedResult<ClassDto>
*/
interface PaginatedClassesReader
{
/**
* @return PaginatedResult<ClassDto>
*/
public function findPaginated(
string $tenantId,
string $academicYearId,
?string $search,
int $page,
int $limit,
): PaginatedResult;
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Query\GetParentInvitations\ParentInvitationDto;
/**
* Read-model port for paginated parent invitation queries (CQRS read side).
*
* @phpstan-type Result = PaginatedResult<ParentInvitationDto>
*/
interface PaginatedParentInvitationsReader
{
/**
* @return PaginatedResult<ParentInvitationDto>
*/
public function findPaginated(
string $tenantId,
?string $status,
?string $studentId,
?string $search,
int $page,
int $limit,
): PaginatedResult;
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Query\GetStudentsImageRights\StudentImageRightsDto;
/**
* Read-model port for paginated student image rights queries (CQRS read side).
*
* @phpstan-type Result = PaginatedResult<StudentImageRightsDto>
*/
interface PaginatedStudentImageRightsReader
{
/**
* @return PaginatedResult<StudentImageRightsDto>
*/
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;
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Query\GetSubjects\SubjectDto;
/**
* Read-model port for paginated subject queries (CQRS read side).
*
* @phpstan-type Result = PaginatedResult<SubjectDto>
*/
interface PaginatedSubjectsReader
{
/**
* @return PaginatedResult<SubjectDto>
*/
public function findPaginated(
string $tenantId,
string $schoolId,
?string $search,
int $page,
int $limit,
): PaginatedResult;
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Query\GetUsers\UserDto;
/**
* Read-model port for paginated user queries (CQRS read side).
*
* @phpstan-type Result = PaginatedResult<UserDto>
*/
interface PaginatedUsersReader
{
/**
* @return PaginatedResult<UserDto>
*/
public function findPaginated(
string $tenantId,
?string $role,
?string $statut,
?string $search,
int $page,
int $limit,
): PaginatedResult;
}

View File

@@ -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<string, array{firstName: string, lastName: string}> $userNames */
$userNames = [];
foreach ($users as $user) {
$userNames[(string) $user->id] = [
'firstName' => $user->firstName,
'lastName' => $user->lastName,
];
}
$classes = $this->classRepository->findAllActiveByTenant($tenantId);
/** @var array<string, string> $classNames */
$classNames = [];
foreach ($classes as $class) {
$classNames[(string) $class->id] = (string) $class->name;
}
$subjects = $this->subjectRepository->findAllActiveByTenant($tenantId);
/** @var array<string, string> $subjectNames */
$subjectNames = [];
foreach ($subjects as $subject) {
$subjectNames[(string) $subject->id] = (string) $subject->name;
}
$dtos = [];
foreach ($assignments as $assignment) {
$teacherId = (string) $assignment->teacherId;
$classId = (string) $assignment->classId;
$subjectId = (string) $assignment->subjectId;
if (!isset($userNames[$teacherId])) {
$this->logger->warning('Assignment {assignmentId} references unknown teacher {teacherId}', [
'assignmentId' => (string) $assignment->id,
'teacherId' => $teacherId,
]);
}
if (!isset($classNames[$classId])) {
$this->logger->warning('Assignment {assignmentId} references unknown class {classId}', [
'assignmentId' => (string) $assignment->id,
'classId' => $classId,
]);
}
if (!isset($subjectNames[$subjectId])) {
$this->logger->warning('Assignment {assignmentId} references unknown subject {subjectId}', [
'assignmentId' => (string) $assignment->id,
'subjectId' => $subjectId,
]);
}
$teacher = $userNames[$teacherId] ?? ['firstName' => '', 'lastName' => ''];
$dtos[] = new AssignmentWithNamesDto(
id: (string) $assignment->id,
teacherId: $teacherId,
teacherFirstName: $teacher['firstName'],
teacherLastName: $teacher['lastName'],
classId: $classId,
className: $classNames[$classId] ?? '',
subjectId: $subjectId,
subjectName: $subjectNames[$subjectId] ?? '',
academicYearId: (string) $assignment->academicYearId,
status: $assignment->status->value,
startDate: $assignment->startDate,
endDate: $assignment->endDate,
createdAt: $assignment->createdAt,
);
}
if ($query->search !== null && $query->search !== '') {
$searchLower = mb_strtolower($query->search);
$dtos = array_values(array_filter(
$dtos,
static fn (AssignmentWithNamesDto $dto) => str_contains(mb_strtolower($dto->teacherFirstName), $searchLower)
|| str_contains(mb_strtolower($dto->teacherLastName), $searchLower)
|| str_contains(mb_strtolower($dto->className), $searchLower)
|| str_contains(mb_strtolower($dto->subjectName), $searchLower),
));
}
$total = count($dtos);
$offset = ($query->page - 1) * $query->limit;
$items = array_slice($dtos, $offset, $query->limit);
return new PaginatedResult(
items: $items,
total: $total,
page: $query->page,
limit: $query->limit,
/* @var PaginatedResult<AssignmentWithNamesDto> */
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<string, mixed>
*/
private function cacheParams(GetAllAssignmentsQuery $query): array
{
return [
'page' => $query->page,
'limit' => $query->limit,
'search' => $query->search,
];
}
}

View File

@@ -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<ClassDto> */
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<string, mixed>
*/
private function cacheParams(GetClassesQuery $query): array
{
return [
'page' => $query->page,
'limit' => $query->limit,
'academic_year_id' => $query->academicYearId,
'search' => $query->search,
];
}
}

View File

@@ -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<ParentInvitationDto> */
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<string, array{firstName: string, lastName: string}>
* @return array<string, mixed>
*/
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,
];
}
}

View File

@@ -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<StudentImageRightsDto>
*/
public function __invoke(GetStudentsImageRightsQuery $query): array
public function __invoke(GetStudentsImageRightsQuery $query): PaginatedResult
{
$students = $this->userRepository->findStudentsByTenant(
TenantId::fromString($query->tenantId),
/* @var PaginatedResult<StudentImageRightsDto> */
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<string, mixed>
*/
private function cacheParams(GetStudentsImageRightsQuery $query): array
{
return [
'page' => $query->page,
'limit' => $query->limit,
'status' => $query->status,
'search' => $query->search,
];
}
}

View File

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

View File

@@ -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<SubjectDto> */
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<string, mixed>
*/
private function cacheParams(GetSubjectsQuery $query): array
{
return [
'page' => $query->page,
'limit' => $query->limit,
'school_id' => $query->schoolId,
'search' => $query->search,
];
}
}

View File

@@ -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<UserDto> */
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<string, mixed>
*/
private function cacheParams(GetUsersQuery $query): array
{
return [
'page' => $query->page,
'limit' => $query->limit,
'role' => $query->role,
'statut' => $query->statut,
'search' => $query->search,
];
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Cache;
use App\Administration\Application\Dto\PaginatedResult;
use function json_encode;
use const JSON_THROW_ON_ERROR;
use function ksort;
use function md5;
use function sprintf;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
/**
* Service cache-aside pour les requêtes paginées.
*
* Chaque entrée est taguée par type d'entité + tenant,
* permettant une invalidation ciblée lors de mutations.
*/
final readonly class PaginatedQueryCache
{
public function __construct(
private TagAwareCacheInterface $paginatedQueriesCache,
) {
}
/**
* @template T
*
* @param array<string, mixed> $params Filtres + page + limit
* @param callable(): PaginatedResult<T> $loader Fonction qui exécute la requête SQL
*
* @return PaginatedResult<T>
*/
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<T> */
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<string, mixed> $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)));
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, string> $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(

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\EventHandler;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use App\Administration\Domain\Event\AffectationRetiree;
use App\Administration\Domain\Event\ClasseArchivee;
use App\Administration\Domain\Event\ClasseCreee;
use App\Administration\Domain\Event\ClasseModifiee;
use App\Administration\Domain\Event\CompteActive;
use App\Administration\Domain\Event\CompteCreated;
use App\Administration\Domain\Event\DroitImageModifie;
use App\Administration\Domain\Event\EleveInscrit;
use App\Administration\Domain\Event\EnseignantAffecte;
use App\Administration\Domain\Event\ImportElevesTermine;
use App\Administration\Domain\Event\ImportEnseignantsTermine;
use App\Administration\Domain\Event\InvitationParentActivee;
use App\Administration\Domain\Event\InvitationParentEnvoyee;
use App\Administration\Domain\Event\InvitationRenvoyee;
use App\Administration\Domain\Event\MatiereCreee;
use App\Administration\Domain\Event\MatiereModifiee;
use App\Administration\Domain\Event\MatiereSupprimee;
use App\Administration\Domain\Event\ParentDelieDEleve;
use App\Administration\Domain\Event\ParentLieAEleve;
use App\Administration\Domain\Event\RoleAttribue;
use App\Administration\Domain\Event\RoleRetire;
use App\Administration\Domain\Event\UtilisateurBloque;
use App\Administration\Domain\Event\UtilisateurDebloque;
use App\Administration\Domain\Event\UtilisateurInvite;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
final readonly class PaginatedQueryCacheInvalidator
{
public function __construct(
private PaginatedQueryCache $cache,
) {
}
// === Users ===
#[AsMessageHandler(bus: 'event.bus')]
public function onUtilisateurInvite(UtilisateurInvite $event): void
{
$this->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);
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Middleware;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use App\Administration\Domain\Event\CompteActive;
use App\Administration\Domain\Event\InvitationParentActivee;
use App\Administration\Domain\Event\InvitationParentEnvoyee;
use App\Administration\Domain\Event\InvitationRenvoyee;
use App\Administration\Domain\Event\UtilisateurInvite;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
use Symfony\Component\Messenger\Middleware\StackInterface;
use Symfony\Component\Messenger\Stamp\ReceivedStamp;
/**
* Invalidates paginated query caches synchronously for events routed to async transport.
*
* Without this middleware, cache invalidation for async-routed events (UtilisateurInvite,
* CompteActive, etc.) would only happen when the async worker processes the event,
* causing stale data in the UI until then.
*
* This middleware runs before SendMessageMiddleware, ensuring the cache is invalidated
* immediately during the originating HTTP request. The PaginatedQueryCacheInvalidator
* handlers still exist as a safety net for worker-side processing.
*/
final readonly class PaginatedCacheInvalidationMiddleware implements MiddlewareInterface
{
public function __construct(
private PaginatedQueryCache $cache,
) {
}
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
if ($envelope->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);
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\ReadModel;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Port\PaginatedAssignmentsReader;
use App\Administration\Application\Query\GetAllAssignments\AssignmentWithNamesDto;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
final readonly class DbalPaginatedAssignmentsReader implements PaginatedAssignmentsReader
{
public function __construct(
private Connection $connection,
) {
}
/**
* @return PaginatedResult<AssignmentWithNamesDto>
*/
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 = <<<SQL
FROM teacher_assignments ta
JOIN users u ON u.id = ta.teacher_id AND u.tenant_id = ta.tenant_id
JOIN school_classes sc ON sc.id = ta.school_class_id AND sc.tenant_id = ta.tenant_id
JOIN subjects s ON s.id = ta.subject_id AND s.tenant_id = ta.tenant_id
SQL;
$countSql = "SELECT COUNT(*) {$fromClause} WHERE {$whereClause}";
/** @var int|string|false $totalRaw */
$totalRaw = $this->connection->fetchOne($countSql, $params);
$total = (int) $totalRaw;
$offset = ($page - 1) * $limit;
$selectSql = <<<SQL
SELECT
ta.id, ta.teacher_id,
u.first_name AS teacher_first_name, u.last_name AS teacher_last_name,
ta.school_class_id AS class_id, sc.name AS class_name,
ta.subject_id, s.name AS subject_name,
ta.academic_year_id, ta.status, ta.start_date, ta.end_date, ta.created_at
{$fromClause}
WHERE {$whereClause}
ORDER BY u.last_name ASC, u.first_name ASC, sc.name ASC
LIMIT :limit OFFSET :offset
SQL;
$params['limit'] = $limit;
$params['offset'] = $offset;
$rows = $this->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,
);
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\ReadModel;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Port\PaginatedClassesReader;
use App\Administration\Application\Query\GetClasses\ClassDto;
use App\Administration\Domain\Model\SchoolClass\ClassStatus;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
final readonly class DbalPaginatedClassesReader implements PaginatedClassesReader
{
public function __construct(
private Connection $connection,
) {
}
/**
* @return PaginatedResult<ClassDto>
*/
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 = <<<SQL
SELECT sc.id, sc.name, sc.level, sc.capacity, sc.status, sc.description, sc.created_at, sc.updated_at
FROM school_classes sc
WHERE {$whereClause}
ORDER BY sc.name ASC
LIMIT :limit OFFSET :offset
SQL;
$params['limit'] = $limit;
$params['offset'] = $offset;
$rows = $this->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,
);
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\ReadModel;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Port\PaginatedParentInvitationsReader;
use App\Administration\Application\Query\GetParentInvitations\ParentInvitationDto;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
final readonly class DbalPaginatedParentInvitationsReader implements PaginatedParentInvitationsReader
{
public function __construct(
private Connection $connection,
) {
}
/**
* @return PaginatedResult<ParentInvitationDto>
*/
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 = <<<SQL
FROM parent_invitations pi
LEFT JOIN users u ON u.id = pi.student_id AND u.tenant_id = pi.tenant_id
SQL;
$countSql = "SELECT COUNT(*) {$fromClause} WHERE {$whereClause}";
/** @var int|string|false $totalRaw */
$totalRaw = $this->connection->fetchOne($countSql, $params);
$total = (int) $totalRaw;
$offset = ($page - 1) * $limit;
$selectSql = <<<SQL
SELECT
pi.id, pi.student_id, pi.parent_email, pi.status,
pi.created_at, pi.expires_at, pi.sent_at, pi.activated_at, pi.activated_user_id,
u.first_name AS student_first_name, u.last_name AS student_last_name
{$fromClause}
WHERE {$whereClause}
ORDER BY pi.created_at DESC
LIMIT :limit OFFSET :offset
SQL;
$params['limit'] = $limit;
$params['offset'] = $offset;
$rows = $this->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,
);
}
}

View File

@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\ReadModel;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Port\PaginatedStudentImageRightsReader;
use App\Administration\Application\Query\GetStudentsImageRights\StudentImageRightsDto;
use App\Administration\Domain\Model\User\ImageRightsStatus;
use App\Administration\Domain\Model\User\Role;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use function json_encode;
use const JSON_THROW_ON_ERROR;
final readonly class DbalPaginatedStudentImageRightsReader implements PaginatedStudentImageRightsReader
{
public function __construct(
private Connection $connection,
) {
}
/**
* @return PaginatedResult<StudentImageRightsDto>
*/
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 = <<<SQL
SELECT
u.id, u.first_name, u.last_name, u.email,
u.image_rights_status, u.image_rights_updated_at,
(
SELECT sc.name
FROM class_assignments ca
JOIN school_classes sc ON sc.id = ca.school_class_id
WHERE ca.user_id = u.id AND ca.tenant_id = u.tenant_id
ORDER BY ca.assigned_at DESC
LIMIT 1
) AS class_name
FROM users u
WHERE {$whereClause}
ORDER BY u.last_name ASC, u.first_name ASC
LIMIT :limit OFFSET :offset
SQL;
$params['limit'] = $limit;
$params['offset'] = $offset;
$rows = $this->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 = <<<SQL
SELECT
u.id, u.first_name, u.last_name, u.email,
u.image_rights_status, u.image_rights_updated_at,
(
SELECT sc.name
FROM class_assignments ca
JOIN school_classes sc ON sc.id = ca.school_class_id
WHERE ca.user_id = u.id AND ca.tenant_id = u.tenant_id
ORDER BY ca.assigned_at DESC
LIMIT 1
) AS class_name
FROM users u
WHERE {$whereClause}
ORDER BY u.last_name ASC, u.first_name ASC
SQL;
$rows = $this->connection->fetchAllAssociative($sql, $params);
return array_map(self::mapRowToDto(...), $rows);
}
/**
* @return array<string, string>
*/
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<string, mixed> $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,
);
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\ReadModel;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Port\PaginatedSubjectsReader;
use App\Administration\Application\Query\GetSubjects\SubjectDto;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
final readonly class DbalPaginatedSubjectsReader implements PaginatedSubjectsReader
{
public function __construct(
private Connection $connection,
) {
}
/**
* @return PaginatedResult<SubjectDto>
*/
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 = <<<SQL
SELECT
s.id, s.name, s.code, s.color, s.description, s.status,
s.created_at, s.updated_at,
(SELECT COUNT(*) FROM teacher_assignments ta WHERE ta.subject_id = s.id AND ta.status = 'active') AS teacher_count,
(SELECT COUNT(DISTINCT ta.school_class_id) FROM teacher_assignments ta WHERE ta.subject_id = s.id AND ta.status = 'active') AS class_count
FROM subjects s
WHERE {$whereClause}
ORDER BY s.name ASC
LIMIT :limit OFFSET :offset
SQL;
$params['limit'] = $limit;
$params['offset'] = $offset;
$rows = $this->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,
);
}
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\ReadModel;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Port\PaginatedUsersReader;
use App\Administration\Application\Query\GetUsers\UserDto;
use App\Administration\Domain\Model\User\Role;
use App\Shared\Domain\Clock;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use function json_decode;
use function json_encode;
use const JSON_THROW_ON_ERROR;
final readonly class DbalPaginatedUsersReader implements PaginatedUsersReader
{
private const int INVITATION_EXPIRY_DAYS = 7;
public function __construct(
private Connection $connection,
private Clock $clock,
) {
}
/**
* @return PaginatedResult<UserDto>
*/
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 = <<<SQL
SELECT
u.id, u.email, u.roles, u.first_name, u.last_name,
u.statut, u.created_at, u.invited_at, u.activated_at,
u.blocked_at, u.blocked_reason
FROM users u
WHERE {$whereClause}
ORDER BY u.last_name ASC, u.first_name ASC
LIMIT :limit OFFSET :offset
SQL;
$params['limit'] = $limit;
$params['offset'] = $offset;
$rows = $this->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<string, mixed> $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,
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,348 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Service\Cache;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use App\Administration\Domain\Event\AffectationRetiree;
use App\Administration\Domain\Event\ClasseCreee;
use App\Administration\Domain\Event\DroitImageModifie;
use App\Administration\Domain\Event\EleveInscrit;
use App\Administration\Domain\Event\EnseignantAffecte;
use App\Administration\Domain\Event\ImportElevesTermine;
use App\Administration\Domain\Event\ImportEnseignantsTermine;
use App\Administration\Domain\Event\InvitationParentActivee;
use App\Administration\Domain\Event\InvitationRenvoyee;
use App\Administration\Domain\Event\MatiereCreee;
use App\Administration\Domain\Event\UtilisateurInvite;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Administration\Domain\Model\Invitation\ParentInvitationId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\SchoolClass\ClassName;
use App\Administration\Domain\Model\Subject\SubjectCode;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\Subject\SubjectName;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use App\Administration\Domain\Model\User\ImageRightsStatus;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\EventHandler\PaginatedQueryCacheInvalidator;
use App\Shared\Domain\Tenant\TenantId;
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 PaginatedQueryCacheInvalidatorTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private PaginatedQueryCache $cache;
private PaginatedQueryCacheInvalidator $invalidator;
protected function setUp(): void
{
$this->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(),
);
}
}

View File

@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Service\Cache;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
final class PaginatedQueryCacheTest extends TestCase
{
private PaginatedQueryCache $cache;
protected function setUp(): void
{
$this->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);
}
}

View File

@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Infrastructure\Middleware;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use App\Administration\Domain\Event\CompteActive;
use App\Administration\Domain\Event\InvitationParentActivee;
use App\Administration\Domain\Event\InvitationParentEnvoyee;
use App\Administration\Domain\Event\InvitationRenvoyee;
use App\Administration\Domain\Event\UtilisateurBloque;
use App\Administration\Domain\Event\UtilisateurInvite;
use App\Administration\Domain\Model\Invitation\ParentInvitationId;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Middleware\PaginatedCacheInvalidationMiddleware;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Ramsey\Uuid\Uuid;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Middleware\StackInterface;
use Symfony\Component\Messenger\Stamp\ReceivedStamp;
final class PaginatedCacheInvalidationMiddlewareTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
private PaginatedQueryCache $cache;
private PaginatedCacheInvalidationMiddleware $middleware;
protected function setUp(): void
{
$this->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;
}
}

View File

@@ -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<StudentImageRights[]>([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
let successMessage = $state<string | null>(null);
let totalItems = $state(0);
let currentPage = $state(Number(page.url.searchParams.get('page')) || 1);
// Filters
let filterStatus = $state<string>(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 @@
<div class="spinner"></div>
<p>Chargement...</p>
</div>
{:else if students.length === 0}
{:else if students.length === 0 && !searchTerm && !filterStatus}
<div class="empty-state">
<div class="empty-icon">📷</div>
<h2>Aucun élève inscrit</h2>
<p>Commencez par créer des comptes élèves pour pouvoir gérer leurs autorisations de droit à l'image.</p>
<a class="btn-primary" href="/admin/users">Gérer les utilisateurs</a>
</div>
{:else if filteredStudents.length === 0}
{:else if students.length === 0}
<div class="empty-state">
<div class="empty-icon">🔍</div>
<h2>Aucun résultat</h2>
@@ -281,19 +293,13 @@
{:else}
<div class="stats-bar">
<span class="stat">
<span class="stat-count">{authorizedStudents.length}</span> autorisé{authorizedStudents.length > 1 ? 's' : ''}
</span>
<span class="stat">
<span class="stat-count stat-danger">{unauthorizedStudents.length}</span> non autorisé{unauthorizedStudents.length > 1 ? 's' : ''}
</span>
<span class="stat">
<span class="stat-count stat-total">{filteredStudents.length}</span> total
<span class="stat-count stat-total">{totalItems}</span> élève{totalItems > 1 ? 's' : ''} au total
</span>
</div>
<div class="section">
<h2>Élèves autorisés ({authorizedStudents.length})</h2>
{#if authorizedStudents.length > 0}
{#if authorizedStudents.length > 0}
<div class="section">
<h2>Élèves autorisés ({authorizedStudents.length})</h2>
<div class="table-wrapper">
<table>
<thead>
@@ -332,14 +338,12 @@
</tbody>
</table>
</div>
{:else}
<p class="empty-section">Aucun élève autorisé.</p>
{/if}
</div>
</div>
{/if}
<div class="section">
<h2>Élèves non autorisés ({unauthorizedStudents.length})</h2>
{#if unauthorizedStudents.length > 0}
{#if unauthorizedStudents.length > 0}
<div class="section">
<h2>Élèves non autorisés ({unauthorizedStudents.length})</h2>
<div class="table-wrapper">
<table>
<thead>
@@ -378,10 +382,10 @@
</tbody>
</table>
</div>
{:else}
<p class="empty-section">Tous les élèves sont autorisés.</p>
{/if}
</div>
</div>
{/if}
<Pagination {currentPage} {totalPages} onPageChange={handlePageChange} />
{/if}
</div>
@@ -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;