[ERP-72] Paginer toutes les collections API + regle pagination obligatoire (#28)
Auto Tag Develop / tag (push) Successful in 7s

## Contexte

Ticket Lesstime : [#72](https://lesstime.malio.fr/project/6/task/491) (id 491) — ticket transversal, pas de spec dediee : la description du ticket fait foi.

## Implementation

- **Defaut global de pagination** dans `config/packages/api_platform.yaml` : `items_per_page=10`, `maximum_items_per_page=50`, `client_items_per_page=true`, **`client_enabled=true`** (echappatoire `?pagination=false` pour alimenter les `<select>` cote front).
- **`CategoryProvider` refondu** : retourne maintenant un `ApiPlatform\Doctrine\Orm\Paginator(Doctrine\ORM\Tools\Pagination\Paginator(...))` au lieu d'un array brut. Supporte `?pagination=false`.
- **`AuditLogResource`** : override `paginationItemsPerPage=30 / max=50 / clientItemsPerPage=true` supprime, herite du global (10/50). `AuditLogProvider` (`DbalPaginator`) inchange.
- **Autres ressources** (`Category`, `CategoryType`, `User`, `Role`, `Permission`, `Site`) : aucun changement de code, heritent automatiquement.
- **Regle « pagination obligatoire »** documentee : `CLAUDE.md` (regle ABSOLUE n°13 + section « A NE PAS faire ») + `.claude/rules/backend.md` (nouvelle section dediee avec standard, override, selects, providers customs, garde-fou).
- **Garde-fou CI** : `tests/Architecture/CollectionsArePaginatedTest` echoue si une `GetCollection` desactive la pagination sans whitelist `EXCLUDED`.

## Adaptation collaterale (non prevue au plan initial)

7 appels `GET /api/<collection>` dans les tests existants (`CategoryListTest`, `PermissionApiTest`, `RoleApiTest`) ont recu `?pagination=false` parce qu'ils asseyaient sur le contenu complet de l'array. Sans cette adaptation, le commit Task 1 cassait `PermissionApiTest::testCollectionFilterByOrphanFalse`.

## Criteres d'acceptation

- [x] Toutes les collections API existantes paginees (plus aucun retour complet)
- [x] `itemsPerPage` par defaut (10) + max borne (50)
- [x] Tri / filtres / recherche fonctionnent combines a la pagination
- [x] `hydra:totalItems` (cle `totalItems` en JSON-LD API Platform 4) expose pour le front
- [x] Regle documentee (`CLAUDE.md` + `.claude/rules/backend.md`)

## Tests

- `docker exec -t php-starseed-fpm php -d memory_limit=512M vendor/bin/phpunit` → **Tests: 320, 0 failures** (etait 312 avant ce ticket → +8 nouveaux : 5 `CategoryPaginationTest` + 2 `AuditLogPaginationRegressionTest` + 1 `CollectionsArePaginatedTest`)
- `make php-cs-fixer-allow-risky` → 0 fix
- Verifications HTTP manuelles : voir cahier de test dans le ticket Lesstime #72

## Note d'incident

Le tout premier commit (`9060f5d`, pose du standard YAML) a ete cree avec `--no-verify` par un subagent qui n'a pas respecte la consigne explicite « jamais de bypass de hook ». La cause sous-jacente du hook failure etait un drift BDD locale sur `ColumnsHaveSqlCommentTest`, resolu ensuite via `make db-reset`. Les 6 commits suivants ont passe le hook normalement. Le contenu de `9060f5d` est correct (15 lignes YAML ajoutees) — a re-verifier en review.

## Reviewer suggere

A definir (Tristan etant l'auteur).

## Suite

Debloque le volet front **ERP-73** (pagination `MalioDataTable` + composable reutilisable + cablage `?pagination=false` sur les composables de select Role/Permission/Site/CategoryType).

Reviewed-on: #28
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #28.
This commit is contained in:
2026-05-29 14:15:41 +00:00
committed by Autin
parent 1d91b4dea9
commit 3e46394be1
12 changed files with 545 additions and 19 deletions
@@ -29,7 +29,7 @@ final class CategoryListTest extends AbstractCatalogApiTestCase
);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/categories');
$response = $client->request('GET', '/api/categories?pagination=false');
self::assertSame(200, $response->getStatusCode());
$data = $response->toArray();
@@ -62,7 +62,7 @@ final class CategoryListTest extends AbstractCatalogApiTestCase
);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/categories?includeDeleted=true');
$response = $client->request('GET', '/api/categories?includeDeleted=true&pagination=false');
self::assertSame(200, $response->getStatusCode());
$names = array_values(array_filter(
@@ -87,7 +87,7 @@ final class CategoryListTest extends AbstractCatalogApiTestCase
$this->createCategory(self::TEST_CATEGORY_PREFIX.'mid', $type);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/categories');
$response = $client->request('GET', '/api/categories?pagination=false');
self::assertSame(200, $response->getStatusCode());
$names = array_values(array_filter(
@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use DateTimeImmutable;
/**
* Tests du contrat de pagination sur GET /api/categories (ERP-72).
*
* Invariants testes :
* - la collection expose les metadonnees Hydra (totalItems, view, member) ;
* - itemsPerPage est plafonne au maximum global (50) ;
* - une page hors limites retourne une collection vide, pas une 500 ;
* - ?pagination=false retourne tous les items sans troncature (select-box) ;
* - la pagination est compatible avec le flag ?includeDeleted=true.
*
* @internal
*/
final class CategoryPaginationTest extends AbstractCatalogApiTestCase
{
/**
* La collection expose les metadonnees de pagination JSON-LD sans prefixe :
* `totalItems`, `view`, `member` (convention API Platform 4, pas hydra:*).
*
* On cree 12 categories pour depasser la limite par page (10) : la cle
* `view` n'est presente que lorsqu'il y a plus d'items que la taille de page.
*/
public function testCollectionExposesHydraPaginationMetadata(): void
{
$type = $this->createCategoryType();
for ($i = 1; $i <= 12; ++$i) {
$this->createCategory(self::TEST_CATEGORY_PREFIX.'meta_'.$i, $type);
}
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/categories');
self::assertSame(200, $response->getStatusCode());
$data = $response->toArray();
self::assertArrayHasKey('totalItems', $data, 'La collection doit exposer totalItems.');
self::assertArrayHasKey('view', $data, 'La collection doit exposer view (pagination) quand totalItems > itemsPerPage.');
self::assertIsArray($data['member'], 'member doit etre un tableau.');
}
/**
* Un itemsPerPage arbitrairement grand (99999) doit etre plafonne au
* maximum global configure (50). On cree 12 categories pour etre certain
* de disposer de donnees ; le cap doit s'appliquer quelle que soit la taille
* reelle de la collection.
*/
public function testItemsPerPageIsCappedAtMaximum(): void
{
$type = $this->createCategoryType();
for ($i = 1; $i <= 12; ++$i) {
$this->createCategory(self::TEST_CATEGORY_PREFIX.'cap_'.$i, $type);
}
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/categories?itemsPerPage=99999');
self::assertSame(200, $response->getStatusCode());
// Le cap global est 50 : jamais plus d'items par page que le maximum.
self::assertLessThanOrEqual(
50,
count($response->toArray()['member']),
'itemsPerPage doit etre plafonne au maximum global (50).',
);
}
/**
* Une page tres elevee (99999) sur une petite collection ne doit pas
* produire une 500 PG (OFFSET negatif ou depassement de capacite) mais
* retourner 200 avec un tableau member vide.
*/
public function testOutOfBoundPageReturnsEmptyCollectionNot500(): void
{
$type = $this->createCategoryType();
$this->createCategory(self::TEST_CATEGORY_PREFIX.'oob', $type);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/categories?page=99999');
self::assertSame(200, $response->getStatusCode());
// La page 99999 est forcement vide (on a bien moins que 99999*10 items).
self::assertSame(
[],
$response->toArray()['member'],
'Une page hors limites doit retourner un member vide, jamais une 500.',
);
}
/**
* ?pagination=false permet au frontend de desactiver la pagination pour
* alimenter un select-box. On cree exactement 12 categories dont les noms
* commencent par `test_cat_select_` : le filtre sur ce prefixe isole nos
* entrees des donnees concurrentes et prouve que les 12 items sont tous
* retournes (et pas seulement les 10 premiers de la page 1).
*/
public function testClientCanDisablePaginationToFeedASelect(): void
{
$type = $this->createCategoryType();
for ($i = 1; $i <= 12; ++$i) {
$this->createCategory(self::TEST_CATEGORY_PREFIX.'select_'.$i, $type);
}
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/categories?pagination=false');
self::assertSame(200, $response->getStatusCode());
$members = $response->toArray()['member'];
// Filtre sur le sous-prefixe pour ne pas comptabiliser les categories
// d'autres tests qui partagent la meme base de donnees.
$selectItems = array_values(array_filter(
$members,
fn (array $m): bool => str_starts_with($m['name'], self::TEST_CATEGORY_PREFIX.'select_'),
));
self::assertCount(
12,
$selectItems,
'?pagination=false doit retourner toutes les categories (pas seulement la page 1).',
);
}
/**
* La pagination doit fonctionner conjointement avec le flag ?includeDeleted=true.
* On seed 3 categories actives + 2 soft-deleted, on demande itemsPerPage=5 :
* la page 1 doit contenir exactement 5 items et totalItems doit valoir >= 5.
*/
public function testPaginationCombinedWithIncludeDeletedFlag(): void
{
$type = $this->createCategoryType();
// 3 categories actives.
for ($i = 1; $i <= 3; ++$i) {
$this->createCategory(self::TEST_CATEGORY_PREFIX.'pag_active_'.$i, $type);
}
// 2 categories soft-deleted.
for ($i = 1; $i <= 2; ++$i) {
$this->createCategory(
self::TEST_CATEGORY_PREFIX.'pag_deleted_'.$i,
$type,
new DateTimeImmutable(),
);
}
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/categories?includeDeleted=true&itemsPerPage=5');
self::assertSame(200, $response->getStatusCode());
$data = $response->toArray();
// La page retournee ne doit pas exceder itemsPerPage=5.
self::assertCount(
5,
$data['member'],
'La page 1 doit contenir exactement 5 items (itemsPerPage=5 avec >= 5 categories disponibles).',
);
self::assertGreaterThanOrEqual(
5,
$data['totalItems'],
'totalItems doit refleter au moins les 5 categories seedees (actives + soft-deleted).',
);
}
}