Compare commits

..

6 Commits

Author SHA1 Message Date
Matthieu 3e2524ae58 feat(commercial) : expose accounting referentials read-only API
Expose TvaMode, PaymentDelay, PaymentType et Bank en lecture seule
(GetCollection + Get), security commercial.clients.view au niveau
operations + ressource. Aucune ecriture declaree -> POST/PATCH/DELETE
renvoient 405.

Tri par defaut position ASC puis label ASC (spec M1 § 4.7). Pagination
serveur conservee (ERP-72) avec paginationClientEnabled pour activer
l'echappatoire ?pagination=false (alimenter un select sans pagination).

Endpoints : GET /api/tva_modes, /api/payment_delays, /api/payment_types,
/api/banks. Tests fonctionnels : 200 + seed, tri position/label,
405 ecritures, 403 sans permission, 401 anonyme, pagination toggle.
2026-06-01 11:50:11 +02:00
Matthieu 2efdb8fec1 feat(commercial) : declare commercial.clients permissions + sync RBAC mirrors
Ajoute CommercialModule::permissions() (5 codes commercial.clients.* :
view, manage, accounting.view, accounting.manage, archive) — alignes sur
les is_granted deja references par ERP-55 (Client ApiResource, ClientProcessor,
ClientReadGroupContextBuilder).

Synchronise les 3 sources RBAC (regle ABSOLUE n8) : item sidebar
"Repertoire clients" (commercial.clients.view), persona user-full dans
personas.ts et SeedE2ECommand.php, cle i18n sidebar.commercial.clients.

Les roles metier Bureau/Compta/Commerciale/Usine sont seedes par ERP-74 :
les 5 permissions sont mappees ici sur le seul persona technique user-full
en attendant, sans creer de nouveau persona (regle n7).
2026-06-01 09:45:17 +02:00
Matthieu e1b8f8a28d feat(commercial) : add Client API Platform provider + processor + business rules
Branche l'API REST du repertoire clients (M1) sur l'entite Client preparee en
ERP-54. Operations GetCollection / Get / Post / Patch (pas de Delete au M1 :
l'archivage passe par PATCH isArchived).

ClientProvider :
- liste paginee (Paginator ORM, aligne sur la convention ERP-72) + echappatoire
  ?pagination=false
- exclut archives + soft-deletes par defaut (RG-1.24), ?includeArchived=true
  reintegre les archives (RG-1.25)
- tri companyName ASC (RG-1.26), filtres ?search (fuzzy companyName/lastName/
  email) et ?categoryType=<code>
- detail : 404 sur soft-delete, embarque contacts/adresses/ribs

ClientProcessor :
- normalisation serveur via ClientFieldNormalizer (RG-1.18 a 1.21)
- 409 sur doublon de nom de societe (RG-1.16) ; 409 dedie sur conflit de
  restauration (RG-1.23)
- gating par onglet : champ comptable -> accounting.manage, isArchived ->
  archive, mode strict 403 sur tout le payload (RG-1.28) ; archivage exclusif
  (RG-1.22) + pose/retrait archivedAt
- regles metier RG-1.01 (prenom/nom), RG-1.03 (distributor/broker exclusifs +
  controle du type de categorie), RG-1.12 (Virement -> banque), RG-1.13 (LCR ->
  >= 1 RIB), RG-1.04 (completude Information pour le role Commerciale)

Lecture comptable conditionnelle : ClientReadGroupContextBuilder ajoute le
groupe client:read:accounting selon commercial.clients.accounting.view.

Resolution des references categorie : CategoryReferenceDenormalizer resout les
IRI vers Category quand la propriete est type-hintee par le contrat
CategoryInterface (denormalisation impossible sur une interface sinon).

Contrats Shared :
- CategoryInterface::getCategoryTypeCode() (implemente par Category) pour la
  verification de type sans import inter-modules
- BusinessRoleAwareInterface (implemente par User) + BusinessRoles::COMMERCIALE
  pour detecter le role metier ; le code de role sera seede par ERP-74 et
  reutilise par ERP-59/60. RG-1.04 reste dormante tant qu'aucun user ne porte
  ce role.

Coordination stack :
- chaines de permission commercial.clients.* referencees ici, declarees en
  ERP-59 (tests RBAC complets en ERP-60)
- config globale de pagination (itemsPerPage client, max 50) portee par ERP-72
- referentiels comptables (PaymentType/Bank/...) exposes en ERP-56

Tests : 31 tests Commercial (integration admin sur les regles metier + unitaires
sur le gating, RG-1.04/1.12/1.13 et le context builder). Suite complete verte
(339 tests).
2026-05-29 16:41:40 +02:00
Matthieu 311e758dea feat(commercial) : add M1 client entities + accounting referentials + repositories
Entites metier (Client, ClientContact, ClientAddress, ClientRib) avec
#[Auditable] + Timestampable/Blamable, et 4 referentiels comptables statiques
(TvaMode, PaymentDelay, PaymentType, Bank). 8 repositories interfaces + impl
Doctrine. Aucun ApiResource (Provider/Processor = ERP-55).

- Client : 2 FK auto-referentes distributor/broker (mutuellement exclusives,
  CHECK en base), M2M categories, FK referentiels comptables, groupes de
  serialisation par onglet. Pas de #[ORM\UniqueConstraint] : unicite du nom de
  societe portee par l'index partiel Postgres (decision Q4).
- ClientRib : tous les champs audites, aucun #[AuditIgnore] sur iban/bic
  (decision 29/05, audit admin-only).
- M2M Category via le contrat Shared CategoryInterface + resolve_target_entities
  (regle n°1, pas d'import inter-modules) ; sites via SiteInterface.
- CommercialReferentialFixtures : re-seed idempotent des 4 referentiels (sinon
  vides apres db-reset car desormais tables mappees, purgees par les fixtures).
- Referentiels whitelistes dans EntitiesAreTimestampableBlamableTest::EXCLUDED.
- doctrine.yaml : mapping ORM du module Commercial + resolve CategoryInterface.
- ColumnCommentsCatalog : ajout des colonnes M1 (chemin schema:update/test) ;
  migration retrofit Version20260528120000 filtree sur les tables existantes
  pour ne pas casser sur les tables des modules crees plus tard.
- makefile test-db-setup : recreation de l'index partiel uq_client_company_name_active.

Refs ERP-54.
2026-05-29 15:36:14 +02:00
Matthieu 9f96d1c40d feat(commercial) : migrate M1 client tables + accounting referentials + extend category_type seed
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m20s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m36s
2026-05-29 14:48:40 +02:00
Matthieu 836f177ff9 docs(commercial) : migration racine + seed fixture CategoryType (blocages ERP-53 vérifiés) 2026-05-29 14:41:43 +02:00
49 changed files with 785 additions and 2170 deletions
-58
View File
@@ -13,64 +13,6 @@
- Le login `/login_check` est **hors** prefix `/api` (nginx reecrit `REQUEST_URI` vers `/login_check`)
- **Exception** : si tu dois creer un controller custom sous `/api/`, mettre `priority: 1` sur `#[Route]` pour eviter le conflit avec API Platform `{id}`
## Pagination (obligatoire)
**Regle** : toute collection API DOIT etre paginee. Aucun retour de collection complete cote serveur.
### Standard global
Pose dans `config/packages/api_platform.yaml` (section `defaults:`) et heritee par toutes les ressources :
| Cle | Valeur | Effet |
|---|---|---|
| `pagination_enabled` | `true` | Pagination Hydra active par defaut. |
| `pagination_items_per_page` | `10` | Taille de page par defaut, aligne sur l'UI `MalioDataTable`. |
| `pagination_maximum_items_per_page` | `50` | Borne dure : `?itemsPerPage=999` → ramene a 50. Anti deep-fetch. |
| `pagination_client_items_per_page` | `true` | Le client peut envoyer `?itemsPerPage=25` (bornee par le max). |
| `pagination_client_enabled` | `true` | Le client peut envoyer `?pagination=false` pour TOUT recuperer (echappatoire selects). |
### Override par ressource (rare)
Si une ressource a besoin d'un autre defaut (ex: payload lourd), utiliser les attributs sur l'operation. **JAMAIS `paginationEnabled: false`** sans whitelist explicite dans `tests/Architecture/CollectionsArePaginatedTest::EXCLUDED`.
```php
new GetCollection(
paginationItemsPerPage: 5, // override taille par defaut
paginationMaximumItemsPerPage: 20, // override borne max
)
```
### Selects et autocompletions
Pour alimenter un `<select>` ou un drawer RBAC (Role, Permission, Site, CategoryType), le front passe :
```ts
useApi().get('/api/roles?pagination=false')
```
Le serveur retourne toute la collection, sans `view`. C'est l'echappatoire prevue par `pagination_client_enabled: true`. Sur les ressources a forte volumetrie, preferer une saisie assistee (recherche serveur via `?q=`) — a planifier dans un ticket dedie.
Les tests fonctionnels qui exercent ce comportement doivent egalement passer `?pagination=false` (cf. `CategoryListTest`, `PermissionApiTest`).
### Providers customs et pagination
Un provider custom qui retourne un `array` brut sur une `CollectionOperationInterface` **court-circuite la pagination Hydra** (pas de `totalItems`, pas de `view`). Patterns supportes :
- **ORM** : injecter `ApiPlatform\State\Pagination\Pagination`, wrap un `Doctrine\ORM\Tools\Pagination\Paginator` dans `ApiPlatform\Doctrine\Orm\Paginator`. Exemple : `CategoryProvider`.
- **DBAL** : implementer un paginator local conforme a `PaginatorInterface`. Exemple : `DbalPaginator` (Core) + `AuditLogProvider`.
Gerer l'echappatoire `?pagination=false` :
```php
if (!$this->pagination->isEnabled($operation, $context)) {
return $qb->getQuery()->getResult(); // tout retourner
}
```
### Garde-fou architecture
`tests/Architecture/CollectionsArePaginatedTest` scanne reflexivement toutes les classes `#[ApiResource]` sous `src/` et echoue si une `GetCollection` pose `paginationEnabled: false` hors whitelist `EXCLUDED`. Ajouter une entree a la whitelist requiert une justification courte + un ticket Lesstime ouvert.
## Repositories
- Interface : `*RepositoryInterface` dans `Domain/Repository/`
-47
View File
@@ -53,53 +53,6 @@ Tout affichage LISTE tabulaire (donnees metier paginees, CRUD admin) doit passer
**Exception** : tableaux purement presentationnels non paginables (diff field/old/new, grille de comparaison, matrice RBAC d'admin, etc.) peuvent rester en `<table>` HTML brut.
## Listes paginees (standard) — usePaginatedList obligatoire
**Toute liste qui consomme une `GetCollection` API doit passer par `usePaginatedList`** (`frontend/shared/composables/usePaginatedList.ts`). Le composable est le pendant front de la regle ABSOLUE n°13 (« toute collection est paginee cote back ») : il consomme l'envelope Hydra (`member` / `totalItems` / `view`) et expose un etat reactif a brancher directement sur `MalioDataTable`.
Pattern de reference :
```ts
const {
items,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
fetch: loadList,
goToPage,
setItemsPerPage,
} = usePaginatedList<MyEntity>({ url: '/my-resources' })
onMounted(loadList)
```
```vue
<MalioDataTable
:columns="columns"
:items="rows"
:total-items="totalItems"
:page="currentPage"
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
:empty-message="t('foo.empty')"
@update:page="goToPage"
@update:per-page="setItemsPerPage"
/>
```
Garanties offertes par le composable :
- Force `Accept: application/ld+json` → API Platform 4 renvoie bien `member` / `totalItems` (sans Accept, retour tableau plat sans pagination).
- Defaut 10 items/page, choix client 10 / 25 / 50, aligne sur le defaut serveur.
- Mutation `setFilters` / `setSort` / `setItemsPerPage` → retombe systematiquement en page 1.
- Cas limite « page hors borne apres filtre » : retombe automatiquement sur la derniere page valide (`tests/usePaginatedList.test.ts`).
- Etat 100 % local (refs internes a l'instance) — **jamais reflete dans l'URL**, conformement a la regle « Etat des tableaux — pas de persistance URL » ci-dessous.
A NE PAS faire :
- Charger une collection complete via `?itemsPerPage=999` pour bypasser la pagination. Le seul cas legitime de retour complet est l'alimentation d'un `<select>` sur un referentiel ≤ quelques dizaines d'entrees, et il passe par `?pagination=false` (echappatoire prevue par `pagination_client_enabled: true`).
- Reimplementer la pagination prev/next a la main au-dessus de `MalioDataTable` — le composant porte deja le selecteur items/page et les boutons Prev/Next.
- Persister `page`/`tri`/`filtre` dans la query string — meme regle que pour `<MalioDataTable>` brut (cf. section suivante).
## Etat des tableaux — pas de persistance URL
**Interdit** de persister l'etat d'un tableau (filtres, pagination, tri par colonne, selection, ligne active, scroll) dans la query string ou de le reinjecter depuis `route.query` au montage.
-3
View File
@@ -25,8 +25,6 @@ Doc humaine : @README.md — Spec audit : @doc/audit-log.md
10. **Jamais mentionner Claude, Anthropic ou une IA** dans un commit (message, titre, body, footer, trailer) ou une PR (titre, description). Pas de `Co-Authored-By: Claude`, pas de `Generated with Claude Code`, pas de `🤖`, pas d'emoji robot, rien. Les commits sont signes par l'utilisateur uniquement.
11. **Migrations d'initialisation au namespace racine** `DoctrineMigrations` dans `migrations/` (setup user, RBAC, seed de base). Les migrations modulaires (`src/Module/*/Infrastructure/Doctrine/Migrations/`) sont reservees aux evolutions post-schema (ajout de colonnes, index) — cf. @.claude/rules/architecture.md pour la raison.
12. **Toujours documenter chaque colonne BDD via `COMMENT ON COLUMN`** dans la migration qui la cree ou la modifie. Description en francais, courte (≤ 200 caracteres), explique la semantique metier + contraintes implicites (unicite partielle, FK importante, lien RG). Garde-fou : `tests/Architecture/ColumnsHaveSqlCommentTest` echoue si une colonne `public` n'a pas de description (`col_description IS NULL`). Details et exemples : @.claude/rules/backend.md § Migrations Doctrine.
13. **Toujours paginer toute collection exposee par l'API.** Aucun retour de collection complete (pas de provider qui retourne un array brut). Standard pose dans `config/packages/api_platform.yaml` : 10 items par defaut, max 50, le client peut basculer entre 10/25/50 et peut envoyer `?pagination=false` pour alimenter un select. Garde-fou : `tests/Architecture/CollectionsArePaginatedTest` echoue si une `GetCollection` desactive la pagination sans whitelist. Details et exemples : @.claude/rules/backend.md § Pagination.
14. **`symfony.lock` est versionne** (au meme titre que `composer.lock`) — ne JAMAIS le `.gitignore`. C'est le registre des recipes Flex appliquees : sans lui, chaque `composer require` rejoue toutes les recipes et repollue `.env`, `config/bundles.php`, `docker-compose.yml` et recree du scaffolding parasite (`src/Entity/`, `src/Controller/`...). Le regenerer si besoin via `composer recipes:install --force`.
## Conventions
@.claude/rules/architecture.md
@@ -55,7 +53,6 @@ Editer uniquement `config/modules.php` (commenter la ligne). Cascade automatique
## A NE PAS faire
- Pas de controller Symfony custom sous `/api/` sans `priority: 1` sur `#[Route]` (conflit API Platform `{id}`).
- Pas de provider API Platform qui retourne un array brut sur une `GetCollection` — court-circuite la pagination Hydra (`totalItems` / `view` absents). Utiliser `ApiPlatform\Doctrine\Orm\Paginator` (ORM) ou un paginator implementant `PaginatorInterface` (DBAL — cf. `DbalPaginator`).
- Pas de `getClientMimeType()` pour valider un upload — utiliser `$file->getMimeType()` (serveur).
- Pas de hardcode de la sidebar cote front, pas de `modules-loader.ts` ni `.module.ts`.
- Pas d'edition manuelle de `extends` dans `frontend/nuxt.config.ts` — les layers sont scannes automatiquement.
-15
View File
@@ -21,18 +21,3 @@ api_platform:
stateless: true
cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin']
# === Pagination Hydra (regle projet : toute collection DOIT etre paginee) ===
# Standard datatable : 10 items par defaut, choix client 10 / 25 / 50.
# Borne dure cote serveur a 50 pour prevenir tout `?itemsPerPage=999999`
# (attaque memoire / deep-fetch). Le client peut neanmoins desactiver la
# pagination via `?pagination=false` pour alimenter un <select> ou autre
# vue "tout-en-un" — c'est l'echappatoire prevue pour les ressources
# servant a la fois de datatable et de source de select (Role,
# Permission, Site, CategoryType). Override par ressource possible via
# `paginationItemsPerPage` / `paginationMaximumItemsPerPage` /
# `paginationEnabled` sur l'attribut #[ApiResource] ou sur une operation.
pagination_enabled: true
pagination_items_per_page: 10
pagination_maximum_items_per_page: 50
pagination_client_items_per_page: true
pagination_client_enabled: true
+7
View File
@@ -103,6 +103,13 @@ return [
'label' => 'sidebar.commercial.section',
'icon' => 'mdi:account-arrow-left-outline',
'items' => [
[
'label' => 'sidebar.commercial.clients',
'to' => '/commercial/clients',
'icon' => 'mdi:account-group-outline',
'module' => 'commercial',
'permission' => 'commercial.clients.view',
],
[
'label' => 'sidebar.commercial.suppliers',
'to' => '/suppliers',
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.59'
app.version: '0.1.54'
+2 -2
View File
@@ -24,10 +24,10 @@
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
<SiteSelector v-if="showSiteSelector"/>
<main
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-10 sm:px-6 lg:px-12 xl:px-11">
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-10 sm:px-6 lg:px-12 xl:px-[170px]">
<div
aria-hidden="true"
class="pointer-events-none sticky top-0 z-30 h-11 flex-shrink-0 bg-white"/>
class="pointer-events-none sticky top-0 z-30 h-[47px] flex-shrink-0 bg-white"/>
<slot/>
</main>
</div>
+1
View File
@@ -23,6 +23,7 @@
},
"commercial": {
"section": "Commercial",
"clients": "Répertoire clients",
"suppliers": "Répertoire fournisseurs"
},
"core": {
@@ -12,7 +12,7 @@
</h2>
</template>
<form class="flex flex-col py-4 gap-2" @submit.prevent="handleSave">
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
<!-- Nom (RG-1.02 obligatoire / RG-1.04 longueur 2-120 apres trim).
Erreur miroir client + erreurs server-side (422) mappees sur ce champ. -->
<MalioInputText
@@ -52,21 +52,21 @@
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
button-class="w-m-btn-action"
button-class="w-[150px]"
@click="emit('delete')"
/>
<MalioButton
v-else
:label="t('common.cancel')"
variant="tertiary"
button-class="w-m-btn-action"
button-class="w-[150px]"
@click="emit('update:modelValue', false)"
/>
<MalioButton
v-if="canShowSave"
:label="t('common.save')"
variant="primary"
button-class="w-m-btn-action"
button-class="w-[150px]"
:disabled="form.submitting.value || loadingTypes"
@click="handleSave"
/>
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { CategoryType } from '~/modules/catalog/types/category'
import type { Category, CategoryType } from '~/modules/catalog/types/category'
import type { HydraCollection } from '~/shared/utils/api'
// Mock du store auth : useCategoriesAdmin s'auto-enregistre via
@@ -28,6 +28,27 @@ const { useCategoriesAdmin } = await import('../useCategoriesAdmin')
const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' }
const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
const CAT_A: Category = {
id: 10,
name: 'Vis',
categoryType: TYPE_VENTE,
deletedAt: null,
createdAt: '2026-01-01T10:00:00+00:00',
updatedAt: '2026-01-01T10:00:00+00:00',
createdBy: null,
updatedBy: null,
}
const CAT_B: Category = {
id: 11,
name: 'Boulons',
categoryType: TYPE_VENTE,
deletedAt: null,
createdAt: '2026-01-02T10:00:00+00:00',
updatedAt: '2026-01-02T10:00:00+00:00',
createdBy: null,
updatedBy: null,
}
function makeHydra<T>(items: T[]): HydraCollection<T> {
return {
totalItems: items.length,
@@ -35,32 +56,113 @@ function makeHydra<T>(items: T[]): HydraCollection<T> {
}
}
/**
* Apres ERP-73, `useCategoriesAdmin` ne porte plus la liste paginee des
* categories (elle est geree par `usePaginatedList<Category>` cote page).
* Le composable se concentre sur le referentiel CategoryType (lecture
* seule, ≤ 5 entrees connues) charge en une fois via `?pagination=false`.
*/
describe('useCategoriesAdmin', () => {
beforeEach(() => {
mockGet.mockReset()
// Reset systematique du state singleton entre tests : sans ca,
// les types charges dans un test fuiteraient dans le suivant.
// les categories chargees dans un test fuiteraient dans le suivant.
const { resetCategoriesAdmin } = useCategoriesAdmin()
resetCategoriesAdmin()
})
describe('fetchAll', () => {
it('appelle GET /categories avec itemsPerPage=999 par defaut', async () => {
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
const { fetchAll } = useCategoriesAdmin()
await fetchAll()
expect(mockGet).toHaveBeenCalledTimes(1)
expect(mockGet).toHaveBeenCalledWith(
'/categories',
{ itemsPerPage: 999 },
{ toast: false },
)
})
it('peuple categories.value depuis le champ Hydra member', async () => {
mockGet.mockResolvedValueOnce(makeHydra([CAT_A, CAT_B]))
const { fetchAll, categories } = useCategoriesAdmin()
await fetchAll()
expect(categories.value).toEqual([CAT_A, CAT_B])
})
it('exclut les soft-deleted par defaut (pas de query includeDeleted)', async () => {
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
const { fetchAll } = useCategoriesAdmin()
await fetchAll()
const queryArg = mockGet.mock.calls[0]?.[1] as Record<string, unknown>
expect(queryArg).not.toHaveProperty('includeDeleted')
})
it('ajoute includeDeleted=true quand demande explicitement', async () => {
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
const { fetchAll } = useCategoriesAdmin()
await fetchAll(true)
expect(mockGet).toHaveBeenCalledWith(
'/categories',
{ itemsPerPage: 999, includeDeleted: 'true' },
{ toast: false },
)
})
it('passe loading a true pendant la requete et false apres', async () => {
let resolveRequest: (v: HydraCollection<Category>) => void = () => {}
mockGet.mockImplementationOnce(
() => new Promise((resolve) => { resolveRequest = resolve }),
)
const { fetchAll, loading } = useCategoriesAdmin()
const pending = fetchAll()
expect(loading.value).toBe(true)
resolveRequest(makeHydra<Category>([]))
await pending
expect(loading.value).toBe(false)
})
it('peuple error.value et vide categories en cas d echec', async () => {
mockGet.mockRejectedValueOnce(new Error('Network down'))
const { fetchAll, categories, error, loading } = useCategoriesAdmin()
// Pre-charge volontairement quelque chose pour verifier la purge.
categories.value = [CAT_A]
await fetchAll()
expect(categories.value).toEqual([])
expect(error.value).toBe('Network down')
expect(loading.value).toBe(false)
})
it('gere une reponse sans champ member (fallback tableau vide)', async () => {
mockGet.mockResolvedValueOnce({
totalItems: 0,
} as unknown as HydraCollection<Category>)
const { fetchAll, categories } = useCategoriesAdmin()
await fetchAll()
expect(categories.value).toEqual([])
})
})
describe('fetchTypes', () => {
it('appelle GET /category_types avec ?pagination=false (echappatoire selects)', async () => {
it('appelle GET /category_types avec itemsPerPage=999', async () => {
mockGet.mockResolvedValueOnce(makeHydra<CategoryType>([]))
const { fetchTypes } = useCategoriesAdmin()
await fetchTypes()
expect(mockGet).toHaveBeenCalledTimes(1)
expect(mockGet).toHaveBeenCalledWith(
'/category_types',
{ pagination: 'false' },
{ itemsPerPage: 999 },
{ toast: false },
)
})
@@ -101,55 +203,48 @@ describe('useCategoriesAdmin', () => {
expect(loadingTypes.value).toBe(false)
})
it('gere une reponse sans champ member (fallback tableau vide)', async () => {
mockGet.mockResolvedValueOnce({
totalItems: 0,
} as unknown as HydraCollection<CategoryType>)
const { fetchTypes, types } = useCategoriesAdmin()
await fetchTypes()
expect(types.value).toEqual([])
})
})
describe('resetCategoriesAdmin', () => {
it('vide types, loadingTypes et error', () => {
const { resetCategoriesAdmin, types, loadingTypes, error }
it('vide categories, types, loading, loadingTypes et error', () => {
const { resetCategoriesAdmin, categories, types, loading, loadingTypes, error }
= useCategoriesAdmin()
// Pre-peuple le state pour verifier la purge effective.
categories.value = [CAT_A]
types.value = [TYPE_VENTE]
loading.value = true
loadingTypes.value = true
error.value = 'oops'
resetCategoriesAdmin()
expect(categories.value).toEqual([])
expect(types.value).toEqual([])
expect(loading.value).toBe(false)
expect(loadingTypes.value).toBe(false)
expect(error.value).toBeNull()
})
})
describe('singleton', () => {
it('deux appels a useCategoriesAdmin() partagent la meme ref types', () => {
it('deux appels a useCategoriesAdmin() partagent la meme ref categories', () => {
const a = useCategoriesAdmin()
const b = useCategoriesAdmin()
// Les fonctions sont reinstanciees a chaque appel mais les refs
// doivent etre rigoureusement les memes (state au niveau module).
expect(a.categories).toBe(b.categories)
expect(a.types).toBe(b.types)
expect(a.loadingTypes).toBe(b.loadingTypes)
expect(a.error).toBe(b.error)
expect(a.loading).toBe(b.loading)
})
it('une mutation via une instance est visible depuis une autre instance', () => {
const a = useCategoriesAdmin()
const b = useCategoriesAdmin()
a.types.value = [TYPE_VENTE]
a.categories.value = [CAT_A]
expect(b.types.value).toEqual([TYPE_VENTE])
expect(b.categories.value).toEqual([CAT_A])
})
})
})
@@ -1,56 +1,96 @@
/**
* Composable de chargement du referentiel CategoryType (M0 — Gestion des
* categories).
* Composable d'administration des categories (M0 — Gestion des categories).
*
* Apres ERP-73 (composable de liste paginee), la liste des categories
* elle-meme passe par `usePaginatedList<Category>` directement dans
* `admin/categories.vue` — c'est un etat propre a la page (pagination,
* filtres, tri locaux). Ce composable se concentre donc sur le
* referentiel CategoryType : petite collection lue une fois et reutilisee
* dans le drawer (select de type) → singleton volontaire pour eviter de
* la recharger a chaque ouverture du drawer.
* Centralise le chargement et le state des deux ressources lues par la page
* `/admin/categories` : la liste des categories et le referentiel
* CategoryType (utilise dans le select du drawer).
*
* State singleton au niveau module : reset automatique au logout via
* `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md), et reset
* explicite via `resetCategoriesAdmin()` appele depuis logout.vue.
* State singleton au niveau module (meme convention que `useSidebar` /
* `useModules` / `useAuditLog`) : reset automatique au logout via
* `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md : « composables
* avec state singleton doivent etre reinitialises au logout »), et reset
* explicite expose via `resetCategoriesAdmin()` appele depuis
* `modules/core/pages/logout.vue`.
*/
import { ref } from 'vue'
import type { CategoryType } from '~/modules/catalog/types/category'
import type { Category, CategoryType } from '~/modules/catalog/types/category'
import type { HydraCollection } from '~/shared/utils/api'
import { onAuthSessionCleared } from '~/shared/stores/auth'
/**
* CategoryType est un referentiel lecture-seule (RG-1.06) avec une
* cardinalite minuscule (5 entrees connues). On force `pagination=false`
* pour recuperer toutes les entrees en un appel et alimenter le select du
* drawer sans pagination — echappatoire prevue par
* `pagination_client_enabled: true` cote API Platform.
* Dette M0 : pas de pagination serveur sur les ressources Catalog (volumetrie
* cible ≤ 300). On force une page geante via `itemsPerPage` pour recuperer
* toute la liste en un coup. A basculer en pagination serveur quand la
* volumetrie reelle depassera ce plafondmeme pattern que sites.vue.
*/
const NO_PAGINATION_QUERY = { pagination: 'false' } as const
const HYDRA_NO_PAGINATION = 999
// State singleton — partage entre tous les composants qui appellent le
// composable dans la meme session. Les refs sont declarees au niveau module
// (pas dans la fonction `useCategoriesAdmin()`) pour eviter qu'une nouvelle
// instance soit creee a chaque appel.
const categories = ref<Category[]>([])
const types = ref<CategoryType[]>([])
const loading = ref(false)
const loadingTypes = ref(false)
const error = ref<string | null>(null)
function resetCategoriesAdminState(): void {
categories.value = []
types.value = []
loading.value = false
loadingTypes.value = false
error.value = null
}
// Auto-enregistrement singleton : purge le state sur 401/clearSession
// pour eviter qu'un user suivant (connecte sur le meme onglet) voie le
// referentiel de l'ancien tenant. Le logout volontaire (page logout.vue)
// appelle directement `resetCategoriesAdmin()` ci-dessous.
// Auto-enregistrement singleton : purge le state sur 401/clearSession pour
// eviter qu'un user suivant (connecte sur le meme onglet) voie l'etat de
// l'ancien. Le logout volontaire (page logout.vue) appelle directement
// `resetCategoriesAdmin()` ci-dessous.
onAuthSessionCleared(resetCategoriesAdminState)
export function useCategoriesAdmin() {
const api = useApi()
/**
* Charge le referentiel CategoryType. Appele a l'ouverture de la page
* admin pour que le select du drawer ait deja les options pretes au
* moment de la creation/edition.
* Charge la liste des categories. Le serveur exclut les soft-deleted par
* defaut (RG-1.08) et trie par name ASC (RG-1.10). Pas de pagination
* serveur (volumetrie ≤ 300, pagination front via MalioDataTable).
*
* `includeDeleted=true` permet a un user avec `catalog.categories.manage`
* de voir les soft-deleted (RG-1.09) — au M0 la page n'utilise pas cette
* option mais on l'expose pour la suite (corbeille future).
*
* Swallow volontaire : un 403 (user sans permission view) ne doit pas
* toaster — la sidebar masque deja l'entree pour ces users, on tombe sur
* la page seulement par URL directe et on affiche un tableau vide propre.
*/
async function fetchAll(includeDeleted = false): Promise<void> {
loading.value = true
error.value = null
try {
const query: Record<string, unknown> = { itemsPerPage: HYDRA_NO_PAGINATION }
if (includeDeleted) {
query.includeDeleted = 'true'
}
const data = await api.get<HydraCollection<Category>>(
'/categories',
query,
{ toast: false },
)
categories.value = data.member ?? []
} catch (e) {
categories.value = []
error.value = (e as Error)?.message ?? 'Erreur de chargement'
} finally {
loading.value = false
}
}
/**
* Charge le referentiel CategoryType (lecture seule, RG-1.06). Appele a
* l'ouverture de la page admin pour que le select du drawer ait deja les
* options pretes au moment de la creation/edition.
*
* Toast desactive : on stocke l'erreur dans `error` plutot que de
* spammer un toast — le drawer affichera l'erreur inline s'il y a lieu.
@@ -60,7 +100,7 @@ export function useCategoriesAdmin() {
try {
const data = await api.get<HydraCollection<CategoryType>>(
'/category_types',
NO_PAGINATION_QUERY,
{ itemsPerPage: HYDRA_NO_PAGINATION },
{ toast: false },
)
types.value = data.member ?? []
@@ -73,18 +113,21 @@ export function useCategoriesAdmin() {
}
/**
* Reset explicite — appele depuis `logout.vue` apres `auth.logout()`
* pour garantir que la prochaine session reparte sur un state propre
* meme si `clearSession()` n'a pas ete declenche (cas logout volontaire).
* Reset explicite — appele depuis `logout.vue` apres `auth.logout()` pour
* garantir que la prochaine session reparte sur un state propre meme si
* `clearSession()` n'a pas ete declenche (cas logout volontaire).
*/
function resetCategoriesAdmin(): void {
resetCategoriesAdminState()
}
return {
categories,
types,
loading,
loadingTypes,
error,
fetchAll,
fetchTypes,
resetCategoriesAdmin,
}
@@ -13,23 +13,18 @@
</template>
</PageHeader>
<!-- Table des categories. Tri serveur (name ASC, RG-1.10) +
pagination serveur via usePaginatedList (#73). Le composable
remplace l'ancien chargement « tout en un coup » a volumetrie
cible ≤ 300 — la pagination est desormais alignee sur la regle
projet (toute collection paginee, regle ABSOLUE n°13). -->
<!-- Table des categories. Affichage exhaustif (volumetrie cible
<= 300, cf. spec § 4.1) tri 100% serveur via CategoryProvider
(name ASC, RG-1.10). La barre de pagination du MalioDataTable
reste cosmetique tant qu'aucun slice client n'est cable : a
traiter cote @malio/layer-ui le jour ou la volumetrie monte. -->
<MalioDataTable
:columns="columns"
:items="categoryItems"
:total-items="totalItems"
:page="currentPage"
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
:total-items="categories.length"
:row-clickable="true"
:empty-message="t('admin.categories.noCategories')"
@row-click="onRowClick"
@update:page="goToPage"
@update:per-page="setItemsPerPage"
/>
<!-- Drawer creation / consultation / edition. -->
@@ -55,27 +50,13 @@ import type { Category } from '~/modules/catalog/types/category'
const { t } = useI18n()
const { can } = usePermissions()
const { fetchTypes } = useCategoriesAdmin()
const { categories, fetchAll, fetchTypes } = useCategoriesAdmin()
const { submitDelete } = useCategoryForm()
useHead({ title: t('admin.categories.title') })
const canManage = computed(() => can('catalog.categories.manage'))
// Pagination serveur via le composable partage (#73). Le CategoryProvider
// applique deja name ASC (RG-1.10) — pas besoin de defaultSort cote front
// tant qu'aucun OrderFilter n'est expose.
const {
items: categories,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
fetch: fetchCategories,
goToPage,
setItemsPerPage,
} = usePaginatedList<Category>({ url: '/categories' })
const drawerOpen = ref(false)
const selectedCategory = ref<Category | null>(null)
const deleteModalOpen = ref(false)
@@ -137,7 +118,7 @@ async function handleDelete(): Promise<void> {
deleteModalOpen.value = false
categoryToDelete.value = null
drawerOpen.value = false
await fetchCategories()
await fetchAll()
}
} finally {
deleting.value = false
@@ -145,14 +126,14 @@ async function handleDelete(): Promise<void> {
}
function onCategorySaved() {
fetchCategories()
fetchAll()
}
// Chargement initial des deux ressources (liste + referentiel des types).
// Le referentiel est pre-charge ici (et pas dans le drawer) pour que le
// select soit pret au moment ou l'utilisateur clique sur « + Ajouter ».
onMounted(() => {
fetchCategories()
fetchAll()
fetchTypes()
})
</script>
@@ -11,7 +11,7 @@
:title="`${group.module} (${selectedCountFor(group)}/${group.permissions.length})`"
header-class="capitalize"
>
<div class="flex flex-col">
<div class="flex flex-col gap-3">
<!-- Tout selectionner pour ce module -->
<MalioCheckbox
:id="`${idPrefix}-group-${group.module}`"
@@ -20,7 +20,7 @@
label-class="font-semibold text-sm text-neutral-700"
@update:model-value="(val: boolean) => emit('toggle-all', group.module, val)"
/>
<div class="flex flex-col">
<div class="flex flex-col gap-2">
<MalioCheckbox
v-for="perm in group.permissions"
:id="`${idPrefix}-perm-${perm.id}`"
@@ -11,7 +11,7 @@
{{ isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole') }}
</h2>
</template>
<form class="flex flex-col py-4 gap-2" @submit.prevent="handleSave">
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
<!-- Champs du role -->
<MalioInputText
v-model="form.label"
@@ -71,7 +71,7 @@
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
button-class="w-m-btn-action"
button-class="w-[150px]"
:disabled="role?.isSystem"
@click="emit('delete')"
/>
@@ -79,13 +79,13 @@
v-else
:label="t('common.cancel')"
variant="tertiary"
button-class="w-m-btn-action"
button-class="w-[150px]"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
variant="primary"
button-class="w-m-btn-action"
button-class="w-[150px]"
:disabled="saving || permissionsLoadFailed"
@click="handleSave"
/>
@@ -11,7 +11,7 @@
{{ t('admin.users.drawer.title', { username: user?.username ?? '' }) }}
</h2>
</template>
<div class="flex flex-col space-y-4 py-4">
<div class="flex flex-col gap-4 py-4">
<!-- Etat d'erreur de chargement des referentiels : bloque la
sauvegarde pour empecher un ecrasement silencieux des droits. -->
<div
@@ -41,13 +41,11 @@
/>
<!-- Section Roles -->
<!-- !mt-0 : la MalioCheckbox au-dessus expose son slot message (16px),
qui couvre deja l'ecart attendu pas besoin du space-y-4 ici. -->
<div class="!mt-0">
<div>
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
{{ t('admin.users.drawer.rolesSection') }}
</h4>
<div class="flex flex-col">
<div class="flex flex-col gap-2">
<MalioCheckbox
v-for="role in allRoles"
:key="role.id"
@@ -86,7 +84,7 @@
<div v-if="allSites.length === 0" class="text-sm text-neutral-400">
{{ t('admin.sites.noSites') }}
</div>
<div class="flex flex-col">
<div class="flex flex-col gap-2">
<MalioCheckbox
v-for="site in allSites"
:id="`site-${site.id}`"
@@ -115,13 +113,13 @@
<MalioButton
:label="t('common.cancel')"
variant="tertiary"
button-class="w-m-btn-action"
button-class="w-[150px]"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
variant="primary"
button-class="w-m-btn-action"
button-class="w-[150px]"
:disabled="saving || loadFailed"
@click="handleSave"
/>
@@ -66,18 +66,15 @@
<MalioAccordion>
<!-- Dates : deux champs date+heure Du / Au (champs datetime a l'origine) -->
<MalioAccordionItem :title="t('audit.filters.date_range')" value="dates">
<!-- pb-4 sur les labels Du/Au : simule le slot message
du MalioDateTime voisin pour qu'items-center recentre
le label sur le centre visible du champ. -->
<div class="grid grid-cols-[auto_1fr] items-center gap-x-3">
<span class="pb-4">{{ t('audit.filters.date_from') }}</span>
<div class="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-4">
<span>{{ t('audit.filters.date_from') }}</span>
<!-- Borne le picker "Du" par la valeur "Au" pour interdire une plage
inversee a la saisie (le backend renverrait silencieusement 0 ligne). -->
<MalioDateTime
v-model="draftDateFrom"
:max="draftDateTo ?? undefined"
/>
<span class="pb-4">{{ t('audit.filters.date_to') }}</span>
<span>{{ t('audit.filters.date_to') }}</span>
<MalioDateTime
v-model="draftDateTo"
:min="draftDateFrom ?? undefined"
@@ -87,7 +84,7 @@
<!-- Type d'entite : cases a cocher (multi-selection) -->
<MalioAccordionItem :title="t('audit.filters.entity_type')" value="entity">
<div class="flex flex-col">
<div class="flex flex-col gap-4">
<MalioCheckbox
v-for="opt in entityTypeOptions"
:id="`filter-entity-${opt.value}`"
@@ -108,7 +105,6 @@
name="audit-action"
:value="opt.value"
:label="opt.label"
group-class="mt-0"
/>
</MalioAccordionItem>
@@ -125,7 +121,7 @@
<MalioButton
variant="tertiary"
:label="t('audit.filters.reset')"
button-class="w-m-btn-action"
button-class="w-[150px]"
@click="resetFilters"
/>
<MalioButton
+23 -18
View File
@@ -13,19 +13,14 @@
</template>
</PageHeader>
<!-- Table des roles pagination serveur via usePaginatedList (#73). -->
<!-- Table des roles -->
<MalioDataTable
:columns="columns"
:items="roleItems"
:total-items="totalItems"
:page="currentPage"
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
:total-items="roles.length"
:row-clickable="canManage"
:empty-message="t('admin.roles.noRoles')"
@row-click="onRowClick"
@update:page="goToPage"
@update:per-page="setItemsPerPage"
>
<template #cell-code="{ item }">
<span class="font-mono text-xs">{{ item.code }}</span>
@@ -71,17 +66,8 @@ const canManage = computed(() => can('core.roles.manage'))
useHead({ title: t('admin.roles.title') })
// Pagination serveur via le composable partage (#73).
const {
items: roles,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
fetch: loadRoles,
goToPage,
setItemsPerPage,
} = usePaginatedList<Role>({ url: '/roles' })
const roles = ref<Role[]>([])
const loading = ref(false)
const columns = [
{ key: 'label', label: t('admin.roles.table.label') },
@@ -116,6 +102,25 @@ const deleteModalOpen = ref(false)
const roleToDelete = ref<Role | null>(null)
const deleting = ref(false)
// Charger la liste des roles
async function loadRoles() {
loading.value = true
try {
const data = await api.get<{ member: Role[] }>(
'/roles',
{},
{ toast: false },
)
roles.value = data.member
} catch {
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
// requete reussie avant une perte reseau ou 403).
roles.value = []
} finally {
loading.value = false
}
}
function openCreateDrawer() {
selectedRole.value = null
drawerOpen.value = true
+20 -21
View File
@@ -2,19 +2,14 @@
<div>
<PageHeader>{{ t('admin.users.title') }}</PageHeader>
<!-- Table des utilisateurs pagination serveur via usePaginatedList (#73). -->
<!-- Table des utilisateurs -->
<MalioDataTable
:columns="columns"
:items="userItems"
:total-items="totalItems"
:page="currentPage"
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
:total-items="users.length"
:row-clickable="canManage"
:empty-message="t('admin.users.noUsers')"
@row-click="onRowClick"
@update:page="goToPage"
@update:per-page="setItemsPerPage"
>
<template #cell-admin="{ item }">
<span
@@ -39,26 +34,15 @@
import type { UserListItem } from '~/shared/types/rbac'
const { t } = useI18n()
const api = useApi()
const { can } = usePermissions()
useHead({ title: t('admin.users.title') })
const canManage = computed(() => can('core.users.manage'))
// Pagination serveur via le composable partage (#73). Le payload `users`
// reste leger (pas de detail RBAC dans la liste — cf. commentaire colonne
// "Sites" plus bas) ce qui rend la pagination 10/25/50 par page confortable.
const {
items: users,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
fetch: loadUsers,
goToPage,
setItemsPerPage,
} = usePaginatedList<UserListItem>({ url: '/users' })
const users = ref<UserListItem[]>([])
const loading = ref(false)
const drawerOpen = ref(false)
const selectedUser = ref<UserListItem | null>(null)
@@ -83,6 +67,21 @@ const userItems = computed(() =>
})),
)
async function loadUsers() {
loading.value = true
try {
const usersData = await api.get<{ member: UserListItem[] }>('/users', {}, { toast: false })
users.value = usersData.member
} catch {
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
// requete reussie avant une perte reseau ou 403). Pas de toast par
// design ici : on laisse la liste vide parler d'elle-meme.
users.value = []
} finally {
loading.value = false
}
}
function getUserById(id: number): UserListItem | undefined {
return users.value.find(u => u.id === id)
}
+2 -2
View File
@@ -6,7 +6,7 @@
<img src="/LOGO_MALIO.png" alt="Logo" class="w-[150px]"/>
</span>
<form
class="mt-8 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
@submit.prevent="handleSubmit"
>
<MalioInputText
@@ -30,7 +30,7 @@
type="submit"
:disabled="isSubmitting"
/>
<p class="mt-6 font-bold">v{{ version }}</p>
<p class="font-bold">v{{ version }}</p>
</form>
</div>
</template>
@@ -11,7 +11,7 @@
{{ isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite') }}
</h2>
</template>
<form class="flex flex-col py-4 gap-2" @submit.prevent="handleSave">
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
<MalioInputText
v-model="form.name"
:label="t('admin.sites.form.name')"
@@ -65,16 +65,11 @@
input-class="w-full font-mono"
required
/>
<!-- pb-4 sur le wrapper : simule le slot message du
MalioInputText voisin pour qu'items-center recentre
la puce sur le centre visible de l'input. -->
<div class="shrink-0 pb-4">
<span
:style="{ backgroundColor: isValidHex ? form.color : 'transparent' }"
class="inline-block size-10 rounded-lg border border-neutral-200"
:class="{ 'border-dashed': !isValidHex }"
/>
</div>
<span
:style="{ backgroundColor: isValidHex ? form.color : 'transparent' }"
class="inline-block size-10 shrink-0 rounded-lg border border-neutral-200"
:class="{ 'border-dashed': !isValidHex }"
/>
</div>
<p v-if="form.color && !isValidHex" class="mt-1 text-xs text-red-600">
{{ t('admin.sites.form.colorInvalid') }}
@@ -92,20 +87,20 @@
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
button-class="w-m-btn-action"
button-class="w-[150px]"
@click="emit('delete')"
/>
<MalioButton
v-else
:label="t('common.cancel')"
variant="tertiary"
button-class="w-m-btn-action"
button-class="w-[150px]"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
variant="primary"
button-class="w-m-btn-action"
button-class="w-[150px]"
:disabled="saving || !isValidHex"
@click="handleSave"
/>
+22 -21
View File
@@ -13,19 +13,14 @@
</template>
</PageHeader>
<!-- Table des sites pagination serveur via usePaginatedList (#73). -->
<!-- Table des sites -->
<MalioDataTable
:columns="columns"
:items="siteItems"
:total-items="totalItems"
:page="currentPage"
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
:total-items="sites.length"
:row-clickable="canManage"
:empty-message="t('admin.sites.noSites')"
@row-click="onRowClick"
@update:page="goToPage"
@update:per-page="setItemsPerPage"
>
<template #cell-color="{ item }">
<span class="inline-flex items-center gap-2">
@@ -72,20 +67,8 @@ const canManage = computed(() => can('sites.manage'))
useHead({ title: t('admin.sites.title') })
// Pagination serveur via le composable partage (#73). Aucun OrderFilter
// declare cote API Platform sur Site, donc on s'appuie sur le tri par
// defaut du repository (id ASC). Le composable est neanmoins pret a
// recevoir un `defaultSort` ou des filtres le jour ou l'API les expose.
const {
items: sites,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
fetch: loadSites,
goToPage,
setItemsPerPage,
} = usePaginatedList<Site>({ url: '/sites' })
const sites = ref<Site[]>([])
const loading = ref(false)
const columns = [
{ key: 'name', label: t('admin.sites.table.name') },
@@ -124,6 +107,24 @@ const deleteModalOpen = ref(false)
const siteToDelete = ref<Site | null>(null)
const deleting = ref(false)
async function loadSites() {
loading.value = true
try {
const data = await api.get<{ member: Site[] }>(
'/sites',
{ itemsPerPage: 999 },
{ toast: false },
)
sites.value = data.member
} catch {
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
// requete reussie avant une perte reseau ou 403).
sites.value = []
} finally {
loading.value = false
}
}
function openCreateDrawer() {
selectedSite.value = null
drawerOpen.value = true
+4 -4
View File
@@ -7,7 +7,7 @@
"name": "starseed-frontend",
"hasInstallScript": true,
"dependencies": {
"@malio/layer-ui": "^1.7.3",
"@malio/layer-ui": "^1.7.1",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -1866,9 +1866,9 @@
"license": "MIT"
},
"node_modules/@malio/layer-ui": {
"version": "1.7.3",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.3/layer-ui-1.7.3.tgz",
"integrity": "sha512-jw3ka0Az6Jf0F9ifsooknkwXph8TNgoe6H3CjF8tbBxl8oND8HLHjlZ04ooUCoOUEIlsQ1Mm2hFFlQRCB04qdA==",
"version": "1.7.1",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.1/layer-ui-1.7.1.tgz",
"integrity": "sha512-RYMMappWt/fgjD+BM7//h2O6kxD6WH9Fui8hoC29xtKySRQsqD61XKTdR7BRRkpktbxKmV39q/hblyAFBqV5yw==",
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
+1 -1
View File
@@ -17,7 +17,7 @@
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@malio/layer-ui": "^1.7.3",
"@malio/layer-ui": "^1.7.1",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -1,412 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { usePaginatedList } from '../usePaginatedList'
const mockApiGet = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
/**
* Tests du composable `usePaginatedList`.
*
* Couvre les invariants critiques :
* - parse Hydra (member / totalItems)
* - navigation page (goToPage / next / prev / bornes)
* - changement items/page retour page 1
* - mutation filtres / tri retour page 1
* - cas limite : page courante hors borne apres filtre derniere page valide
* - liste vide / page unique
* - reset defaults
* - swallow d'erreur reseau (la promesse `fetch` ne reject jamais)
* - header `Accept: application/ld+json` toujours envoye (besoin du
* paginator Hydra cote API Platform 4).
*/
describe('usePaginatedList', () => {
beforeEach(() => {
mockApiGet.mockReset()
})
function mockResponse(member: unknown[], totalItems: number): void {
mockApiGet.mockResolvedValueOnce({ member, totalItems })
}
it('fetch initial : page=1, itemsPerPage par defaut, parse Hydra', async () => {
mockResponse([{ id: 1 }, { id: 2 }], 42)
const list = usePaginatedList<{ id: number }>({ url: '/sites' })
await list.fetch()
expect(mockApiGet).toHaveBeenCalledTimes(1)
const [url, query, opts] = mockApiGet.mock.calls[0]
expect(url).toBe('/sites')
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
expect(opts).toMatchObject({
toast: false,
headers: { Accept: 'application/ld+json' },
})
expect(list.items.value).toEqual([{ id: 1 }, { id: 2 }])
expect(list.totalItems.value).toBe(42)
expect(list.totalPages.value).toBe(5)
expect(list.isEmpty.value).toBe(false)
expect(list.isSinglePage.value).toBe(false)
})
it('itemsPerPage personnalise est respecte au premier fetch', async () => {
mockResponse([], 0)
const list = usePaginatedList({ url: '/users', defaultItemsPerPage: 25 })
await list.fetch()
expect(mockApiGet.mock.calls[0][1]).toMatchObject({ itemsPerPage: 25 })
expect(list.itemsPerPage.value).toBe(25)
})
it('goToPage(N) declenche un nouvel appel avec page=N', async () => {
mockResponse([{ id: 1 }], 30) // page 1
const list = usePaginatedList<{ id: number }>({ url: '/users' })
await list.fetch()
mockResponse([{ id: 2 }], 30) // page 2
await list.goToPage(2)
expect(mockApiGet).toHaveBeenCalledTimes(2)
expect(mockApiGet.mock.calls[1][1]).toMatchObject({ page: 2, itemsPerPage: 10 })
expect(list.currentPage.value).toBe(2)
})
it('goToPage hors borne sup. est clampe a totalPages', async () => {
mockResponse([], 30) // totalPages = 3
const list = usePaginatedList({ url: '/roles' })
await list.fetch()
mockResponse([], 30)
await list.goToPage(999)
expect(list.currentPage.value).toBe(3)
})
it('goToPage hors borne inf. est clampe a 1 (no-op si deja en 1)', async () => {
mockResponse([], 30)
const list = usePaginatedList({ url: '/roles' })
await list.fetch()
mockApiGet.mockClear()
await list.goToPage(-5)
// Deja en page 1 -> aucun nouvel appel.
expect(mockApiGet).toHaveBeenCalledTimes(0)
expect(list.currentPage.value).toBe(1)
})
it('nextPage / prevPage avancent et reculent dans les bornes', async () => {
mockResponse([], 30) // page 1, totalPages 3
const list = usePaginatedList({ url: '/roles' })
await list.fetch()
mockResponse([], 30)
await list.nextPage()
expect(list.currentPage.value).toBe(2)
mockResponse([], 30)
await list.nextPage()
expect(list.currentPage.value).toBe(3)
// En derniere page -> no-op
mockApiGet.mockClear()
await list.nextPage()
expect(mockApiGet).toHaveBeenCalledTimes(0)
expect(list.currentPage.value).toBe(3)
mockResponse([], 30)
await list.prevPage()
expect(list.currentPage.value).toBe(2)
})
it('setItemsPerPage revient en page 1 et refetch', async () => {
mockResponse([], 100)
const list = usePaginatedList({ url: '/users' })
await list.fetch()
// place-toi page 3
mockResponse([], 100)
await list.goToPage(3)
expect(list.currentPage.value).toBe(3)
mockResponse([], 100)
await list.setItemsPerPage(25)
expect(list.currentPage.value).toBe(1)
expect(list.itemsPerPage.value).toBe(25)
expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({ page: 1, itemsPerPage: 25 })
})
it('setItemsPerPage no-op si meme valeur', async () => {
mockResponse([], 10)
const list = usePaginatedList({ url: '/users' })
await list.fetch()
mockApiGet.mockClear()
await list.setItemsPerPage(10)
expect(mockApiGet).toHaveBeenCalledTimes(0)
})
it('setFilters fusionne et retombe en page 1', async () => {
mockResponse([], 100)
const list = usePaginatedList<unknown, { name?: string; active?: boolean }>({
url: '/users',
defaultFilters: { active: true },
})
await list.fetch()
mockResponse([], 100)
await list.goToPage(2)
mockResponse([], 100)
await list.setFilters({ name: 'alice' })
expect(list.currentPage.value).toBe(1)
expect(list.filters.value).toEqual({ active: true, name: 'alice' })
expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({
page: 1,
active: true,
name: 'alice',
})
})
it('setFilters({ key: undefined }) supprime la cle', async () => {
mockResponse([], 100)
const list = usePaginatedList<unknown, { name?: string }>({
url: '/users',
defaultFilters: { name: 'alice' },
})
await list.fetch()
mockResponse([], 100)
await list.setFilters({ name: undefined })
expect(list.filters.value).toEqual({})
// Le query envoye ne contient plus `name` (compactQuery elimine
// aussi les valeurs vides).
const q = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
expect(q.name).toBeUndefined()
})
it('setFilters({ replace: true }) remplace integralement', async () => {
mockResponse([], 100)
const list = usePaginatedList<unknown, { a?: string; b?: string }>({
url: '/users',
defaultFilters: { a: 'x' },
})
await list.fetch()
mockResponse([], 100)
await list.setFilters({ b: 'y' }, { replace: true })
expect(list.filters.value).toEqual({ b: 'y' })
})
it('setSort envoie order[field]=direction et reset page', async () => {
mockResponse([], 100)
const list = usePaginatedList({ url: '/users' })
await list.fetch()
mockResponse([], 100)
await list.goToPage(2)
mockResponse([], 100)
await list.setSort({ field: 'username', direction: 'desc' })
expect(list.currentPage.value).toBe(1)
const q = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
expect(q['order[username]']).toBe('desc')
})
it('setSort(null) retire le tri', async () => {
mockResponse([], 100)
const list = usePaginatedList({
url: '/users',
defaultSort: { field: 'name', direction: 'asc' },
})
await list.fetch()
// Le tri initial est applique
let q = mockApiGet.mock.calls[0][1] as Record<string, unknown>
expect(q['order[name]']).toBe('asc')
mockResponse([], 100)
await list.setSort(null)
q = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
expect(q['order[name]']).toBeUndefined()
})
it('setFilters retombe en page 1 (cas standard, pas le hors-borne)', async () => {
// setFilters remet toujours page=1 avant de refetcher : ce n'est
// donc PAS le chemin de retry hors-borne (couvert par le test
// suivant via un refetch a page constante). On verifie juste le
// reset de page ici.
mockResponse([], 50) // 5 pages
const list = usePaginatedList({ url: '/users' })
await list.fetch()
mockResponse([], 50)
await list.goToPage(5)
expect(list.currentPage.value).toBe(5)
mockResponse([{ id: 1 }, { id: 2 }], 12)
await list.setFilters({ active: true } as never)
expect(list.currentPage.value).toBe(1)
expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({ page: 1 })
})
it('declenche le retry sur derniere page si currentPage > totalPages apres fetch', async () => {
// Scenario : on a fait un fetch (5 pages, page=1). Sans toucher aux
// filtres mais entre deux fetchs la donnee a change cote serveur,
// la page courante peut devenir hors borne. On force le scenario
// en montant manuellement currentPage via goToPage borne, puis en
// simulant une reponse plus petite.
mockResponse([], 50) // 5 pages
const list = usePaginatedList({ url: '/users' })
await list.fetch()
mockResponse([], 50)
await list.goToPage(5)
expect(list.currentPage.value).toBe(5)
// Maintenant simule : refetch -> totalItems chute a 12 (2 pages),
// le composable doit refetcher sur page=2.
mockApiGet.mockReset()
mockApiGet
.mockResolvedValueOnce({ member: [], totalItems: 12 }) // page=5 vide
.mockResolvedValueOnce({ member: [{ id: 11 }, { id: 12 }], totalItems: 12 }) // page=2
await list.fetch()
expect(mockApiGet).toHaveBeenCalledTimes(2)
expect(mockApiGet.mock.calls[1][1]).toMatchObject({ page: 2 })
expect(list.currentPage.value).toBe(2)
expect(list.items.value).toEqual([{ id: 11 }, { id: 12 }])
})
it('liste vide : isEmpty true, isSinglePage true', async () => {
mockResponse([], 0)
const list = usePaginatedList({ url: '/users' })
await list.fetch()
expect(list.totalItems.value).toBe(0)
expect(list.isEmpty.value).toBe(true)
expect(list.isSinglePage.value).toBe(true)
expect(list.totalPages.value).toBe(1)
})
it('isEmpty est faux avant le premier fetch (etat indetermine)', () => {
const list = usePaginatedList({ url: '/users' })
expect(list.isEmpty.value).toBe(false)
})
it('reset revient aux defaults', async () => {
mockResponse([], 100)
const list = usePaginatedList<unknown, { a?: string }>({
url: '/users',
defaultItemsPerPage: 10,
defaultFilters: { a: 'x' },
defaultSort: { field: 'name', direction: 'asc' },
})
await list.fetch()
mockResponse([], 100)
await list.setItemsPerPage(50)
mockResponse([], 100)
await list.setFilters({ a: 'y' })
mockResponse([], 100)
await list.setSort({ field: 'id', direction: 'desc' })
mockResponse([], 100)
await list.goToPage(2)
expect(list.currentPage.value).toBe(2)
mockResponse([], 100)
await list.reset()
expect(list.itemsPerPage.value).toBe(10)
expect(list.filters.value).toEqual({ a: 'x' })
expect(list.sort.value).toEqual({ field: 'name', direction: 'asc' })
expect(list.currentPage.value).toBe(1)
})
it('swallow l\'erreur reseau : items vides, loading false, fetch ne reject pas', async () => {
const boom = new Error('boom')
mockApiGet.mockRejectedValueOnce(boom)
const list = usePaginatedList({ url: '/users' })
await expect(list.fetch()).resolves.toBeUndefined()
expect(list.items.value).toEqual([])
expect(list.totalItems.value).toBe(0)
expect(list.loading.value).toBe(false)
// L'erreur est consideree comme un fetch consume -> isEmpty=true.
expect(list.isEmpty.value).toBe(true)
// ... mais `error` est expose pour distinguer « vide » d'« echec ».
expect(list.error.value).toBe(boom)
})
it('error est remis a null des qu\'un fetch ulterieur reussit', async () => {
mockApiGet.mockRejectedValueOnce(new Error('boom'))
const list = usePaginatedList({ url: '/users' })
await list.fetch()
expect(list.error.value).toBeInstanceOf(Error)
mockResponse([{ id: 1 }], 1)
await list.fetch()
expect(list.error.value).toBeNull()
expect(list.items.value).toEqual([{ id: 1 }])
})
it('ignore une reponse periemee : la derniere requete *demandee* gagne', async () => {
// Deux fetch concurrents : le 1er resout APRES le 2eme. Sans garde
// de sequence, la reponse arrivee en dernier (token 1) ecraserait
// les donnees plus fraiches du token 2. Avec la garde, token 2 fait
// foi quel que soit l'ordre d'arrivee reseau.
const list = usePaginatedList<{ id: number }>({ url: '/users' })
let resolveSlow!: (v: unknown) => void
const slow = new Promise((r) => { resolveSlow = r })
// 1er appel : reponse lente (en vol).
mockApiGet.mockReturnValueOnce(slow)
// 2eme appel : reponse immediate avec des donnees plus fraiches.
mockApiGet.mockResolvedValueOnce({ member: [{ id: 2 }], totalItems: 30 })
const p1 = list.fetch() // token 1, en vol
const p2 = list.fetch() // token 2, resout tout de suite
await p2
expect(list.items.value).toEqual([{ id: 2 }])
// La reponse lente du token 1 arrive enfin : elle doit etre ignoree.
resolveSlow({ member: [{ id: 1 }], totalItems: 30 })
await p1
expect(list.items.value).toEqual([{ id: 2 }])
// Le spinner reste eteint (la requete recente l'avait deja coupe).
expect(list.loading.value).toBe(false)
})
it('extraQuery est injecte a chaque fetch (ex : includeDeleted)', async () => {
mockResponse([], 0)
const list = usePaginatedList({
url: '/categories',
extraQuery: { includeDeleted: 'true' },
})
await list.fetch()
expect(mockApiGet.mock.calls[0][1]).toMatchObject({ includeDeleted: 'true' })
})
it('valeurs nulles/vides des filtres ne sont pas envoyees', async () => {
mockResponse([], 0)
const list = usePaginatedList<unknown, { name?: string; q?: string }>({
url: '/users',
defaultFilters: { name: '', q: undefined } as never,
})
await list.fetch()
const q = mockApiGet.mock.calls[0][1] as Record<string, unknown>
expect(q.name).toBeUndefined()
expect(q.q).toBeUndefined()
})
it('refresh() est un alias de fetch()', async () => {
mockResponse([{ id: 1 }], 1)
const list = usePaginatedList<{ id: number }>({ url: '/users' })
await list.refresh()
expect(list.items.value).toEqual([{ id: 1 }])
})
})
@@ -1,364 +0,0 @@
import { computed, ref, type Ref } from 'vue'
import type { HydraCollection } from '~/shared/utils/api'
/**
* Composable generique de liste paginee serveur.
*
* Responsabilites :
* - centraliser l'etat tableau (page courante, items/page, tri, filtres,
* totalItems, items, loading, error) cote local JAMAIS dans l'URL,
* conformement a la regle ABSOLUE n°6 du CLAUDE.md (« Jamais persister
* l'etat de tableau dans l'URL »).
* - dialoguer avec une ressource API Platform 4 (Hydra) en passant
* `page`, `itemsPerPage` et le tri/filtres en query params.
* - exposer une API simple a brancher sur `MalioDataTable`
* (props page/perPage/totalItems + events update:page / update:per-page).
*
* Volontairement **par-instance** (state local a chaque appel) : a la
* difference de `useAuditLog` / `useCategoriesAdmin` qui sont des
* singletons module-level partages, une liste paginee est propre a son
* ecran et ne doit pas etre partagee entre pages (sinon un retour
* arriere reprendrait la pagination d'une autre liste).
*
* Pas de gestion URL : si une page veut un deep link (ex : ouvrir un
* detail), elle le fait via sa propre route, pas via la query string
* de pagination. Derogation possible uniquement si l'utilisateur le
* demande explicitement, cf. CLAUDE.md.
*/
/**
* Direction de tri serveur. API Platform 4 attend `asc` ou `desc` via la
* syntaxe `?order[field]=asc`.
*/
export type SortDirection = 'asc' | 'desc'
/**
* Specification de tri : un seul champ trie a la fois cote front (la
* majorite des tableaux Malio n'expose pas le multi-tri). Si null, aucun
* `order[...]` n'est envoye et l'API applique son tri par defaut.
*/
export interface SortSpec {
field: string
direction: SortDirection
}
/**
* Type des filtres : un dictionnaire de valeurs serialisables en query
* params. Le caller decide du mapping (ex : `{ active: true }`,
* `{ 'name[ilike]': 'a' }`). Valeurs `null` / `undefined` / chaines vides
* sont automatiquement omises au moment de la requete.
*/
export type PaginatedListFilters = Record<string, string | number | boolean | string[] | null | undefined>
export interface UsePaginatedListOptions<F extends PaginatedListFilters = PaginatedListFilters> {
/** URL relative au prefix `/api` (ex : `/sites`, `/categories`). */
url: string
/** Items par page initial. Defaut 10 (aligne avec le defaut serveur). */
defaultItemsPerPage?: number
/** Options proposees dans le selecteur items/page. Defaut [10, 25, 50]. */
itemsPerPageOptions?: number[]
/** Filtres initiaux. */
defaultFilters?: F
/** Tri initial. */
defaultSort?: SortSpec | null
/**
* Query params additionnels propres a la ressource (ex : `includeDeleted=true`,
* `groups[]=foo`) injectes a chaque requete. **Snapshot statique** : l'objet
* est lu tel quel a chaque `fetch()`, ses valeurs ne sont pas deballees. Ne
* pas y passer de `ref` / `computed` (elles seraient serialisees comme objet,
* pas comme valeur) pour un extra reactif, muter les filtres via
* `setFilters` ou ouvrir un ticket pour un support `MaybeRefOrGetter`.
*/
extraQuery?: Record<string, unknown>
}
export interface UsePaginatedListReturn<T, F extends PaginatedListFilters = PaginatedListFilters> {
/** Items de la page courante. */
items: Ref<T[]>
/** Total d'items (toutes pages) renvoye par Hydra. */
totalItems: Ref<number>
/** Page courante (1-based). */
currentPage: Ref<number>
/** Taille de page courante. */
itemsPerPage: Ref<number>
/** Options exposees au selecteur items/page. */
itemsPerPageOptions: Ref<number[]>
/** Nombre total de pages (≥ 1). */
totalPages: Ref<number>
/** Indicateur de chargement (vrai pendant `fetch()`). */
loading: Ref<boolean>
/**
* Derniere erreur de `fetch()` (null si le dernier appel a abouti).
* Permet a la page de distinguer « liste reellement vide » d'un echec
* reseau / 403 : sans ca, `isEmpty` confond les deux cas (la liste
* tombe a 0 item dans les deux situations). La page decide de l'UX
* (bandeau, bouton reessayer) le composable ne toaste pas.
*/
error: Ref<unknown | null>
/** Vrai apres au moins un fetch reussi avec 0 item. */
isEmpty: Ref<boolean>
/** Vrai si la collection tient en une seule page (totalPages <= 1). */
isSinglePage: Ref<boolean>
/** Filtres courants (mutation via `setFilters`). */
filters: Ref<F>
/** Tri courant (mutation via `setSort`). */
sort: Ref<SortSpec | null>
/** Lance un fetch contre l'API et met a jour items/totalItems. */
fetch: () => Promise<void>
/** Va a la page demandee (bornes appliquees : 1 ≤ p ≤ totalPages). */
goToPage: (page: number) => Promise<void>
/** Page suivante (no-op si deja en derniere page). */
nextPage: () => Promise<void>
/** Page precedente (no-op si deja en premiere page). */
prevPage: () => Promise<void>
/** Change la taille de page et revient en page 1. */
setItemsPerPage: (value: number) => Promise<void>
/** Applique de nouveaux filtres et revient en page 1. */
setFilters: (next: Partial<F>, options?: { replace?: boolean }) => Promise<void>
/** Change le tri et revient en page 1. */
setSort: (next: SortSpec | null) => Promise<void>
/** Reinitialise filtres + tri + page sur les valeurs par defaut. */
reset: () => Promise<void>
/** Alias de `fetch()` (intention plus claire dans certains contextes). */
refresh: () => Promise<void>
}
/**
* Force `application/ld+json` : sous `application/json`, API Platform 4
* renvoie un tableau plat sans envelope de pagination on ne pourrait pas
* lire `totalItems` ni `view`. Voir aussi `useAuditLog.ts`.
*/
const JSONLD_HEADERS = { Accept: 'application/ld+json' } as const
/**
* Filtre les entrees nulles/undefined/vides d'un objet de query : evite
* d'envoyer `?foo=&bar=null` a l'API qui declencherait parfois des erreurs
* de filtre cote Symfony (`FilterInterface::apply` strict).
*/
function compactQuery(raw: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const [key, value] of Object.entries(raw)) {
if (value === null || value === undefined) continue
if (typeof value === 'string' && value === '') continue
if (Array.isArray(value) && value.length === 0) continue
out[key] = value
}
return out
}
export function usePaginatedList<T, F extends PaginatedListFilters = PaginatedListFilters>(
options: UsePaginatedListOptions<F>,
): UsePaginatedListReturn<T, F> {
const api = useApi()
const defaultItemsPerPage = options.defaultItemsPerPage ?? 10
const initialFilters = { ...(options.defaultFilters ?? ({} as F)) } as F
const initialSort = options.defaultSort ?? null
const items = ref<T[]>([]) as Ref<T[]>
const totalItems = ref(0)
const currentPage = ref(1)
const itemsPerPage = ref(defaultItemsPerPage)
const itemsPerPageOptions = ref(options.itemsPerPageOptions ?? [10, 25, 50])
const loading = ref(false)
const error = ref<unknown | null>(null)
// Jeton de sequence : incremente a chaque `fetch()`. Une reponse dont
// le jeton n'est plus le dernier est ignoree (protection contre les
// reponses periemes quand l'utilisateur enchaine page / tri / filtres
// plus vite que le reseau ne repond — sinon la derniere reponse
// *arrivee* gagnerait au lieu de la derniere *demandee*).
let fetchToken = 0
// `hasFetched` evite que `isEmpty` retourne `true` avant le premier
// chargement (etat initial = 0 items mais on ne sait pas encore si la
// ressource est vide ou en cours de chargement). Un appel reseau au
// moins doit avoir abouti pour qu'on annonce une liste « vide ».
const hasFetched = ref(false)
const filters = ref({ ...initialFilters }) as Ref<F>
const sort = ref<SortSpec | null>(initialSort ? { ...initialSort } : null)
const totalPages = computed(() => {
if (totalItems.value <= 0 || itemsPerPage.value <= 0) return 1
return Math.max(1, Math.ceil(totalItems.value / itemsPerPage.value))
})
const isEmpty = computed(() => hasFetched.value && totalItems.value === 0)
const isSinglePage = computed(() => totalPages.value <= 1)
/**
* Construit l'objet query envoye a l'API : extras + filtres, puis
* pagination + tri. Les cles reservees (`page`, `itemsPerPage`,
* `order[...]`) sont assignees **en dernier** pour qu'un filtre ou un
* extra portant le meme nom ne puisse pas ecraser silencieusement la
* pagination. Les filtres `null`/`undefined`/'' sont elimines pour ne
* pas polluer l'URL.
*/
function buildQuery(): Record<string, unknown> {
const query: Record<string, unknown> = {}
if (options.extraQuery) {
Object.assign(query, options.extraQuery)
}
Object.assign(query, filters.value)
// Cles reservees en dernier : priorite a la pagination/au tri.
query.page = currentPage.value
query.itemsPerPage = itemsPerPage.value
if (sort.value) {
// Format API Platform : ?order[field]=asc
query[`order[${sort.value.field}]`] = sort.value.direction
}
return compactQuery(query)
}
/**
* Lance un fetch et applique la borne haute si necessaire. Si la page
* courante depasse `totalPages` apres l'application des filtres (cas
* « j'etais en page 5, je filtre, il ne reste qu'une page »), on
* rappelle l'API sur la derniere page valide. Un seul niveau de retry
* pour eviter une boucle si l'API renvoie des resultats incoherents.
*/
async function fetch(): Promise<void> {
const token = ++fetchToken
loading.value = true
error.value = null
try {
const data = await api.get<HydraCollection<T>>(
options.url,
buildQuery(),
{ toast: false, headers: JSONLD_HEADERS },
)
// Une requete plus recente a ete lancee entre-temps : on jette
// cette reponse pour ne pas ecraser des donnees plus fraiches.
if (token !== fetchToken) return
items.value = data.member ?? []
totalItems.value = data.totalItems ?? 0
const tp = totalItems.value > 0
? Math.max(1, Math.ceil(totalItems.value / itemsPerPage.value))
: 1
// Si on est hors borne ET qu'il y a au moins une page valide
// a viser, on retombe sur la derniere page (cf. cas limite
// « page hors borne apres filtre » de la spec #73). On ne
// refetch que si la nouvelle page est differente, sinon
// boucle infinie potentielle.
if (currentPage.value > tp && tp >= 1 && totalItems.value > 0) {
currentPage.value = tp
const data2 = await api.get<HydraCollection<T>>(
options.url,
buildQuery(),
{ toast: false, headers: JSONLD_HEADERS },
)
// Meme garde apres le refetch hors-borne.
if (token !== fetchToken) return
items.value = data2.member ?? []
totalItems.value = data2.totalItems ?? 0
}
hasFetched.value = true
} catch (e) {
// Reponse periemee : ne pas toucher au state, une requete plus
// recente est en cours et fera foi.
if (token !== fetchToken) return
// Swallow volontaire : on remet la liste a vide pour ne pas
// afficher de donnees stale, et on expose l'erreur pour que la
// page distingue « vide » d'« echec ». Le composant parent
// decide de l'UX (toast / message d'erreur) — pas d'a-priori ici.
error.value = e
items.value = []
totalItems.value = 0
hasFetched.value = true
} finally {
// Seule la requete la plus recente eteint le spinner : une
// reponse periemee ne doit pas le couper alors qu'un fetch plus
// recent est encore en vol.
if (token === fetchToken) loading.value = false
}
}
async function goToPage(page: number): Promise<void> {
const tp = totalPages.value
const next = Math.max(1, Math.min(page, tp))
if (next === currentPage.value) return
currentPage.value = next
await fetch()
}
async function nextPage(): Promise<void> {
if (currentPage.value >= totalPages.value) return
await goToPage(currentPage.value + 1)
}
async function prevPage(): Promise<void> {
if (currentPage.value <= 1) return
await goToPage(currentPage.value - 1)
}
async function setItemsPerPage(value: number): Promise<void> {
if (!Number.isFinite(value) || value <= 0) return
const rounded = Math.floor(value)
if (rounded === itemsPerPage.value) return
itemsPerPage.value = rounded
currentPage.value = 1
await fetch()
}
/**
* `replace: false` (defaut) fusionne avec les filtres courants. Une
* valeur explicitement `undefined` retire la cle (utile pour effacer
* un filtre depuis un champ controle). `replace: true` remplace
* integralement l'objet par `next`.
*/
async function setFilters(next: Partial<F>, opts?: { replace?: boolean }): Promise<void> {
if (opts?.replace) {
filters.value = { ...(next as F) }
} else {
const merged = { ...filters.value, ...next } as F
// Supprime les cles explicitement passees a undefined : sans ce
// nettoyage, l'objet `filters` accumulerait des cles fantomes.
for (const key of Object.keys(next)) {
if (next[key as keyof F] === undefined) {
delete (merged as Record<string, unknown>)[key]
}
}
filters.value = merged
}
currentPage.value = 1
await fetch()
}
async function setSort(next: SortSpec | null): Promise<void> {
sort.value = next ? { ...next } : null
currentPage.value = 1
await fetch()
}
async function reset(): Promise<void> {
filters.value = { ...initialFilters }
sort.value = initialSort ? { ...initialSort } : null
itemsPerPage.value = defaultItemsPerPage
currentPage.value = 1
await fetch()
}
return {
items,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
totalPages,
loading,
error,
isEmpty,
isSinglePage,
filters,
sort,
fetch,
goToPage,
nextPage,
prevPage,
setItemsPerPage,
setFilters,
setSort,
reset,
refresh: fetch,
}
}
+10
View File
@@ -65,6 +65,16 @@ export const personas: Record<PersonaKey, Persona> = {
'sites.bypass_scope',
'catalog.categories.view',
'catalog.categories.manage',
// Commercial — Repertoire clients (M1). Mappe ici sur le persona
// "tout" en attendant les vrais roles metier (bureau/compta/
// commerciale/usine) seedes par ERP-74. Pas de nouveau persona
// (regle ABSOLUE n°7). commercial.clients.view n'ajoute pas de lien
// dans la section Administration, donc expectedAdminLinks reste inchange.
'commercial.clients.view',
'commercial.clients.manage',
'commercial.clients.accounting.view',
'commercial.clients.accounting.manage',
'commercial.clients.archive',
],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
},
+3 -19
View File
@@ -84,18 +84,10 @@ final class Version20260601000000 extends AbstractMigration
$this->addSql('DROP TABLE payment_delay');
$this->addSql('DROP TABLE tva_mode');
// Retire uniquement les 4 types seedes par cette migration ET restes
// orphelins (aucune `category` ne les reference). Sans la clause
// NOT EXISTS, le DELETE casse sur la FK RESTRICT category.category_type_id
// des qu'une categorie pointe sur l'un d'eux. Symetrique du
// `ON CONFLICT (code) DO NOTHING` du up() : on ne defait que ce qu'on a
// reellement cree et qui n'est pas reutilise.
// Retire uniquement les 4 types seedes par cette migration. Les autres
// types eventuels (CRUD futur) sont preserves.
$this->addSql(<<<'SQL'
DELETE FROM category_type
WHERE code IN ('DISTRIBUTEUR', 'COURTIER', 'SECTEUR', 'AUTRE')
AND NOT EXISTS (
SELECT 1 FROM category c WHERE c.category_type_id = category_type.id
)
DELETE FROM category_type WHERE code IN ('DISTRIBUTEUR', 'COURTIER', 'SECTEUR', 'AUTRE')
SQL);
}
@@ -228,14 +220,6 @@ final class Version20260601000000 extends AbstractMigration
$this->addSql('CREATE INDEX idx_client_created_by ON client (created_by)');
$this->addSql('CREATE INDEX idx_client_updated_by ON client (updated_by)');
// Index sur les FK des referentiels comptables — coherence avec les autres
// FK deja indexees ci-dessus (Postgres n'indexe pas automatiquement les
// colonnes portant une FOREIGN KEY).
$this->addSql('CREATE INDEX idx_client_tva_mode_id ON client (tva_mode_id)');
$this->addSql('CREATE INDEX idx_client_payment_delay_id ON client (payment_delay_id)');
$this->addSql('CREATE INDEX idx_client_payment_type_id ON client (payment_type_id)');
$this->addSql('CREATE INDEX idx_client_bank_id ON client (bank_id)');
// Unicite metier partielle (Q4) : nom de societe insensible a la casse,
// parmi les non-archives ET non soft-deletes uniquement. Pas d'index
// unique sur siren ni email.
@@ -4,14 +4,11 @@ declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Doctrine\Orm\Paginator;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
@@ -32,32 +29,18 @@ final class CategoryProvider implements ProviderInterface
public function __construct(
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository')]
private readonly CategoryRepositoryInterface $repository,
private readonly Pagination $pagination,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Category|iterable|Paginator|null
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Category|iterable|null
{
$includeDeleted = $this->readIncludeDeleted($context);
if ($operation instanceof CollectionOperationInterface) {
$qb = $this->repository->createListQueryBuilder($includeDeleted);
// Echappatoire ?pagination=false : retourne la collection complete sans Paginator.
// Utile pour les drawers Role/Permission/Site/CategoryType qui alimentent un <select>.
if (!$this->pagination->isEnabled($operation, $context)) {
return $qb->getQuery()->getResult();
}
// Branche paginee standard : on applique offset/limit via Pagination,
// puis on enveloppe dans le Paginator ORM (fetchJoinCollection: true
// pour que Doctrine compte correctement avec les JOINs futurs).
$limit = $this->pagination->getLimit($operation, $context);
$page = max(1, $this->pagination->getPage($context));
$offset = ($page - 1) * $limit;
$qb->setFirstResult($offset)->setMaxResults($limit);
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true));
return $this->repository
->createListQueryBuilder($includeDeleted)
->getQuery()
->getResult()
;
}
// Get unitaire : recharger l'entite, puis appliquer le filtre soft-delete.
@@ -9,4 +9,36 @@ final class CommercialModule
public const string ID = 'commercial';
public const string LABEL = 'Commercial';
public const bool REQUIRED = false;
/**
* Liste declarative des permissions RBAC exposees par le module Commercial.
*
* Consommee par la commande `app:sync-permissions` (SyncPermissionsCommand)
* qui se charge d'upserter ces entrees dans la table `permission`, de
* reactiver les codes precedemment marques orphelins et de marquer comme
* orphelins ceux qui ont disparu du code source.
*
* La cle `module` est auto-injectee par le sync command a partir de
* `self::ID`, il est donc inutile de la repeter dans chaque entree.
*
* Convention de nommage des codes : `module.resource[.sub].action` en
* snake_case, le prefixe module devant correspondre exactement a
* `self::ID` (verifie par la commande de synchronisation).
*
* Granularite alignee sur Core/Catalog (view + manage), plus deux
* permissions dediees a l'onglet Comptabilite et a l'archivage
* (cf. spec-back M1 § 2.7).
*
* @return array<int, array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'commercial.clients.view', 'label' => 'Voir les clients'],
['code' => 'commercial.clients.manage', 'label' => 'Créer / modifier les clients (hors onglet Comptabilité)'],
['code' => 'commercial.clients.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un client'],
['code' => 'commercial.clients.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un client'],
['code' => 'commercial.clients.archive', 'label' => 'Archiver / restaurer un client'],
];
}
}
+25 -3
View File
@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineBankRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -13,10 +16,29 @@ use Symfony\Component\Serializer\Attribute\Groups;
* CIC, Credit Agricole) : referentiel statique seede par la migration M1 et
* re-seede en dev/test par CommercialReferentialFixtures.
*
* Lecture seule au M1 (HP-M2-2). Pas de Timestampable/Blamable (referentiel
* statique whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED). Le
* groupe `client:read:accounting` permet l'embarquement dans la reponse Client.
* Lecture seule au M1 (HP-M2-2) : GetCollection + Get uniquement (ERP-56),
* permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de
* Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
* `client:read:accounting` permet l'embarquement dans la reponse Client.
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('commercial.clients.view')",
normalizationContext: ['groups' => ['bank:read']],
// Tri par defaut spec M1 § 4.7 : position ASC puis label ASC.
order: ['position' => 'ASC', 'label' => 'ASC'],
// ERP-72 : pagination serveur + toggle ?pagination=false (cf. TvaMode).
paginationClientEnabled: true,
),
new Get(
security: "is_granted('commercial.clients.view')",
normalizationContext: ['groups' => ['bank:read']],
),
],
security: "is_granted('commercial.clients.view')",
)]
#[ORM\Entity(repositoryClass: DoctrineBankRepository::class)]
#[ORM\Table(name: 'bank')]
#[ORM\UniqueConstraint(name: 'uq_bank_code', columns: ['code'])]
@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrinePaymentDelayRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -13,10 +16,29 @@ use Symfony\Component\Serializer\Attribute\Groups;
* referentiel statique seede par la migration M1 et re-seede en dev/test par
* CommercialReferentialFixtures.
*
* Lecture seule au M1 (HP-M2-2). Pas de Timestampable/Blamable (referentiel
* statique whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED). Le
* groupe `client:read:accounting` permet l'embarquement dans la reponse Client.
* Lecture seule au M1 (HP-M2-2) : GetCollection + Get uniquement (ERP-56),
* permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de
* Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
* `client:read:accounting` permet l'embarquement dans la reponse Client.
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('commercial.clients.view')",
normalizationContext: ['groups' => ['payment_delay:read']],
// Tri par defaut spec M1 § 4.7 : position ASC puis label ASC.
order: ['position' => 'ASC', 'label' => 'ASC'],
// ERP-72 : pagination serveur + toggle ?pagination=false (cf. TvaMode).
paginationClientEnabled: true,
),
new Get(
security: "is_granted('commercial.clients.view')",
normalizationContext: ['groups' => ['payment_delay:read']],
),
],
security: "is_granted('commercial.clients.view')",
)]
#[ORM\Entity(repositoryClass: DoctrinePaymentDelayRepository::class)]
#[ORM\Table(name: 'payment_delay')]
#[ORM\UniqueConstraint(name: 'uq_payment_delay_code', columns: ['code'])]
@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrinePaymentTypeRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -16,10 +19,29 @@ use Symfony\Component\Serializer\Attribute\Groups;
* Le `code` porte une semantique metier : VIREMENT impose une banque (RG-1.12),
* LCR impose au moins un RIB (RG-1.13).
*
* Lecture seule au M1 (HP-M2-2). Pas de Timestampable/Blamable (referentiel
* statique whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED). Le
* groupe `client:read:accounting` permet l'embarquement dans la reponse Client.
* Lecture seule au M1 (HP-M2-2) : GetCollection + Get uniquement (ERP-56),
* permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de
* Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
* `client:read:accounting` permet l'embarquement dans la reponse Client.
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('commercial.clients.view')",
normalizationContext: ['groups' => ['payment_type:read']],
// Tri par defaut spec M1 § 4.7 : position ASC puis label ASC.
order: ['position' => 'ASC', 'label' => 'ASC'],
// ERP-72 : pagination serveur + toggle ?pagination=false (cf. TvaMode).
paginationClientEnabled: true,
),
new Get(
security: "is_granted('commercial.clients.view')",
normalizationContext: ['groups' => ['payment_type:read']],
),
],
security: "is_granted('commercial.clients.view')",
)]
#[ORM\Entity(repositoryClass: DoctrinePaymentTypeRepository::class)]
#[ORM\Table(name: 'payment_type')]
#[ORM\UniqueConstraint(name: 'uq_payment_type_code', columns: ['code'])]
@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineTvaModeRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -13,15 +16,35 @@ use Symfony\Component\Serializer\Attribute\Groups;
* referentiel statique seede par la migration M1 (Version20260601000000) et
* re-seede en dev/test par CommercialReferentialFixtures.
*
* Lecture seule au M1 : pas de POST/PATCH/DELETE (HP-M2-2). L'ApiResource
* (GetCollection + Get, tri position ASC) est branche au ticket dedie des
* referentiels lecture seule.
* Lecture seule au M1 (HP-M2-2) : seules GetCollection et Get sont exposees
* (ERP-56), sous la permission commercial.clients.view ; aucune ecriture
* declaree -> POST/PATCH/DELETE renvoient 405.
*
* Referentiel statique : pas de Timestampable/Blamable (whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le
* groupe `client:read:accounting` permet d'embarquer le mode dans la reponse
* d'un Client (onglet Comptabilite) au lieu d'un IRI.
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('commercial.clients.view')",
normalizationContext: ['groups' => ['tva_mode:read']],
// Tri par defaut spec M1 § 4.7 : position ASC puis label ASC
// (ordre des selecteurs comptables) — provider Doctrine par defaut.
order: ['position' => 'ASC', 'label' => 'ASC'],
// ERP-72 : pagination serveur sur toute collection autonome. Le
// toggle client est desactive globalement, on l'active ici pour
// permettre ?pagination=false (alimenter un <MalioSelect> entier).
paginationClientEnabled: true,
),
new Get(
security: "is_granted('commercial.clients.view')",
normalizationContext: ['groups' => ['tva_mode:read']],
),
],
security: "is_granted('commercial.clients.view')",
)]
#[ORM\Entity(repositoryClass: DoctrineTvaModeRepository::class)]
#[ORM\Table(name: 'tva_mode')]
#[ORM\UniqueConstraint(name: 'uq_tva_mode_code', columns: ['code'])]
@@ -6,7 +6,6 @@ namespace App\Module\Commercial\Infrastructure\ApiPlatform\Serializer;
use ApiPlatform\Metadata\IriConverterInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
/**
@@ -41,18 +40,7 @@ final class CategoryReferenceDenormalizer implements DenormalizerInterface
// est le comportement attendu pour une reference cassee.
$resource = $this->iriConverter->getResourceFromIri($data);
// IRI syntaxiquement valide mais pointant sur une autre ressource (ex:
// '/api/clients/5' la ou une categorie est attendue) : on refuse
// explicitement plutot que de retourner null silencieusement, ce qui
// perdrait la reference sans erreur. UnexpectedValueException -> 400.
if (!$resource instanceof CategoryInterface) {
throw new UnexpectedValueException(sprintf(
'L\'IRI "%s" ne référence pas une catégorie.',
$data,
));
}
return $resource;
return $resource instanceof CategoryInterface ? $resource : null;
}
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
@@ -15,7 +15,6 @@ use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Security\BusinessRoles;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use JsonException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -85,7 +84,6 @@ final class ClientProcessor implements ProcessorInterface
private readonly ClientInformationCompletenessValidator $informationValidator,
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
@@ -94,17 +92,17 @@ final class ClientProcessor implements ProcessorInterface
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
$writableKeys = $this->writablePayloadKeys();
$payloadKeys = $this->payloadKeys();
$isArchiveRequest = $this->guardArchive($data, $writableKeys);
$this->guardAccounting($data);
$isArchiveRequest = $this->guardArchive($data, $payloadKeys);
$this->guardAccounting($payloadKeys);
$this->normalize($data);
$this->validateMainContact($data);
$this->validateDistributorBroker($data);
$this->validateAccountingConsistency($data);
$this->validateInformationCompleteness($data, $writableKeys);
$this->validateInformationCompleteness($data, $payloadKeys);
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
@@ -128,28 +126,15 @@ final class ClientProcessor implements ProcessorInterface
}
/**
* RG-1.22 / RG-1.23 : si le payload bascule reellement isArchived, exige la
* permission archive (403), interdit toute autre modification (422) et
* pose/retire archivedAt. Retourne true si la requete est une requete
* d'archivage.
* RG-1.22 / RG-1.23 : si le payload porte isArchived, exige la permission
* archive (403), interdit toute autre modification (422) et pose/retire
* archivedAt. Retourne true si la requete est une requete d'archivage.
*
* Le gating est restreint a la mise a jour d'un client existant ET au seul
* cas ou isArchived change vraiment : un POST (entite non encore geree par
* l'ORM) ou un PATCH « representation complete » renvoyant isArchived
* inchange ne doit declencher ni 403 ni 422 parasite.
*
* @param list<string> $writableKeys cles ecrivables du payload (hors @* et champs inconnus)
* @param list<string> $payloadKeys
*/
private function guardArchive(Client $data, array $writableKeys): bool
private function guardArchive(Client $data, array $payloadKeys): bool
{
// POST / entite non geree : l'archivage est une action de mise a jour.
if (!$this->em->contains($data)) {
return false;
}
// isArchived inchange par rapport a l'etat persiste : pas une requete
// d'archivage (cas du PATCH representation complete).
if (!$this->fieldChanged($data, 'isArchived', $data->isArchived())) {
if (!in_array(self::ARCHIVE_FIELD, $payloadKeys, true)) {
return false;
}
@@ -161,8 +146,8 @@ final class ClientProcessor implements ProcessorInterface
));
}
// RG-1.22 : une requete d'archivage ne modifie aucun autre champ ecrivable.
if ([] !== array_diff($writableKeys, [self::ARCHIVE_FIELD])) {
// RG-1.22 : une requete d'archivage ne modifie aucun autre champ.
if ([] !== array_diff($payloadKeys, [self::ARCHIVE_FIELD])) {
throw new UnprocessableEntityHttpException(
'Une requête d\'archivage ne peut modifier aucun autre champ que "isArchived".',
);
@@ -175,88 +160,29 @@ final class ClientProcessor implements ProcessorInterface
}
/**
* RG-1.28 : la modification effective d'un champ comptable exige
* accounting.manage, sinon 403 sur l'ensemble du payload (mode strict, pas
* de filtrage silencieux). On ne gate que si un champ change reellement par
* rapport a l'etat persiste : un POST/PATCH renvoyant des champs comptables
* inchanges (ou null en creation) ne declenche pas de 403 parasite. Le
* message precise le premier champ fautif.
* RG-1.28 : un champ comptable dans le payload exige accounting.manage,
* sinon 403 sur l'ensemble du payload (mode strict, pas de filtrage
* silencieux). Le message precise le premier champ fautif.
*
* @param list<string> $payloadKeys
*/
private function guardAccounting(Client $data): void
private function guardAccounting(array $payloadKeys): void
{
$changed = $this->changedAccountingFields($data);
$touched = array_values(array_intersect($payloadKeys, self::ACCOUNTING_FIELDS));
if ([] === $changed) {
if ([] === $touched) {
return;
}
if (!$this->security->isGranted(self::PERM_ACCOUNTING_MANAGE)) {
throw new AccessDeniedHttpException(sprintf(
'Le champ "%s" requiert la permission "%s".',
$changed[0],
$touched[0],
self::PERM_ACCOUNTING_MANAGE,
));
}
}
/**
* Champs comptables dont la valeur courante differe de l'etat persiste. Les
* relations (tvaMode, paymentDelay, paymentType, bank) sont comparees par
* identite d'objet : l'identity map Doctrine renvoie la meme instance tant
* que la reference est inchangee.
*
* @return list<string>
*/
private function changedAccountingFields(Client $data): array
{
$changed = [];
foreach (self::ACCOUNTING_FIELDS as $field) {
$newValue = match ($field) {
'siren' => $data->getSiren(),
'accountNumber' => $data->getAccountNumber(),
'tvaMode' => $data->getTvaMode(),
'nTva' => $data->getNTva(),
'paymentDelay' => $data->getPaymentDelay(),
'paymentType' => $data->getPaymentType(),
'bank' => $data->getBank(),
};
if ($this->fieldChanged($data, $field, $newValue)) {
$changed[] = $field;
}
}
return $changed;
}
/**
* Vrai si la valeur courante d'un champ differe de l'etat persiste. Pour une
* entite non geree (creation/POST), l'etat persiste est vide : toute valeur
* non-null est alors un changement.
*/
private function fieldChanged(Client $data, string $field, mixed $newValue): bool
{
$original = $this->originalData($data);
return $newValue !== ($original[$field] ?? null);
}
/**
* Snapshot des valeurs persistees de l'entite (telles que chargees, avant
* application du payload). Vide pour une entite non geree (POST).
*
* @return array<string, mixed>
*/
private function originalData(Client $data): array
{
if (!$this->em->contains($data)) {
return [];
}
return $this->em->getUnitOfWork()->getOriginalEntityData($data);
}
/**
* Normalisation serveur (RG-1.18 a 1.21). Les setters non-nullables
* (companyName, email, phonePrimary) ne sont touches que si une valeur est
@@ -391,32 +317,11 @@ final class ClientProcessor implements ProcessorInterface
&& $user->hasBusinessRole(BusinessRoles::COMMERCIALE);
}
/**
* Cles ecrivables effectivement presentes dans le payload : on retire les
* cles JSON-LD (@id, @context, @var...) et tout champ non rattache a un
* groupe d'ecriture connu. C'est la base du 422 d'archivage (RG-1.22) et du
* declenchement conditionnel de RG-1.04 sans elles, un PATCH
* « representation complete » porteur de @id ferait croire a une
* modification multi-onglets.
*
* @return list<string>
*/
private function writablePayloadKeys(): array
{
$writable = array_merge(
self::MAIN_FIELDS,
self::INFORMATION_FIELDS,
self::ACCOUNTING_FIELDS,
[self::ARCHIVE_FIELD],
);
return array_values(array_intersect($this->payloadKeys(), $writable));
}
/**
* Cles de premier niveau effectivement envoyees par le client (payload JSON
* brut), filtrage compris. Pour un PATCH merge-patch+json, ce sont les seuls
* champs modifies.
* brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies ;
* c'est ce qui permet le gating par onglet (RG-1.22 / RG-1.28) et le
* declenchement conditionnel de RG-1.04.
*
* @return list<string>
*/
@@ -11,7 +11,6 @@ use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -46,7 +45,6 @@ final class ClientProvider implements ProviderInterface
#[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository')]
private readonly ClientRepositoryInterface $repository,
private readonly Pagination $pagination,
private readonly EntityManagerInterface $em,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Client|iterable|Paginator|null
@@ -75,7 +73,7 @@ final class ClientProvider implements ProviderInterface
// Echappatoire ?pagination=false : collection complete sans Paginator
// (cf. convention ERP-72 — utile pour un <select> cote front).
if (!$this->pagination->isEnabled($operation, $context)) {
// @var list<Client> $result
/** @var list<Client> $result */
return $qb->getQuery()->getResult();
}
@@ -146,13 +144,8 @@ final class ClientProvider implements ProviderInterface
return;
}
// Sous-requete construite via l'EntityManager (et non
// $repository->createQueryBuilder()) : createQueryBuilder() n'est pas
// declaree sur ClientRepositoryInterface, l'appeler exposerait un detail
// d'implementation Doctrine hors du contrat (fuite d'abstraction).
$sub = $this->em->createQueryBuilder()
$sub = $this->repository->createQueryBuilder('c2')
->select('c2.id')
->from(Client::class, 'c2')
->join('c2.categories', 'cat2')
->join('cat2.categoryType', 'ct2')
->where('ct2.code = :categoryType')
@@ -29,16 +29,18 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\AuditLogProvider;
* ?performed_at[after]=2026-04-01T00:00:00Z
* ?performed_at[before]=2026-04-30T23:59:59Z
*
* La pagination herite du standard global (10 items / page, max 50, cf.
* `config/packages/api_platform.yaml`). Elle est materialisee par le
* DbalPaginator du provider qui implemente PaginatorInterface API Platform
* genere automatiquement hydra:view sans construction manuelle.
* La pagination est assuree par le provider via DbalPaginator (implementant
* ApiPlatform\State\Pagination\PaginatorInterface), ce qui genere
* automatiquement hydra:view aucune construction manuelle.
*/
#[ApiResource(
shortName: 'AuditLog',
operations: [
new GetCollection(
uriTemplate: '/audit-logs',
paginationItemsPerPage: 30,
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 50,
security: "is_granted('core.audit_log.view')",
provider: AuditLogProvider::class,
),
@@ -68,13 +68,6 @@ final readonly class AuditLogProvider implements ProviderInterface
*/
private function provideCollection(Operation $operation, array $context): DbalPaginator
{
// Contrairement aux ressources ORM (cf. CategoryProvider), ce provider
// ne gere PAS l'echappatoire `?pagination=false` : la pagination y est
// toujours forcee. `audit_log` est une table append-only a croissance
// infinie — la dumper entierement saturerait memoire/reseau et n'a aucun
// usage front (pas de <select> alimente par l'audit). Le flag global
// `pagination_client_enabled: true` reste donc volontairement inerte ici.
//
// `page` brut peut etre <= 0 (parametre client) → OFFSET negatif → 500 PG
// (`SQLSTATE[22023] OFFSET must not be negative`). API Platform clampe
// `itemsPerPage` au max de la resource mais pas `page` ; on impose un
@@ -186,6 +186,15 @@ final class SeedE2ECommand extends Command
'sites.bypass_scope',
'catalog.categories.view',
'catalog.categories.manage',
// Commercial — Repertoire clients (M1). Mappe ici sur le
// persona "tout" en attendant les vrais roles metier
// (bureau/compta/commerciale/usine) seedes par ERP-74.
// Miroir de frontend/tests/e2e/_fixtures/personas.ts.
'commercial.clients.view',
'commercial.clients.manage',
'commercial.clients.accounting.view',
'commercial.clients.accounting.manage',
'commercial.clients.archive',
],
],
[
-256
View File
@@ -1,256 +0,0 @@
{
"api-platform/symfony": {
"version": "4.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "4.0",
"ref": "e9952e9f393c2d048f10a78f272cd35e807d972b"
},
"files": [
"config/packages/api_platform.yaml",
"config/routes/api_platform.yaml",
"src/ApiResource/.gitignore"
]
},
"doctrine/deprecations": {
"version": "1.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "fdd756167454623e21f1d769c5b814b243782a67"
}
},
"doctrine/doctrine-bundle": {
"version": "3.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.0",
"ref": "d39a3bd844edfe90c20ae520b804a3bf4f82b4ad"
},
"files": [
"config/packages/doctrine.yaml",
"src/Entity/.gitignore",
"src/Repository/.gitignore"
]
},
"doctrine/doctrine-fixtures-bundle": {
"version": "4.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.0",
"ref": "1f5514cfa15b947298df4d771e694e578d4c204d"
},
"files": [
"src/DataFixtures/AppFixtures.php"
]
},
"doctrine/doctrine-migrations-bundle": {
"version": "4.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.1",
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
},
"files": [
"config/packages/doctrine_migrations.yaml",
"migrations/.gitignore"
]
},
"friendsofphp/php-cs-fixer": {
"version": "3.94",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.39",
"ref": "97aaf9026490db73b86c23d49e5774bc89d2b232"
},
"files": [
".php-cs-fixer.dist.php"
]
},
"lexik/jwt-authentication-bundle": {
"version": "3.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.5",
"ref": "e9481b233a11ef7e15fe055a2b21fd3ac1aa2bb7"
},
"files": [
"config/packages/lexik_jwt_authentication.yaml"
]
},
"nelmio/cors-bundle": {
"version": "2.6",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.5",
"ref": "6bea22e6c564fba3a1391615cada1437d0bde39c"
},
"files": [
"config/packages/nelmio_cors.yaml"
]
},
"nyholm/psr7": {
"version": "1.8",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "4a8c0345442dcca1d8a2c65633dcf0285dd5a5a2"
},
"files": [
"config/packages/nyholm_psr7.yaml"
]
},
"phpunit/phpunit": {
"version": "13.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "11.1",
"ref": "ca0bc067abfb40a8de1b2561b96cbfc2b833c314"
},
"files": [
".env.test",
"phpunit.dist.xml",
"tests/bootstrap.php",
"bin/phpunit"
]
},
"symfony/console": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.3",
"ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
},
"files": [
"bin/console"
]
},
"symfony/flex": {
"version": "2.10",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.4",
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
},
"files": [
".env",
".env.dev"
]
},
"symfony/framework-bundle": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.4",
"ref": "d5dcd308c8becd725c9d8b91e31aab1ff0bbc30b"
},
"files": [
"config/packages/cache.yaml",
"config/packages/framework.yaml",
"config/preload.php",
"config/routes/framework.yaml",
"config/services.yaml",
"public/index.php",
"src/Controller/.gitignore",
"src/Kernel.php",
".editorconfig"
]
},
"symfony/monolog-bundle": {
"version": "4.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.7",
"ref": "1b9efb10c54cb51c713a9391c9300ff8bceda459"
},
"files": [
"config/packages/monolog.yaml"
]
},
"symfony/property-info": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
},
"files": [
"config/packages/property_info.yaml"
]
},
"symfony/routing": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.4",
"ref": "bc94c4fd86f393f3ab3947c18b830ea343e51ded"
},
"files": [
"config/packages/routing.yaml",
"config/routes.yaml"
]
},
"symfony/security-bundle": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.4",
"ref": "c42fee7802181cdd50f61b8622715829f5d2335c"
},
"files": [
"config/packages/security.yaml",
"config/routes/security.yaml"
]
},
"symfony/twig-bundle": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "f250159ebe99153d0c640a3e7742876fc7453f2c"
},
"files": [
"config/packages/twig.yaml",
"templates/base.html.twig"
]
},
"symfony/uid": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
}
},
"symfony/validator": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd"
},
"files": [
"config/packages/validator.yaml"
]
}
}
@@ -1,126 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Architecture;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Symfony\Component\Finder\Finder;
/**
* Garde-fou architecture : toute operation `GetCollection` exposee via API Platform
* doit avoir la pagination activee (ou laisser la valeur par defaut, qui est
* activee globalement dans `config/packages/api_platform.yaml`).
*
* Interdit : `new GetCollection(paginationEnabled: false)` sans exception documentee.
*
* Raison : une collection non paginee peut retourner des milliers de lignes et
* saturer la memoire du serveur, le reseau et le navigateur. La pagination est la
* seule protection fiable contre ce risque sur un CRM a donnees croissantes.
*
* Quand ajouter une entree dans `EXCLUDED` :
* - La collection est structurellement bornee (referentiel statique, < 100 items,
* jamais alimente par des utilisateurs) ET la suppression de la pagination est
* documentee avec une justification metier explicite.
* - Format obligatoire : `FQCN => 'justification + reference ticket/spec'`
*
* @internal
*/
final class CollectionsArePaginatedTest extends TestCase
{
/**
* Resources API Platform dont un `GetCollection` peut desactiver la pagination.
*
* Laisser vide au demarrage. Pour ajouter une exception :
* 'App\Module\Foo\Infrastructure\ApiPlatform\Resource\BarResource'
* => 'Referentiel statique < 50 items (types de contrat). Cf. ERP-XX.',
*
* @var array<class-string, string>
*/
private const EXCLUDED = [];
public function testAllGetCollectionOperationsHavePaginationEnabled(): void
{
$finder = new Finder()
->files()
->in(__DIR__.'/../../src')
->name('*.php')
->contains('#[ApiResource')
;
// Garde : si le scan ne trouve rien, le chemin est casse — le test
// deviendrait un faux positif vert. On verifie qu'il a du grain a moudre.
self::assertNotEmpty(
iterator_to_array($finder),
'Aucun fichier #[ApiResource] trouve sous src/ : chemin invalide ou codebase vide.',
);
foreach ($finder as $file) {
$fqcn = $this->extractFqcn($file->getRealPath());
if (null === $fqcn || !class_exists($fqcn)) {
continue;
}
$reflection = new ReflectionClass($fqcn);
$apiResourceAttributes = $reflection->getAttributes(ApiResource::class);
if ([] === $apiResourceAttributes) {
continue;
}
foreach ($apiResourceAttributes as $attribute) {
/** @var ApiResource $apiResource */
$apiResource = $attribute->newInstance();
$operations = $apiResource->getOperations()?->getIterator() ?? [];
foreach ($operations as $operation) {
if (!$operation instanceof GetCollection) {
continue;
}
if (false !== $operation->getPaginationEnabled()) {
continue;
}
// La pagination est explicitement desactivee : verifier
// que la resource est dans la whitelist EXCLUDED.
self::assertArrayHasKey(
$fqcn,
self::EXCLUDED,
sprintf(
"La resource %s desactive la pagination sur une operation GetCollection.\n"
."Regle : toute collection API Platform doit etre paginee (cf. .claude/rules/backend.md).\n"
."Si cette collection est structurellement bornee et que la desactivation est justifiee,\n"
.'ajouter une entree dans CollectionsArePaginatedTest::EXCLUDED avec une justification.',
$fqcn,
),
);
}
}
}
}
/**
* Extrait le FQCN (namespace + classe) d'un fichier PHP par lecture du
* source, sans charger le fichier.
*/
private function extractFqcn(string $path): ?string
{
$source = file_get_contents($path);
if (false === $source) {
return null;
}
if (
1 !== preg_match('/^namespace\s+([^;]+);/m', $source, $nsMatch)
|| 1 !== preg_match('/^(?:final\s+|abstract\s+|readonly\s+)*class\s+(\w+)/m', $source, $classMatch)
) {
return null;
}
return trim($nsMatch[1]).'\\'.$classMatch[1];
}
}
@@ -29,7 +29,7 @@ final class CategoryListTest extends AbstractCatalogApiTestCase
);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/categories?pagination=false');
$response = $client->request('GET', '/api/categories');
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&pagination=false');
$response = $client->request('GET', '/api/categories?includeDeleted=true');
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?pagination=false');
$response = $client->request('GET', '/api/categories');
self::assertSame(200, $response->getStatusCode());
$names = array_values(array_filter(
@@ -1,171 +0,0 @@
<?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).',
);
}
}
@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\TvaMode;
use PHPUnit\Framework\Attributes\DataProvider;
/**
* Tests fonctionnels des 4 referentiels comptables lecture seule (ERP-56) :
* tva_mode, payment_delay, payment_type, bank. Cf. spec-back M1 § 4.7.
*
* Couvre les criteres d'acceptation du ticket :
* - les 4 GetCollection repondent 200 avec le seed (CommercialReferentialFixtures) ;
* - tri par defaut position ASC puis label ASC ;
* - POST / PATCH / DELETE -> 405 (aucune operation d'ecriture declaree) ;
* - user authentifie sans commercial.clients.view -> 403 ;
* - anonyme -> 401 ;
* - pagination serveur active (ERP-72) + echappatoire ?pagination=false.
*
* @internal
*/
final class ReferentialApiTest extends AbstractCommercialApiTestCase
{
private const string LD = 'application/ld+json';
/**
* Endpoint => codes attendus dans le seed (sous-ensemble verifie present).
*
* @var array<string, list<string>>
*/
private const SEED = [
'/api/tva_modes' => ['FRANCE_VENTES', 'EXPORT_VENTES', 'INTRACOM_VENTES'],
'/api/payment_delays' => ['J15', 'J30', 'A_RECEPTION'],
'/api/payment_types' => ['VIREMENT', 'LCR', 'NON_SOUMISE', 'CHEQUE'],
'/api/banks' => ['SG', 'CIC', 'CA'],
];
/**
* Purge les eventuelles lignes de test inserees dans tva_mode (tri label).
* Les codes du seed ne commencent jamais par TEST_, donc cette purge ne
* touche pas les referentiels metier.
*/
protected function tearDown(): void
{
$this->getEm()
->createQuery('DELETE FROM '.TvaMode::class.' t WHERE t.code LIKE :prefix')
->setParameter('prefix', 'TEST\_%')
->execute()
;
parent::tearDown();
}
/**
* Critere : chaque endpoint repond 200 et expose le seed (id, code, label,
* position) sous le groupe de lecture du referentiel.
*
* @param list<string> $expectedCodes
*/
#[DataProvider('endpointProvider')]
public function testCollectionReturns200WithSeed(string $endpoint, array $expectedCodes): void
{
$client = $this->createAdminClient();
$response = $client->request('GET', $endpoint.'?pagination=false', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
$members = $response->toArray()['member'];
$codes = array_map(static fn (array $m): string => $m['code'], $members);
foreach ($expectedCodes as $expected) {
self::assertContains($expected, $codes, $endpoint.' doit exposer le code seede '.$expected);
}
// Le DTO de lecture expose bien id / code / label / position.
$first = $members[0];
self::assertArrayHasKey('id', $first);
self::assertArrayHasKey('label', $first);
self::assertArrayHasKey('position', $first);
}
/**
* Critere : GET item repond 200 (recupere via un id reel de la collection).
*/
public function testGetItemReturns200(): void
{
$client = $this->createAdminClient();
$first = $client->request('GET', '/api/tva_modes?pagination=false', ['headers' => ['Accept' => self::LD]])
->toArray()['member'][0]
;
$client->request('GET', '/api/tva_modes/'.$first['id'], ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
}
/**
* Critere : tri par defaut position ASC. Le seed tva_mode est ordonne
* FRANCE_VENTES (10) < EXPORT_VENTES (20) < INTRACOM_VENTES (30).
*/
public function testDefaultSortByPositionAsc(): void
{
$client = $this->createAdminClient();
$codes = array_map(
static fn (array $m): string => $m['code'],
$client->request('GET', '/api/tva_modes?pagination=false', ['headers' => ['Accept' => self::LD]])->toArray()['member'],
);
$expectedOrder = ['FRANCE_VENTES', 'EXPORT_VENTES', 'INTRACOM_VENTES'];
$filtered = array_values(array_intersect($codes, $expectedOrder));
self::assertSame(
$expectedOrder,
$filtered,
'Les modes de TVA doivent etre tries position ASC (§ 4.7).',
);
}
/**
* Critere : a position egale, tri label ASC (departage). On insere deux
* lignes de test partageant la meme position, labels volontairement dans le
* desordre alphabetique ; le tearDown les purge ensuite.
*/
public function testTieBreakSortByLabelAsc(): void
{
$em = $this->getEm();
foreach ([['TEST_TIE_Z', 'ZZZ Tie'], ['TEST_TIE_A', 'AAA Tie']] as [$code, $label]) {
$mode = new TvaMode();
$mode->setCode($code);
$mode->setLabel($label);
$mode->setPosition(9000);
$em->persist($mode);
}
$em->flush();
$client = $this->createAdminClient();
$codes = array_map(
static fn (array $m): string => $m['code'],
$client->request('GET', '/api/tva_modes?pagination=false', ['headers' => ['Accept' => self::LD]])->toArray()['member'],
);
$tie = array_values(array_intersect($codes, ['TEST_TIE_A', 'TEST_TIE_Z']));
self::assertSame(
['TEST_TIE_A', 'TEST_TIE_Z'],
$tie,
'A position egale, le tri secondaire doit etre label ASC (§ 4.7).',
);
}
/**
* Critere ERP-72 : la collection est paginee par defaut. Preuve : une page
* au-dela des donnees est vide (un provider non pagine ignorerait `page`).
* Avec ?pagination=false, le parametre `page` est ignore -> tout revient.
*/
public function testPaginationActiveAndClientToggle(): void
{
$client = $this->createAdminClient();
// Page 2 d'un referentiel tenant sur une page : vide -> pagination active.
$page2 = $client->request('GET', '/api/tva_modes?page=2', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('totalItems', $page2);
self::assertSame([], $page2['member'], 'La page 2 doit etre vide : pagination serveur active.');
// ?pagination=false : `page` ignore, le seed complet est renvoye.
$all = $client->request('GET', '/api/tva_modes?pagination=false&page=2', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertNotEmpty($all['member'], '?pagination=false doit desactiver la pagination (page ignoree).');
}
/**
* Critere : aucune operation d'ecriture n'est declaree -> POST sur la
* collection renvoie 405 Method Not Allowed sur les 4 referentiels.
*
* @param list<string> $expectedCodes
*/
#[DataProvider('endpointProvider')]
public function testPostReturns405(string $endpoint, array $expectedCodes): void
{
$client = $this->createAdminClient();
$client->request('POST', $endpoint, [
'headers' => ['Content-Type' => self::LD],
'json' => ['code' => 'X', 'label' => 'X', 'position' => 1],
]);
self::assertResponseStatusCodeSame(405);
}
/**
* Critere : PATCH et DELETE sur un item renvoient 405 (lecture seule).
*/
public function testPatchAndDeleteReturn405(): void
{
$client = $this->createAdminClient();
$first = $client->request('GET', '/api/tva_modes?pagination=false', ['headers' => ['Accept' => self::LD]])
->toArray()['member'][0]
;
$iri = '/api/tva_modes/'.$first['id'];
$client->request('PATCH', $iri, [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['label' => 'Renamed'],
]);
self::assertResponseStatusCodeSame(405);
$client->request('DELETE', $iri);
self::assertResponseStatusCodeSame(405);
}
/**
* Critere : un utilisateur authentifie sans la permission
* commercial.clients.view obtient 403 sur les 4 endpoints.
*
* @param list<string> $expectedCodes
*/
#[DataProvider('endpointProvider')]
public function testForbiddenWithoutPermission(string $endpoint, array $expectedCodes): void
{
// User jetable portant une permission SANS rapport (existe en base mais
// ne donne pas commercial.clients.view).
$creds = $this->createUserWithPermission('core.users.view');
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$client->request('GET', $endpoint, ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403);
}
/**
* Critere : un appel anonyme (non authentifie) obtient 401 sur les 4
* endpoints.
*
* @param list<string> $expectedCodes
*/
#[DataProvider('endpointProvider')]
public function testUnauthorizedWhenAnonymous(string $endpoint, array $expectedCodes): void
{
$client = self::createClient();
$client->request('GET', $endpoint, ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(401);
}
/**
* @return iterable<string, array{string, list<string>}>
*/
public static function endpointProvider(): iterable
{
foreach (self::SEED as $endpoint => $codes) {
yield $endpoint => [$endpoint, $codes];
}
}
}
@@ -1,62 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Unit;
use ApiPlatform\Metadata\IriConverterInterface;
use App\Module\Commercial\Infrastructure\ApiPlatform\Serializer\CategoryReferenceDenormalizer;
use App\Shared\Domain\Contract\CategoryInterface;
use PHPUnit\Framework\TestCase;
use stdClass;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
/**
* Tests unitaires du CategoryReferenceDenormalizer : resolution d'un IRI vers
* une Category concrete, et rejet explicite d'un IRI pointant sur une autre
* ressource (au lieu d'un null silencieux qui perdrait la reference).
*
* @internal
*/
final class CategoryReferenceDenormalizerTest extends TestCase
{
public function testResolvesCategoryIri(): void
{
$category = $this->createStub(CategoryInterface::class);
$iriConverter = $this->createMock(IriConverterInterface::class);
$iriConverter->method('getResourceFromIri')->willReturn($category);
$denormalizer = new CategoryReferenceDenormalizer($iriConverter);
self::assertSame(
$category,
$denormalizer->denormalize('/api/categories/1', CategoryInterface::class),
);
}
public function testRejectsIriOfWrongType(): void
{
// Bug review ERP-55 : un IRI syntaxiquement valide mais pointant sur une
// autre ressource (ex: /api/clients/5) doit lever une exception au lieu
// d'etre silencieusement ignore.
$iriConverter = $this->createMock(IriConverterInterface::class);
$iriConverter->method('getResourceFromIri')->willReturn(new stdClass());
$denormalizer = new CategoryReferenceDenormalizer($iriConverter);
$this->expectException(UnexpectedValueException::class);
$denormalizer->denormalize('/api/clients/5', CategoryInterface::class);
}
public function testReturnsNullForEmptyData(): void
{
// Valeur vide deleguee par l'ArrayDenormalizer : aucun appel a
// l'IriConverter, retour null.
$iriConverter = $this->createMock(IriConverterInterface::class);
$iriConverter->expects(self::never())->method('getResourceFromIri');
$denormalizer = new CategoryReferenceDenormalizer($iriConverter);
self::assertNull($denormalizer->denormalize('', CategoryInterface::class));
}
}
@@ -16,8 +16,6 @@ use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientProcessor;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Security\BusinessRoles;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\UnitOfWork;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
@@ -38,126 +36,45 @@ final class ClientProcessorTest extends TestCase
{
public function testAccountingFieldWithoutPermissionIsForbidden(): void
{
// RG-1.28 : la modification effective d'un champ comptable sans
// accounting.manage -> 403. En creation (POST), positionner siren est un
// changement vs l'etat persiste vide.
$client = $this->minimalClient();
$client->setSiren('123456789');
// RG-1.28 : un champ comptable sans accounting.manage -> 403.
$processor = $this->makeProcessor(granted: [], payload: ['siren' => '123456789']);
$this->expectException(AccessDeniedHttpException::class);
$processor->process($client, $this->operation());
$processor->process($this->minimalClient(), $this->operation());
}
public function testStrictMixWithAccountingFieldIsForbidden(): void
{
// RG-1.28 : payload mixant main + accounting sans la permission -> 403
// sur l'ensemble (pas de filtrage silencieux).
$client = $this->minimalClient();
$client->setCompanyName('X');
$client->setSiren('123456789');
$processor = $this->makeProcessor(
granted: [],
payload: ['companyName' => 'X', 'siren' => '123456789'],
);
$this->expectException(AccessDeniedHttpException::class);
$processor->process($client, $this->operation());
$processor->process($this->minimalClient(), $this->operation());
}
public function testArchiveWithoutPermissionIsForbidden(): void
{
// RG-1.22 : basculer isArchived sans la permission archive -> 403.
$client = $this->minimalClient();
$client->setIsArchived(true);
$processor = $this->makeProcessor(
granted: [],
payload: ['isArchived' => true],
managed: true,
originalData: ['isArchived' => false],
);
// RG-1.22 : isArchived sans la permission archive -> 403.
$processor = $this->makeProcessor(granted: [], payload: ['isArchived' => true]);
$this->expectException(AccessDeniedHttpException::class);
$processor->process($client, $this->operation());
$processor->process($this->minimalClient(), $this->operation());
}
public function testArchiveWithOtherFieldIsUnprocessable(): void
{
// RG-1.22 : une requete d'archivage ne modifie aucun autre champ.
$client = $this->minimalClient();
$client->setIsArchived(true);
$client->setCompanyName('X');
$processor = $this->makeProcessor(
granted: ['commercial.clients.archive'],
payload: ['isArchived' => true, 'companyName' => 'X'],
managed: true,
originalData: ['isArchived' => false],
);
$this->expectException(UnprocessableEntityHttpException::class);
$processor->process($client, $this->operation());
}
public function testPostWithIsArchivedFalseIsNotGated(): void
{
// Bug review ERP-55 : un POST renvoyant isArchived:false (valeur par
// defaut) ne doit declencher ni 403 (archive) ni 422, meme sans
// permission. L'entite n'est pas encore geree par l'ORM.
$client = $this->minimalClient(); // isArchived = false par defaut
$processor = $this->makeProcessor(
granted: [],
payload: ['companyName' => 'Test Co', 'isArchived' => false],
managed: false,
);
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
}
public function testFullRepresentationPatchWithUnchangedArchiveIsNotGated(): void
{
// Bug review ERP-55 : un PATCH « representation complete » renvoyant
// isArchived inchange + des cles JSON-LD (@id, @context) ne doit pas etre
// gate (ni 403 archive ni 422), meme sans permission.
$client = $this->minimalClient(); // isArchived = false (inchange)
$processor = $this->makeProcessor(
granted: [],
payload: [
'@id' => '/api/clients/1',
'@context' => '/api/contexts/Client',
'companyName' => 'Test Co',
'isArchived' => false,
],
managed: true,
originalData: ['isArchived' => false],
);
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
}
public function testUnchangedAccountingFieldOnPatchIsNotGated(): void
{
// Bug review ERP-55 : renvoyer un champ comptable a sa valeur persistee
// (PATCH representation complete) ne change rien -> pas d'exigence
// accounting.manage.
$client = $this->minimalClient();
$client->setSiren('123456789'); // identique a l'etat persiste
$processor = $this->makeProcessor(
granted: [],
payload: ['companyName' => 'Test Co', 'siren' => '123456789'],
managed: true,
// getOriginalEntityData renvoie tous les champs mappes d'une entite
// geree : isArchived (non-null) y figure toujours.
originalData: ['siren' => '123456789', 'isArchived' => false],
);
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
$processor->process($this->minimalClient(), $this->operation());
}
public function testVirementWithoutBankIsUnprocessable(): void
@@ -253,18 +170,11 @@ final class ClientProcessorTest extends TestCase
}
/**
* @param list<string> $granted Permissions accordees a l'utilisateur courant
* @param array<string, mixed> $payload Corps JSON simule de la requete
* @param bool $managed true = entite geree par l'ORM (PATCH), false = creation (POST)
* @param array<string, mixed> $originalData Etat persiste simule (getOriginalEntityData) pour la detection de changement
* @param list<string> $granted Permissions accordees a l'utilisateur courant
* @param array<string, mixed> $payload Corps JSON simule de la requete
*/
private function makeProcessor(
array $granted,
array $payload,
?UserInterface $user = null,
bool $managed = false,
array $originalData = [],
): ClientProcessor {
private function makeProcessor(array $granted, array $payload, ?UserInterface $user = null): ClientProcessor
{
$persist = new class implements ProcessorInterface {
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
@@ -281,23 +191,12 @@ final class ClientProcessorTest extends TestCase
$requestStack = new RequestStack();
$requestStack->push(new Request([], [], [], [], [], [], json_encode($payload, JSON_THROW_ON_ERROR)));
// EntityManager stub : contains() distingue creation (POST) et mise a
// jour (PATCH) ; getOriginalEntityData() fournit l'etat persiste compare
// par le gating (RG-1.22 / RG-1.28).
$uow = $this->createMock(UnitOfWork::class);
$uow->method('getOriginalEntityData')->willReturn($originalData);
$em = $this->createMock(EntityManagerInterface::class);
$em->method('contains')->willReturn($managed);
$em->method('getUnitOfWork')->willReturn($uow);
return new ClientProcessor(
$persist,
new ClientFieldNormalizer(),
new ClientInformationCompletenessValidator(),
$security,
$requestStack,
$em,
);
}
@@ -1,71 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Core\Api;
/**
* Regression test de pagination sur GET /api/audit-logs (ERP-72).
*
* Avant ce ticket, `paginationItemsPerPage` etait fixe a 30 dans
* AuditLogResource. Apres migration vers les defaults globaux (10/50),
* ce fichier verrouille le nouveau contrat :
* - la reponse est paginee (max 10 items par page par defaut) ;
* - un itemsPerPage excessif est plafonne a 50.
*
* Pas de seed : la table audit_log contient deja des lignes issues des
* fixtures / autres tests. Les assertions utilisent des inegalites pour
* rester robustes quelle que soit la quantite exacte de donnees presentes.
*
* @internal
*/
final class AuditLogPaginationRegressionTest extends AbstractApiTestCase
{
/**
* La collection /api/audit-logs doit etre paginee avec les defaults globaux :
* - `member`, `totalItems`, `view` presentes dans la reponse JSON-LD ;
* - au plus 10 items par page (nouveau defaut, etait 30 avant ce ticket).
*/
public function testAuditLogCollectionStillPaginated(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/audit-logs');
self::assertSame(200, $response->getStatusCode());
$data = $response->toArray();
self::assertArrayHasKey('totalItems', $data, 'La collection audit-logs doit exposer totalItems.');
self::assertArrayHasKey('view', $data, 'La collection audit-logs doit exposer view (pagination active).');
self::assertIsArray($data['member'], 'member doit etre un tableau.');
// Le nouveau defaut global est 10 (etait 30 dans AuditLogResource avant ERP-72).
self::assertLessThanOrEqual(
10,
count($data['member']),
'La page par defaut ne doit pas depasser 10 items (default global ERP-72).',
);
}
/**
* Un itemsPerPage excessif (99999) doit etre plafonne au maximum global (50).
* Teste la regression specifique du paginator DBAL custom (DbalPaginator) qui
* pourrait ignorer la limite si la logique de cap n'est pas appliquee cote provider.
*/
public function testAuditLogItemsPerPageCappedAt50(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/audit-logs?itemsPerPage=99999');
self::assertSame(200, $response->getStatusCode());
$data = $response->toArray();
self::assertIsArray($data['member'], 'member doit etre un tableau.');
// Le cap global est 50 : jamais plus d'items par page que le maximum.
self::assertLessThanOrEqual(
50,
count($data['member']),
'itemsPerPage=99999 doit etre plafonne a 50 (maximum global ERP-72).',
);
}
}
+3 -35
View File
@@ -71,43 +71,11 @@ final class PermissionApiTest extends AbstractApiTestCase
self::assertGreaterThanOrEqual(3, $data['totalItems']);
}
/**
* Verrouille le chemin paginE PAR DEFAUT (ERP-72) : sans `?pagination=false`,
* `/api/permissions` doit borner la page au defaut global (10) et exposer
* `view`. Les autres tests de filtre passent `?pagination=false` et
* n'exercent donc plus ce contrat on le reteste ici de maniere isolee.
*
* On seed 12 permissions de test pour garantir un total > 10 quelle que soit
* la quantite de permissions reelles presentes en base.
*/
public function testDefaultCollectionIsPaginatedToGlobalDefault(): void
{
$em = $this->getEm();
for ($i = 1; $i <= 12; ++$i) {
$em->persist(new Permission(sprintf('test.core.pagination.perm_%d', $i), sprintf('Perm pagination %d (test)', $i), 'core'));
}
$em->flush();
$em->clear();
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/permissions');
self::assertResponseIsSuccessful();
$data = $response->toArray();
// La page par defaut ne doit jamais depasser le maximum global (10).
self::assertLessThanOrEqual(10, count($data['member']), 'La page par defaut doit etre bornee a 10 items.');
// Avec >= 12 permissions de test (+ reelles), le total depasse une page.
self::assertGreaterThan(10, $data['totalItems']);
// `view` n'est present que lorsque la collection est reellement paginee.
self::assertArrayHasKey('view', $data, 'La collection doit exposer view quand totalItems > itemsPerPage.');
}
public function testCollectionFilterByModule(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/permissions', [
'query' => ['module' => 'core', 'pagination' => 'false'],
'query' => ['module' => 'core'],
]);
self::assertResponseIsSuccessful();
@@ -126,7 +94,7 @@ final class PermissionApiTest extends AbstractApiTestCase
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/permissions', [
'query' => ['orphan' => 'true', 'pagination' => 'false'],
'query' => ['orphan' => 'true'],
]);
self::assertResponseIsSuccessful();
@@ -146,7 +114,7 @@ final class PermissionApiTest extends AbstractApiTestCase
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/permissions', [
'query' => ['orphan' => 'false', 'pagination' => 'false'],
'query' => ['orphan' => 'false'],
]);
self::assertResponseIsSuccessful();
+1 -30
View File
@@ -146,7 +146,7 @@ final class RoleApiTest extends AbstractApiTestCase
public function testGetCollectionAsAdminReturnsRoles(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/roles?pagination=false');
$response = $client->request('GET', '/api/roles');
self::assertResponseIsSuccessful();
$data = $response->toArray();
@@ -157,35 +157,6 @@ final class RoleApiTest extends AbstractApiTestCase
self::assertContains('test_editor', $codes);
}
/**
* Verrouille le chemin paginE PAR DEFAUT (ERP-72) : le test ci-dessus passe
* `?pagination=false` (usage <select>) et n'exerce donc plus le defaut
* paginE. On seed 11 roles de test pour depasser une page (10) et verifier
* que, sans parametre, la page est bornee a 10 et expose `view`.
*/
public function testDefaultCollectionIsPaginatedToGlobalDefault(): void
{
$em = $this->getEm();
for ($i = 1; $i <= 11; ++$i) {
$em->persist(new Role(sprintf('test_pg_%d', $i), sprintf('Role pagination %d (test)', $i), false));
}
$em->flush();
$em->clear();
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/roles');
self::assertResponseIsSuccessful();
$data = $response->toArray();
// La page par defaut ne doit jamais depasser le maximum global (10).
self::assertLessThanOrEqual(10, count($data['member']), 'La page par defaut doit etre bornee a 10 items.');
// 11 roles de test + 2 systeme + editor + viewer => total > 10.
self::assertGreaterThan(10, $data['totalItems']);
// `view` n'est present que lorsque la collection est reellement paginee.
self::assertArrayHasKey('view', $data, 'La collection doit exposer view quand totalItems > itemsPerPage.');
}
public function testGetCollectionFilterByIsSystemTrue(): void
{
$client = $this->authenticatedClient('admin', 'admin');