test(commercial) : tests PHPUnit M2 fournisseurs (matrice RG + contrat sérialisation + DoD JSON réel) (ERP-92)

Suite fonctionnelle M2 assertant sur le CORPS JSON (jamais les annotations),
jumelle de la suite clients M1 :
- contrat de sérialisation : 4 régressions M1 re-testées (RIB gaté absent pour
  Commerciale, booléens triageProvider/isArchived présents, embed
  categories[].code/name, embed sites[].name/postalCode objet) + enveloppe AP4
  (member/totalItems/view, archivés exclus) + suppression du contact inline ;
- matrice RBAC réelle (app:seed-rbac) bureau/compta/commerciale/usine 200/403,
  gating accounting par omission de clé, mode strict PATCH (RG-2.16) ;
- RG-2.03/2.04/2.05/2.06/2.07/2.08/2.09/2.10/2.11/2.12/2.14/2.15/2.17 ;
- sous-ressources contacts/adresses/ribs (CRUD, sécurité, normalisation) ;
- anti N+1 liste (compte de requêtes constant), audit Supplier + RIB iban/bic.

Fix de contrat découvert et corrigé (sinon DoD figée sur un contrat faux) :
les référentiels comptables (TvaMode/PaymentType/PaymentDelay/Bank) ne portaient
que le groupe client:read:accounting (M1) → sur un fournisseur ils sortaient en
IRI nu. Ajout de supplier:read:accounting → objet {id, code, label} embarqué.

makefile : test-db-setup recrée l'index partiel uq_supplier_company_name_active
(droppé par schema:update comme pour le client) — oubli M2.

DoD § 4.0.bis : réponses JSON RÉELLES (liste + détail admin/commerciale) collées,
capturées via SupplierSerializationContractTest.
This commit is contained in:
Matthieu
2026-06-07 10:45:07 +02:00
parent 97459e798f
commit 559a3a9b11
18 changed files with 2148 additions and 64 deletions
@@ -0,0 +1,371 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Tests anti-regression du CONTRAT DE SERIALISATION du repertoire fournisseurs
* (M2, spec-back § 4.0 / § 4.0.bis / § 4.0.ter). Jumeau du
* {@see ClientSerializationContractTest} (M1), il reverifie sur le JSON reel les
* 4 pieges silencieux constates en prod sur le M1 :
* - #4 : fuite RIB (IBAN/BIC) vers un user sans accounting.view -> clé `ribs`
* ABSENTE pour la Commerciale.
* - #3 : booleens droppes (Groups sur la propriete `isX`, getter derivant `x`)
* -> triageProvider (adresse) et isArchived (fournisseur) presents.
* - #1 : categories embarquees sans code/name -> code + name presents en LISTE
* ET DETAIL.
* - #2 : sites embarques en IRI nu -> name + postalCode presents en LISTE
* (via getSites()) ET DETAIL (addresses[].sites[]).
* Plus l'enveloppe AP4 (member/totalItems/view sans prefixe hydra:, archives
* exclus) et la suppression du contact inline (refonte-contact V0.2).
*
* REGLE D'OR : ces tests assertent sur le CORPS JSON reel, jamais sur les
* annotations. Toute regression de groupe de serialisation casse ici.
*
* @internal
*/
final class SupplierSerializationContractTest extends AbstractSupplierApiTestCase
{
// === #4 — Gating des RIB par accounting.view ===
public function testRibsPresentForAdminWithAccountingView(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('Rib Admin Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// Admin bypass RBAC -> accounting.view -> RIB embarques (label/bic/iban).
self::assertArrayHasKey('ribs', $data);
self::assertNotEmpty($data['ribs']);
self::assertSame('Compte principal', $data['ribs'][0]['label']);
self::assertSame(self::VALID_IBAN, $data['ribs'][0]['iban']);
self::assertSame(self::VALID_BIC, $data['ribs'][0]['bic']);
}
public function testRibsAbsentForCommercialeWithoutAccountingView(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('Rib Commerciale Co');
// Commerciale : commercial.suppliers.view SANS accounting.view.
$creds = $this->createUserWithPermission('commercial.suppliers.view');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// La clé `ribs` est ABSENTE (pas null) : le groupe supplier:read:accounting
// n'est pas ajoute au contexte -> getRibs() jamais serialise. Fin de la
// fuite IBAN/BIC (piege n°4 du M1).
self::assertArrayNotHasKey('ribs', $data);
}
// === #4.bis — Gating par OMISSION des scalaires comptables ===
public function testAccountingScalarsGatedByOmission(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('Compta Gating Co');
$id = $supplier->getId();
// Admin : scalaires comptables presents.
$admin = $this->createAdminClient();
$adminData = $admin->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('siren', $adminData);
self::assertSame('123456789', $adminData['siren']);
self::assertArrayHasKey('accountNumber', $adminData);
self::assertArrayHasKey('paymentType', $adminData);
// Commerciale : scalaires comptables ABSENTS (omission, pas null).
$creds = $this->createUserWithPermission('commercial.suppliers.view');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$data = $http->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayNotHasKey('siren', $data);
self::assertArrayNotHasKey('accountNumber', $data);
self::assertArrayNotHasKey('nTva', $data);
self::assertArrayNotHasKey('tvaMode', $data);
self::assertArrayNotHasKey('paymentType', $data);
self::assertArrayNotHasKey('ribs', $data);
}
// === Refs comptables embarquees {id,label} et non IRI nu (ERP-92) ===
public function testAccountingReferentialsEmbedIdAndLabel(): void
{
$this->skipIfSitesModuleDisabled();
// Reglement Virement -> banque renseignee : on couvre les 4 referentiels.
$supplier = $this->seedCompleteSupplier('Refs Embed Co', 'VIREMENT');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// Avant fix ERP-92 : ces refs sortaient en IRI nu ("/api/tva_modes/30")
// car les entites partagees ne portaient que `client:read:accounting` (M1),
// pas `supplier:read:accounting`. Apres fix : objet {id, label} embarque
// (le front consultation/edition affiche le libelle sans fetch — § 4.0).
foreach (['tvaMode', 'paymentDelay', 'paymentType', 'bank'] as $ref) {
self::assertArrayHasKey($ref, $data, sprintf('Le ref comptable "%s" doit etre present.', $ref));
self::assertIsArray($data[$ref], sprintf('Le ref "%s" doit etre un objet embarque, pas un IRI nu.', $ref));
self::assertArrayHasKey('id', $data[$ref]);
self::assertArrayHasKey('label', $data[$ref]);
self::assertNotSame('', (string) $data[$ref]['label']);
}
// paymentType embarque aussi son code (logique front VIREMENT/LCR).
self::assertArrayHasKey('code', $data['paymentType']);
self::assertSame('VIREMENT', $data['paymentType']['code']);
}
// === #3 — Booleens presents dans le JSON (triageProvider + isArchived) ===
public function testAddressTriageProviderBooleanIsPresentInDetail(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('Bool Addr Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('addresses', $data);
self::assertNotEmpty($data['addresses']);
$address = $data['addresses'][0];
// Le bug M1 droppait TOTALEMENT la cle (Groups sur la propriete `triageProvider`,
// getter derivant `triage`). Apres parade (Groups + SerializedName sur le
// getter isTriageProvider), la cle est presente ET typee bool `true`.
self::assertArrayHasKey('triageProvider', $address);
self::assertTrue($address['triageProvider']);
}
public function testSupplierIsArchivedBooleanIsPresentInDetail(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('Bool Archived Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// isArchived expose via Groups + SerializedName('isArchived') sur le getter :
// sans cela Symfony exposerait la cle "archived" et la droppait (piege n°3 M1).
self::assertArrayHasKey('isArchived', $data);
self::assertFalse($data['isArchived']);
}
// === #1 — Embed code/name des Category (liste ET detail) ===
public function testCategoriesEmbedCodeAndNameInDetail(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('Embed Cat Detail Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertNotEmpty($data['categories']);
$category = $data['categories'][0];
// Avant correctif M1 : seuls @id/@type (category:read absent du contexte).
// Apres : code + name embarques.
self::assertArrayHasKey('code', $category);
self::assertArrayHasKey('name', $category);
self::assertSame('NEGOCIANT', $category['code']);
// Categories d'adresse aussi (category:read dans le contexte du detail).
self::assertArrayHasKey('categories', $data['addresses'][0]);
self::assertNotEmpty($data['addresses'][0]['categories']);
self::assertArrayHasKey('code', $data['addresses'][0]['categories'][0]);
}
public function testCategoriesEmbedCodeAndNameInList(): void
{
$this->skipIfSitesModuleDisabled();
$token = 'CatList'.substr(bin2hex(random_bytes(3)), 0, 6);
$supplier = $this->seedCompleteSupplier($token);
$http = $this->createAdminClient();
$list = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
$row = $this->memberById($list, (int) $supplier->getId());
self::assertNotNull($row, 'Le fournisseur seede doit apparaitre dans la liste filtree.');
self::assertNotEmpty($row['categories']);
self::assertArrayHasKey('code', $row['categories'][0]);
self::assertArrayHasKey('name', $row['categories'][0]);
self::assertSame('NEGOCIANT', $row['categories'][0]['code']);
}
// === #2 — Embed name/postalCode des Site (liste via getSites + detail) ===
public function testSitesEmbedNameAndPostalCodeInList(): void
{
$this->skipIfSitesModuleDisabled();
$token = 'SiteList'.substr(bin2hex(random_bytes(3)), 0, 6);
$supplier = $this->seedCompleteSupplier($token);
$http = $this->createAdminClient();
$list = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
$row = $this->memberById($list, (int) $supplier->getId());
self::assertNotNull($row);
// sites agreges depuis les adresses via getSites() : objet Site entier
// (name + postalCode), pas un IRI nu (piege n°2 M1). Multi-sites (>= 2).
self::assertArrayHasKey('sites', $row);
self::assertGreaterThanOrEqual(2, count($row['sites']));
self::assertArrayHasKey('name', $row['sites'][0]);
self::assertArrayHasKey('postalCode', $row['sites'][0]);
self::assertNotSame('', (string) $row['sites'][0]['name']);
}
public function testSitesEmbedNameAndPostalCodeInDetail(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('Site Detail Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
$address = $data['addresses'][0];
self::assertArrayHasKey('sites', $address);
self::assertGreaterThanOrEqual(2, count($address['sites']), 'L\'adresse seedee est multi-sites.');
self::assertArrayHasKey('name', $address['sites'][0]);
self::assertArrayHasKey('postalCode', $address['sites'][0]);
self::assertNotSame('', (string) $address['sites'][0]['name']);
}
// === Detail : sous-collections embarquees ===
public function testDetailEmbedsContactsAddressesRibs(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('Embed Subres Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertNotEmpty($data['contacts']);
self::assertSame('Marie', $data['contacts'][0]['firstName']);
self::assertSame('Martin', $data['contacts'][0]['lastName']);
self::assertArrayHasKey('email', $data['contacts'][0]);
self::assertNotEmpty($data['addresses']);
self::assertSame('DEPART', $data['addresses'][0]['addressType']);
self::assertNotEmpty($data['ribs']);
}
// === refonte-contact V0.2 : plus de contact inline sur le fournisseur ===
public function testSupplierHasNoInlineContactFields(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('No Inline Contact Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// Les champs de contact vivent UNIQUEMENT sous contacts[] (refonte-contact).
foreach (['firstName', 'lastName', 'phonePrimary', 'phoneSecondary', 'email'] as $key) {
self::assertArrayNotHasKey($key, $data, sprintf('Le champ inline "%s" ne doit plus exister au niveau du fournisseur.', $key));
}
}
// === Enveloppe AP4 (sans prefixe hydra:) + exclusion des archives ===
public function testCollectionEnvelopeShapeAndArchivedExcluded(): void
{
$this->skipIfSitesModuleDisabled();
$http = $this->createAdminClient();
$token = 'EnvCheck'.substr(bin2hex(random_bytes(3)), 0, 6);
$this->seedSupplier($token.' Active');
$this->seedSupplier($token.' Archived', true);
// Liste par defaut filtree sur le token : enveloppe member/totalItems sans
// prefixe hydra:, archive EXCLU du totalItems (RG-2.17).
$default = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('member', $default);
self::assertArrayHasKey('totalItems', $default);
self::assertArrayNotHasKey('hydra:member', $default);
self::assertArrayNotHasKey('hydra:totalItems', $default);
self::assertSame(1, $default['totalItems'], 'Archive exclu du totalItems par defaut.');
// includeArchived : l'archive reintegre le total.
$all = $http->request('GET', '/api/suppliers?search='.$token.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertSame(2, $all['totalItems']);
// `view` (PartialCollectionView) sans prefixe hydra:.
$paged = $http->request('GET', '/api/suppliers?search='.$token.'&includeArchived=true&itemsPerPage=1', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('view', $paged);
self::assertArrayNotHasKey('hydra:view', $paged);
}
/**
* DoD (§ 4.0.bis) : capture des reponses JSON REELLES (liste + detail admin +
* detail commerciale) pour les coller dans la spec avant de lancer les tickets
* front. Le test asserte la forme ; si la variable d'env SUPPLIER_DOD_DUMP est
* positionnee, il ecrit aussi les 3 corps formates sous /tmp pour copie.
*/
public function testDodReferenceJsonShape(): void
{
$this->skipIfSitesModuleDisabled();
$token = 'DoD'.substr(bin2hex(random_bytes(3)), 0, 6);
$supplier = $this->seedCompleteSupplier($token);
$id = (int) $supplier->getId();
$admin = $this->createAdminClient();
$list = $admin->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
$detailAdmin = $admin->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
$creds = $this->createUserWithPermission('commercial.suppliers.view');
$commerciale = $this->authenticatedClient($creds['username'], $creds['password']);
$detailCommerciale = $commerciale->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
// Forme minimale attendue (la DoD valide que tout champ front est present).
self::assertArrayHasKey('member', $list);
self::assertArrayHasKey('siren', $detailAdmin);
self::assertArrayHasKey('ribs', $detailAdmin);
self::assertArrayNotHasKey('siren', $detailCommerciale);
self::assertArrayNotHasKey('ribs', $detailCommerciale);
if (false !== getenv('SUPPLIER_DOD_DUMP')) {
$flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
file_put_contents('/tmp/supplier-dod-list.json', json_encode($list, $flags));
file_put_contents('/tmp/supplier-dod-detail-admin.json', json_encode($detailAdmin, $flags));
file_put_contents('/tmp/supplier-dod-detail-commerciale.json', json_encode($detailCommerciale, $flags));
}
}
/**
* Retrouve un membre de la collection par son id (liste filtree).
*
* @param array<string, mixed> $collection
*
* @return array<string, mixed>|null
*/
private function memberById(array $collection, int $id): ?array
{
foreach ($collection['member'] ?? [] as $member) {
if (($member['id'] ?? null) === $id) {
return $member;
}
}
return null;
}
}