Files
Classeo/backend/tests/Unit/Administration/Application/Query/GetUsers/GetUsersHandlerTest.php
Mathias STRASSER 76e16db0d8 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.
2026-02-15 13:54:51 +01:00

341 lines
9.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Query\GetUsers;
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 DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
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 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,
);
$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);
}
}
#[Test]
public function filtersUsersByStatut(): void
{
$this->seedUsers();
$query = new GetUsersQuery(
tenantId: self::TENANT_ID,
statut: 'pending',
);
$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);
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
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$teacher1 = User::inviter(
email: new Email('teacher1@example.com'),
role: Role::PROF,
tenantId: $tenantId,
schoolName: 'École Alpha',
firstName: 'Jean',
lastName: 'Dupont',
invitedAt: new DateTimeImmutable('2026-02-01 10:00:00'),
);
$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);
}
}