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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user