feat(admin) : filtres + pagination serveur sur /admin/users/sites/roles

Ajoute le filtrage par colonne et la pagination negociee via query params
sur les 3 DataTables admin existantes. Tout est cote serveur (API Platform
SearchFilter + BooleanFilter) pour scaler naturellement.

Backend :
- api_platform.yaml : scan du mapping Sites + pagination_client_items_per_page
  (avec borne max 100 pour proteger contre les payloads exagerement grands).
- User : SearchFilter username (partial), rbacRoles.code (exact),
  sites.name (exact) + BooleanFilter isAdmin.
- Site : SearchFilter name/city/postalCode (partial).
- Role : SearchFilter label/code (partial), permissions.code (exact).
  (BooleanFilter isSystem deja present.)

Frontend :
- Composable useDataTableServerState (shared) : singleton de page/perPage/
  filters avec debounce 300ms sur les filters, fetch immediat sur page/
  perPage, reset page=1 au changement filter, token anti-race-condition.
- Pages admin : chaque filtre dans un slot #header-{key} (input text avec
  debounce, select mono-selection pour les relations). Font-size 20px sur
  les inputs de filtre.
- /admin/users : colonne Sites + filtre Sites conditionnes par
  useModules().isModuleActive('sites') — preserve l'invariant "module
  desactivable sans casse".

Tests : 215/215 PHPUnit (14 nouveaux filtres/pagination) + 48/48 Vitest
(8 nouveaux useDataTableServerState).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 17:00:34 +02:00
parent 296befe187
commit cb6d2d72ec
10 changed files with 875 additions and 88 deletions

View File

@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Core\Api;
/**
* Tests fonctionnels des ApiFilter ajoutes sur User, Role et Site pour
* les DataTables admin (filtrage serveur + pagination negociee).
*
* Ces tests s'appuient uniquement sur les fixtures (admin, alice, bob +
* 3 sites + 2 roles systeme + 6 permissions) — aucune mutation entre
* tests, pas de cleanup necessaire.
*
* @internal
*/
final class AdminFiltersApiTest extends AbstractApiTestCase
{
// ========================================================================
// User filters
// ========================================================================
public function testUsersFilterByUsernamePartial(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/users?username=ali');
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertSame(1, $data['totalItems']);
self::assertSame('alice', $data['member'][0]['username']);
}
public function testUsersFilterByIsAdminTrueReturnsOnlyAdmins(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/users?isAdmin=true');
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertGreaterThanOrEqual(1, $data['totalItems']);
foreach ($data['member'] as $user) {
self::assertTrue($user['isAdmin']);
}
}
public function testUsersFilterByIsAdminFalseExcludesAdmins(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/users?isAdmin=false');
$data = $response->toArray();
foreach ($data['member'] as $user) {
self::assertFalse($user['isAdmin']);
}
}
public function testUsersFilterBySiteNameReturnsUsersOfThatSite(): void
{
// alice est rattachee a Chatellerault uniquement, bob a Saint-Jean.
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/users?sites.name=Saint-Jean');
$data = $response->toArray();
$usernames = array_column($data['member'], 'username');
self::assertContains('admin', $usernames);
self::assertContains('bob', $usernames);
self::assertNotContains('alice', $usernames);
}
public function testUsersFilterByRoleCodeReturnsUsersWithThatRole(): void
{
// admin porte le role systeme 'admin', alice/bob portent 'user'.
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/users?rbacRoles.code=admin');
$data = $response->toArray();
$usernames = array_column($data['member'], 'username');
self::assertContains('admin', $usernames);
self::assertNotContains('alice', $usernames);
}
// ========================================================================
// Site filters
// ========================================================================
public function testSitesFilterByNamePartial(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/sites?name=Chat');
$data = $response->toArray();
self::assertSame(1, $data['totalItems']);
self::assertSame('Chatellerault', $data['member'][0]['name']);
}
public function testSitesFilterByCityPartial(): void
{
$client = $this->authenticatedClient('admin', 'admin');
// Fontenet est la ville du site Saint-Jean.
$response = $client->request('GET', '/api/sites?city=Fonten');
$data = $response->toArray();
self::assertSame(1, $data['totalItems']);
self::assertSame('Saint-Jean', $data['member'][0]['name']);
}
public function testSitesFilterByPostalCodePartial(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/sites?postalCode=82');
$data = $response->toArray();
self::assertSame(1, $data['totalItems']);
self::assertSame('Pommevic', $data['member'][0]['name']);
}
// ========================================================================
// Role filters
// ========================================================================
public function testRolesFilterByLabelPartial(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/roles?label=Admin');
$data = $response->toArray();
self::assertGreaterThanOrEqual(1, $data['totalItems']);
foreach ($data['member'] as $role) {
self::assertStringContainsStringIgnoringCase('admin', $role['label']);
}
}
public function testRolesFilterByCodePartial(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/roles?code=user');
$data = $response->toArray();
self::assertGreaterThanOrEqual(1, $data['totalItems']);
foreach ($data['member'] as $role) {
self::assertStringContainsString('user', $role['code']);
}
}
public function testRolesFilterByIsSystemTrueReturnsOnlySystemRoles(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/roles?isSystem=true');
$data = $response->toArray();
self::assertGreaterThanOrEqual(2, $data['totalItems']);
foreach ($data['member'] as $role) {
self::assertTrue($role['isSystem']);
}
}
public function testRolesFilterByPermissionCodeReturnsRolesWithThatPermission(): void
{
// Le role systeme 'admin' a le flag isAdmin qui bypass toutes les
// permissions — il n'a pas necessairement des permissions explicites.
// On teste donc avec la permission sites.view qui devrait exister
// mais potentiellement n'etre sur aucun role custom. Le filtre
// fonctionne techniquement meme sur un resultat vide.
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/roles?permissions.code=sites.view');
self::assertResponseIsSuccessful();
$data = $response->toArray();
// On valide juste que la requete est acceptee (200) et que le
// filtre transforme bien l'IRI en JOIN — nombre de resultats
// depend de l'etat des fixtures.
self::assertArrayHasKey('totalItems', $data);
}
// ========================================================================
// Pagination
// ========================================================================
public function testPaginationWithItemsPerPageReducesMember(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/sites?itemsPerPage=2');
$data = $response->toArray();
self::assertLessThanOrEqual(2, count($data['member']));
// totalItems reflete le TOTAL pas la page courante.
self::assertGreaterThanOrEqual(3, $data['totalItems']);
}
public function testPaginationPage2SkipsFirstItems(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$page1 = $client->request('GET', '/api/sites?itemsPerPage=1&page=1')->toArray();
$page2 = $client->request('GET', '/api/sites?itemsPerPage=1&page=2')->toArray();
self::assertCount(1, $page1['member']);
self::assertCount(1, $page2['member']);
self::assertNotSame(
$page1['member'][0]['id'],
$page2['member'][0]['id'],
'Les items de la page 2 doivent differer de ceux de la page 1.',
);
}
}