feat: Pagination et recherche des sections admin
Les listes admin (utilisateurs, classes, matières, affectations) chargeaient toutes les données d'un coup, ce qui dégradait l'expérience avec un volume croissant. La pagination côté serveur existait dans la config API Platform mais aucun Provider ne l'exploitait. Cette implémentation ajoute la pagination serveur (30 items/page, max 100) avec recherche textuelle sur toutes les sections, des composants frontend réutilisables (Pagination + SearchInput avec debounce), et la synchronisation URL pour le partage de liens filtrés. Les Query valident leurs paramètres (clamp page/limit, trim search) pour éviter les abus. Les affectations utilisent des lookup maps pour résoudre les noms sans N+1 queries. Les pages admin gèrent les race conditions via AbortController.
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Query\GetAllAssignments;
|
||||
|
||||
use App\Administration\Application\Query\GetAllAssignments\GetAllAssignmentsHandler;
|
||||
use App\Administration\Application\Query\GetAllAssignments\GetAllAssignmentsQuery;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
|
||||
use App\Administration\Domain\Model\Subject\Subject;
|
||||
use App\Administration\Domain\Model\Subject\SubjectCode;
|
||||
use App\Administration\Domain\Model\Subject\SubjectName;
|
||||
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySubjectRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryTeacherAssignmentRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function count;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
final class GetAllAssignmentsHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
private const string OTHER_TENANT_ID = '550e8400-e29b-41d4-a716-446655440099';
|
||||
|
||||
private InMemoryTeacherAssignmentRepository $assignmentRepository;
|
||||
private InMemoryUserRepository $userRepository;
|
||||
private InMemoryClassRepository $classRepository;
|
||||
private InMemorySubjectRepository $subjectRepository;
|
||||
private GetAllAssignmentsHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->assignmentRepository = new InMemoryTeacherAssignmentRepository();
|
||||
$this->userRepository = new InMemoryUserRepository();
|
||||
$this->classRepository = new InMemoryClassRepository();
|
||||
$this->subjectRepository = new InMemorySubjectRepository();
|
||||
$this->handler = new GetAllAssignmentsHandler(
|
||||
$this->assignmentRepository,
|
||||
$this->userRepository,
|
||||
$this->classRepository,
|
||||
$this->subjectRepository,
|
||||
new NullLogger(),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsAllActiveAssignmentsWithNames(): void
|
||||
{
|
||||
$this->seedData();
|
||||
|
||||
$query = new GetAllAssignmentsQuery(tenantId: self::TENANT_ID);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(2, $result->items);
|
||||
self::assertSame(2, $result->total);
|
||||
self::assertSame(1, $result->page);
|
||||
self::assertSame(30, $result->limit);
|
||||
|
||||
// Verify denormalized names are populated
|
||||
$dto = $result->items[0];
|
||||
self::assertNotSame('', $dto->teacherFirstName);
|
||||
self::assertNotSame('', $dto->className);
|
||||
self::assertNotSame('', $dto->subjectName);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function searchesByTeacherName(): void
|
||||
{
|
||||
$this->seedData();
|
||||
|
||||
$query = new GetAllAssignmentsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
search: 'Jean',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame('Jean', $result->items[0]->teacherFirstName);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function searchesByClassName(): void
|
||||
{
|
||||
$this->seedData();
|
||||
|
||||
$query = new GetAllAssignmentsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
search: '6ème',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertGreaterThanOrEqual(1, count($result->items));
|
||||
self::assertStringContainsString('6ème', $result->items[0]->className);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function searchesBySubjectName(): void
|
||||
{
|
||||
$this->seedData();
|
||||
|
||||
$query = new GetAllAssignmentsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
search: 'Français',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame('Français', $result->items[0]->subjectName);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function searchIsCaseInsensitive(): void
|
||||
{
|
||||
$this->seedData();
|
||||
|
||||
$query = new GetAllAssignmentsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
search: 'jean',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame('Jean', $result->items[0]->teacherFirstName);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function paginatesResults(): void
|
||||
{
|
||||
$this->seedData();
|
||||
|
||||
$query = new GetAllAssignmentsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
page: 1,
|
||||
limit: 1,
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame(2, $result->total);
|
||||
self::assertSame(1, $result->page);
|
||||
self::assertSame(1, $result->limit);
|
||||
self::assertSame(2, $result->totalPages());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsSecondPage(): void
|
||||
{
|
||||
$this->seedData();
|
||||
|
||||
$query = new GetAllAssignmentsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
page: 2,
|
||||
limit: 1,
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame(2, $result->total);
|
||||
self::assertSame(2, $result->page);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsEmptyWhenNoMatches(): void
|
||||
{
|
||||
$this->seedData();
|
||||
|
||||
$query = new GetAllAssignmentsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
search: 'nonexistent',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(0, $result->items);
|
||||
self::assertSame(0, $result->total);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function excludesAssignmentsFromOtherTenants(): void
|
||||
{
|
||||
$this->seedData();
|
||||
|
||||
$query = new GetAllAssignmentsQuery(tenantId: self::OTHER_TENANT_ID);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(0, $result->items);
|
||||
self::assertSame(0, $result->total);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handlesOrphanedAssignment(): void
|
||||
{
|
||||
// Create an assignment with no matching teacher/class/subject
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$now = new DateTimeImmutable('2026-02-01 10:00:00');
|
||||
|
||||
$orphanedClass = SchoolClass::creer(
|
||||
tenantId: $tenantId,
|
||||
schoolId: SchoolId::generate(),
|
||||
academicYearId: AcademicYearId::generate(),
|
||||
name: new ClassName('Orphan Class'),
|
||||
level: null,
|
||||
capacity: null,
|
||||
createdAt: $now,
|
||||
);
|
||||
$this->classRepository->save($orphanedClass);
|
||||
|
||||
$orphanedSubject = Subject::creer(
|
||||
tenantId: $tenantId,
|
||||
schoolId: SchoolId::generate(),
|
||||
name: new SubjectName('Orphan Subject'),
|
||||
code: new SubjectCode('ORPH'),
|
||||
color: null,
|
||||
createdAt: $now,
|
||||
);
|
||||
$this->subjectRepository->save($orphanedSubject);
|
||||
|
||||
// Teacher does NOT exist in userRepository
|
||||
$orphanedTeacher = User::inviter(
|
||||
email: new Email('orphan@example.com'),
|
||||
role: Role::PROF,
|
||||
tenantId: TenantId::fromString(self::OTHER_TENANT_ID), // Different tenant — won't be found
|
||||
schoolName: 'Autre',
|
||||
firstName: 'Ghost',
|
||||
lastName: 'Teacher',
|
||||
invitedAt: $now,
|
||||
);
|
||||
$this->userRepository->save($orphanedTeacher);
|
||||
|
||||
$assignment = TeacherAssignment::creer(
|
||||
tenantId: $tenantId,
|
||||
teacherId: $orphanedTeacher->id,
|
||||
classId: $orphanedClass->id,
|
||||
subjectId: $orphanedSubject->id,
|
||||
academicYearId: AcademicYearId::generate(),
|
||||
createdAt: $now,
|
||||
);
|
||||
$this->assignmentRepository->save($assignment);
|
||||
|
||||
$query = new GetAllAssignmentsQuery(tenantId: self::TENANT_ID);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
// Assignment should appear with empty teacher names (orphaned reference logged as warning)
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame('', $result->items[0]->teacherFirstName);
|
||||
self::assertSame('', $result->items[0]->teacherLastName);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clampsInvalidPageToOne(): void
|
||||
{
|
||||
$query = new GetAllAssignmentsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
page: -1,
|
||||
);
|
||||
|
||||
self::assertSame(1, $query->page);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clampsExcessiveLimitToMax(): void
|
||||
{
|
||||
$query = new GetAllAssignmentsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
limit: 999,
|
||||
);
|
||||
|
||||
self::assertSame(100, $query->limit);
|
||||
}
|
||||
|
||||
private function seedData(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$schoolId = SchoolId::generate();
|
||||
$academicYearId = AcademicYearId::generate();
|
||||
$now = new DateTimeImmutable('2026-02-01 10:00:00');
|
||||
|
||||
// Create teachers
|
||||
$teacher1 = User::inviter(
|
||||
email: new Email('teacher1@example.com'),
|
||||
role: Role::PROF,
|
||||
tenantId: $tenantId,
|
||||
schoolName: 'École Alpha',
|
||||
firstName: 'Jean',
|
||||
lastName: 'Dupont',
|
||||
invitedAt: $now,
|
||||
);
|
||||
$this->userRepository->save($teacher1);
|
||||
|
||||
$teacher2 = User::inviter(
|
||||
email: new Email('teacher2@example.com'),
|
||||
role: Role::PROF,
|
||||
tenantId: $tenantId,
|
||||
schoolName: 'École Alpha',
|
||||
firstName: 'Marie',
|
||||
lastName: 'Martin',
|
||||
invitedAt: $now,
|
||||
);
|
||||
$this->userRepository->save($teacher2);
|
||||
|
||||
// Create classes
|
||||
$class1 = SchoolClass::creer(
|
||||
tenantId: $tenantId,
|
||||
schoolId: $schoolId,
|
||||
academicYearId: $academicYearId,
|
||||
name: new ClassName('6ème A'),
|
||||
level: SchoolLevel::SIXIEME,
|
||||
capacity: 30,
|
||||
createdAt: $now,
|
||||
);
|
||||
$this->classRepository->save($class1);
|
||||
|
||||
// Create subjects
|
||||
$subject1 = Subject::creer(
|
||||
tenantId: $tenantId,
|
||||
schoolId: $schoolId,
|
||||
name: new SubjectName('Mathématiques'),
|
||||
code: new SubjectCode('MATH'),
|
||||
color: null,
|
||||
createdAt: $now,
|
||||
);
|
||||
$this->subjectRepository->save($subject1);
|
||||
|
||||
$subject2 = Subject::creer(
|
||||
tenantId: $tenantId,
|
||||
schoolId: $schoolId,
|
||||
name: new SubjectName('Français'),
|
||||
code: new SubjectCode('FR'),
|
||||
color: null,
|
||||
createdAt: $now,
|
||||
);
|
||||
$this->subjectRepository->save($subject2);
|
||||
|
||||
// Create assignments
|
||||
$assignment1 = TeacherAssignment::creer(
|
||||
tenantId: $tenantId,
|
||||
teacherId: $teacher1->id,
|
||||
classId: $class1->id,
|
||||
subjectId: $subject1->id,
|
||||
academicYearId: $academicYearId,
|
||||
createdAt: $now,
|
||||
);
|
||||
$this->assignmentRepository->save($assignment1);
|
||||
|
||||
$assignment2 = TeacherAssignment::creer(
|
||||
tenantId: $tenantId,
|
||||
teacherId: $teacher2->id,
|
||||
classId: $class1->id,
|
||||
subjectId: $subject2->id,
|
||||
academicYearId: $academicYearId,
|
||||
createdAt: $now,
|
||||
);
|
||||
$this->assignmentRepository->save($assignment2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Query\GetClasses;
|
||||
|
||||
use App\Administration\Application\Query\GetClasses\GetClassesHandler;
|
||||
use App\Administration\Application\Query\GetClasses\GetClassesQuery;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetClassesHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
|
||||
private InMemoryClassRepository $classRepository;
|
||||
private GetClassesHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->classRepository = new InMemoryClassRepository();
|
||||
$this->handler = new GetClassesHandler($this->classRepository);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsAllActiveClassesForTenantAndYear(): void
|
||||
{
|
||||
$this->seedClasses();
|
||||
|
||||
$query = new GetClassesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(3, $result->items);
|
||||
self::assertSame(3, $result->total);
|
||||
self::assertSame(1, $result->page);
|
||||
self::assertSame(30, $result->limit);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function filtersClassesByName(): void
|
||||
{
|
||||
$this->seedClasses();
|
||||
|
||||
$query = new GetClassesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
search: '6ème',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame('6ème A', $result->items[0]->name);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function filtersClassesByLevel(): void
|
||||
{
|
||||
$this->seedClasses();
|
||||
|
||||
$query = new GetClassesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
search: SchoolLevel::CM2->value,
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame('CM2 B', $result->items[0]->name);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function searchIsCaseInsensitive(): void
|
||||
{
|
||||
$this->seedClasses();
|
||||
|
||||
$query = new GetClassesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
search: 'cm2',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function paginatesResults(): void
|
||||
{
|
||||
$this->seedClasses();
|
||||
|
||||
$query = new GetClassesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
page: 1,
|
||||
limit: 2,
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(2, $result->items);
|
||||
self::assertSame(3, $result->total);
|
||||
self::assertSame(1, $result->page);
|
||||
self::assertSame(2, $result->limit);
|
||||
self::assertSame(2, $result->totalPages());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsSecondPage(): void
|
||||
{
|
||||
$this->seedClasses();
|
||||
|
||||
$query = new GetClassesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
page: 2,
|
||||
limit: 2,
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame(3, $result->total);
|
||||
self::assertSame(2, $result->page);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsEmptyWhenNoMatches(): void
|
||||
{
|
||||
$this->seedClasses();
|
||||
|
||||
$query = new GetClassesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
search: 'nonexistent',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(0, $result->items);
|
||||
self::assertSame(0, $result->total);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clampsInvalidPageToOne(): void
|
||||
{
|
||||
$this->seedClasses();
|
||||
|
||||
$query = new GetClassesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
page: -1,
|
||||
);
|
||||
|
||||
self::assertSame(1, $query->page);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clampsExcessiveLimitToMax(): void
|
||||
{
|
||||
$query = new GetClassesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
limit: 999,
|
||||
);
|
||||
|
||||
self::assertSame(100, $query->limit);
|
||||
}
|
||||
|
||||
private function seedClasses(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$schoolId = SchoolId::generate();
|
||||
$academicYearId = AcademicYearId::fromString(self::ACADEMIC_YEAR_ID);
|
||||
$now = new DateTimeImmutable('2026-02-01 10:00:00');
|
||||
|
||||
$this->classRepository->save(SchoolClass::creer(
|
||||
tenantId: $tenantId,
|
||||
schoolId: $schoolId,
|
||||
academicYearId: $academicYearId,
|
||||
name: new ClassName('6ème A'),
|
||||
level: SchoolLevel::SIXIEME,
|
||||
capacity: 30,
|
||||
createdAt: $now,
|
||||
));
|
||||
|
||||
$this->classRepository->save(SchoolClass::creer(
|
||||
tenantId: $tenantId,
|
||||
schoolId: $schoolId,
|
||||
academicYearId: $academicYearId,
|
||||
name: new ClassName('CM2 B'),
|
||||
level: SchoolLevel::CM2,
|
||||
capacity: 25,
|
||||
createdAt: $now,
|
||||
));
|
||||
|
||||
$this->classRepository->save(SchoolClass::creer(
|
||||
tenantId: $tenantId,
|
||||
schoolId: $schoolId,
|
||||
academicYearId: $academicYearId,
|
||||
name: new ClassName('CP Alpha'),
|
||||
level: SchoolLevel::CP,
|
||||
capacity: 20,
|
||||
createdAt: $now,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Query\GetSubjects;
|
||||
|
||||
use App\Administration\Application\Query\GetSubjects\GetSubjectsHandler;
|
||||
use App\Administration\Application\Query\GetSubjects\GetSubjectsQuery;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Model\Subject\Subject;
|
||||
use App\Administration\Domain\Model\Subject\SubjectCode;
|
||||
use App\Administration\Domain\Model\Subject\SubjectColor;
|
||||
use App\Administration\Domain\Model\Subject\SubjectName;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySubjectRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetSubjectsHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||
|
||||
private InMemorySubjectRepository $subjectRepository;
|
||||
private GetSubjectsHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->subjectRepository = new InMemorySubjectRepository();
|
||||
$this->handler = new GetSubjectsHandler($this->subjectRepository);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsAllActiveSubjectsForTenantAndSchool(): void
|
||||
{
|
||||
$this->seedSubjects();
|
||||
|
||||
$query = new GetSubjectsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(3, $result->items);
|
||||
self::assertSame(3, $result->total);
|
||||
self::assertSame(1, $result->page);
|
||||
self::assertSame(30, $result->limit);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function filtersSubjectsByName(): void
|
||||
{
|
||||
$this->seedSubjects();
|
||||
|
||||
$query = new GetSubjectsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
search: 'Mathématiques',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame('Mathématiques', $result->items[0]->name);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function filtersSubjectsByCode(): void
|
||||
{
|
||||
$this->seedSubjects();
|
||||
|
||||
$query = new GetSubjectsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
search: 'FR',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame('FR', $result->items[0]->code);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function searchIsCaseInsensitive(): void
|
||||
{
|
||||
$this->seedSubjects();
|
||||
|
||||
$query = new GetSubjectsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
search: 'math',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function paginatesResults(): void
|
||||
{
|
||||
$this->seedSubjects();
|
||||
|
||||
$query = new GetSubjectsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
page: 1,
|
||||
limit: 2,
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(2, $result->items);
|
||||
self::assertSame(3, $result->total);
|
||||
self::assertSame(1, $result->page);
|
||||
self::assertSame(2, $result->limit);
|
||||
self::assertSame(2, $result->totalPages());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsSecondPage(): void
|
||||
{
|
||||
$this->seedSubjects();
|
||||
|
||||
$query = new GetSubjectsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
page: 2,
|
||||
limit: 2,
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame(3, $result->total);
|
||||
self::assertSame(2, $result->page);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsEmptyWhenNoMatches(): void
|
||||
{
|
||||
$this->seedSubjects();
|
||||
|
||||
$query = new GetSubjectsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
search: 'nonexistent',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(0, $result->items);
|
||||
self::assertSame(0, $result->total);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clampsInvalidPageToOne(): void
|
||||
{
|
||||
$query = new GetSubjectsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
page: -1,
|
||||
);
|
||||
|
||||
self::assertSame(1, $query->page);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clampsExcessiveLimitToMax(): void
|
||||
{
|
||||
$query = new GetSubjectsQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
limit: 999,
|
||||
);
|
||||
|
||||
self::assertSame(100, $query->limit);
|
||||
}
|
||||
|
||||
private function seedSubjects(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$schoolId = SchoolId::fromString(self::SCHOOL_ID);
|
||||
$now = new DateTimeImmutable('2026-02-01 10:00:00');
|
||||
|
||||
$this->subjectRepository->save(Subject::creer(
|
||||
tenantId: $tenantId,
|
||||
schoolId: $schoolId,
|
||||
name: new SubjectName('Mathématiques'),
|
||||
code: new SubjectCode('MATH'),
|
||||
color: new SubjectColor('#3B82F6'),
|
||||
createdAt: $now,
|
||||
));
|
||||
|
||||
$this->subjectRepository->save(Subject::creer(
|
||||
tenantId: $tenantId,
|
||||
schoolId: $schoolId,
|
||||
name: new SubjectName('Français'),
|
||||
code: new SubjectCode('FR'),
|
||||
color: new SubjectColor('#EF4444'),
|
||||
createdAt: $now,
|
||||
));
|
||||
|
||||
$this->subjectRepository->save(Subject::creer(
|
||||
tenantId: $tenantId,
|
||||
schoolId: $schoolId,
|
||||
name: new SubjectName('Histoire-Géo'),
|
||||
code: new SubjectCode('HG'),
|
||||
color: new SubjectColor('#10B981'),
|
||||
createdAt: $now,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,10 @@ final class GetUsersHandlerTest extends TestCase
|
||||
$query = new GetUsersQuery(tenantId: self::TENANT_ID);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(3, $result);
|
||||
self::assertCount(3, $result->items);
|
||||
self::assertSame(3, $result->total);
|
||||
self::assertSame(1, $result->page);
|
||||
self::assertSame(30, $result->limit);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -60,8 +63,9 @@ final class GetUsersHandlerTest extends TestCase
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(2, $result);
|
||||
foreach ($result as $dto) {
|
||||
self::assertCount(2, $result->items);
|
||||
self::assertSame(2, $result->total);
|
||||
foreach ($result->items as $dto) {
|
||||
self::assertSame(Role::PROF->value, $dto->role);
|
||||
}
|
||||
}
|
||||
@@ -77,8 +81,9 @@ final class GetUsersHandlerTest extends TestCase
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(2, $result);
|
||||
foreach ($result as $dto) {
|
||||
self::assertCount(2, $result->items);
|
||||
self::assertSame(2, $result->total);
|
||||
foreach ($result->items as $dto) {
|
||||
self::assertSame('pending', $dto->statut);
|
||||
}
|
||||
}
|
||||
@@ -88,7 +93,6 @@ final class GetUsersHandlerTest extends TestCase
|
||||
{
|
||||
$this->seedUsers();
|
||||
|
||||
// Add user to different tenant
|
||||
$otherUser = User::inviter(
|
||||
email: new Email('other@example.com'),
|
||||
role: Role::ADMIN,
|
||||
@@ -103,13 +107,13 @@ final class GetUsersHandlerTest extends TestCase
|
||||
$query = new GetUsersQuery(tenantId: self::TENANT_ID);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(3, $result);
|
||||
self::assertCount(3, $result->items);
|
||||
self::assertSame(3, $result->total);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function calculatesInvitationExpiree(): void
|
||||
{
|
||||
// Invited 10 days ago — should be expired
|
||||
$user = User::inviter(
|
||||
email: new Email('old@example.com'),
|
||||
role: Role::PROF,
|
||||
@@ -124,8 +128,171 @@ final class GetUsersHandlerTest extends TestCase
|
||||
$query = new GetUsersQuery(tenantId: self::TENANT_ID);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertTrue($result[0]->invitationExpiree);
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertTrue($result->items[0]->invitationExpiree);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function paginatesResults(): void
|
||||
{
|
||||
$this->seedUsers();
|
||||
|
||||
$query = new GetUsersQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
page: 1,
|
||||
limit: 2,
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(2, $result->items);
|
||||
self::assertSame(3, $result->total);
|
||||
self::assertSame(1, $result->page);
|
||||
self::assertSame(2, $result->limit);
|
||||
self::assertSame(2, $result->totalPages());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsSecondPage(): void
|
||||
{
|
||||
$this->seedUsers();
|
||||
|
||||
$query = new GetUsersQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
page: 2,
|
||||
limit: 2,
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame(3, $result->total);
|
||||
self::assertSame(2, $result->page);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function searchesByFirstName(): void
|
||||
{
|
||||
$this->seedUsers();
|
||||
|
||||
$query = new GetUsersQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
search: 'Jean',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame('Jean', $result->items[0]->firstName);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function searchesByLastName(): void
|
||||
{
|
||||
$this->seedUsers();
|
||||
|
||||
$query = new GetUsersQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
search: 'Martin',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame('Martin', $result->items[0]->lastName);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function searchesByEmail(): void
|
||||
{
|
||||
$this->seedUsers();
|
||||
|
||||
$query = new GetUsersQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
search: 'parent@',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame('parent@example.com', $result->items[0]->email);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function searchIsCaseInsensitive(): void
|
||||
{
|
||||
$this->seedUsers();
|
||||
|
||||
$query = new GetUsersQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
search: 'jean',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame('Jean', $result->items[0]->firstName);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function searchCombinesWithRoleFilter(): void
|
||||
{
|
||||
$this->seedUsers();
|
||||
|
||||
$query = new GetUsersQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
role: Role::PROF->value,
|
||||
search: 'Jean',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(1, $result->items);
|
||||
self::assertSame('Jean', $result->items[0]->firstName);
|
||||
self::assertSame(Role::PROF->value, $result->items[0]->role);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function searchResetsCountCorrectly(): void
|
||||
{
|
||||
$this->seedUsers();
|
||||
|
||||
$query = new GetUsersQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
search: 'nonexistent',
|
||||
);
|
||||
$result = ($this->handler)($query);
|
||||
|
||||
self::assertCount(0, $result->items);
|
||||
self::assertSame(0, $result->total);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clampsPageZeroToOne(): void
|
||||
{
|
||||
$query = new GetUsersQuery(tenantId: self::TENANT_ID, page: 0);
|
||||
self::assertSame(1, $query->page);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clampsNegativePageToOne(): void
|
||||
{
|
||||
$query = new GetUsersQuery(tenantId: self::TENANT_ID, page: -5);
|
||||
self::assertSame(1, $query->page);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clampsLimitZeroToOne(): void
|
||||
{
|
||||
$query = new GetUsersQuery(tenantId: self::TENANT_ID, limit: 0);
|
||||
self::assertSame(1, $query->limit);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clampsExcessiveLimitToHundred(): void
|
||||
{
|
||||
$query = new GetUsersQuery(tenantId: self::TENANT_ID, limit: 999);
|
||||
self::assertSame(100, $query->limit);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function clampsNegativeLimitToOne(): void
|
||||
{
|
||||
$query = new GetUsersQuery(tenantId: self::TENANT_ID, limit: -10);
|
||||
self::assertSame(1, $query->limit);
|
||||
}
|
||||
|
||||
private function seedUsers(): void
|
||||
@@ -152,7 +319,6 @@ final class GetUsersHandlerTest extends TestCase
|
||||
lastName: 'Martin',
|
||||
invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'),
|
||||
);
|
||||
// Activate teacher2
|
||||
$teacher2->activer(
|
||||
'$argon2id$hashed',
|
||||
new DateTimeImmutable('2026-02-02 10:00:00'),
|
||||
|
||||
Reference in New Issue
Block a user