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:
205
tests/Module/Core/Api/AdminFiltersApiTest.php
Normal file
205
tests/Module/Core/Api/AdminFiltersApiTest.php
Normal 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user