test(commercial) : tests PHPUnit M2 fournisseurs (matrice RG + contrat sérialisation + DoD JSON réel) (ERP-92) (#71)
Auto Tag Develop / tag (push) Successful in 7s
Auto Tag Develop / tag (push) Successful in 7s
## ERP-92 — Tests PHPUnit M2 fournisseurs (#521) Suite fonctionnelle M2 assertant sur le **corps JSON** (jamais les annotations), jumelle de la suite clients M1. ### Couverture - **Contrat de sérialisation** (`SupplierSerializationContractTest`) : 4 régressions M1 re-testées — RIB gaté **absent** pour la Commerciale, booléens `triageProvider`/`isArchived` présents, embed `categories[].code/name`, embed `sites[].name/postalCode` (objet, pas IRI) — + enveloppe AP4 (`member`/`totalItems`/`view`, archivés exclus) + suppression du contact inline. - **Matrice RBAC réelle** (`app:seed-rbac`, pas de mock) : bureau/compta/commerciale/usine 200/403, gating `accounting` par **omission de clé**, mode strict PATCH (RG-2.16). - **Matrice RG-2.03 → RG-2.17** (création, normalisation RG-2.12, catégorie FOURNISSEUR RG-2.10, unicité RG-2.11, archivage RG-2.14/2.15, RG-2.07/2.08 compta, sous-ressources RG-2.04/2.05/2.06/2.09). - **Anti N+1 liste** : nombre de requêtes constant entre 2 et 4 fournisseurs. **Audit** Supplier + RIB (`iban`/`bic` dans le diff). ### Fix de contrat (découvert par la DoD) Les référentiels comptables (`TvaMode`/`PaymentType`/`PaymentDelay`/`Bank`) ne portaient que `client:read:accounting` (M1) → sur un fournisseur ils sortaient en **IRI nu**. Ajout de `supplier:read:accounting` → objet `{id, code, label}` embarqué (additif, zéro impact M1). Sans ce fix, #95/#96 auraient été développés contre un contrat faux. ### Infra `makefile` : `test-db-setup` recrée l'index partiel `uq_supplier_company_name_active` (droppé par `schema:update` comme celui du client — oubli M2). ### DoD ✅ § 4.0.bis : réponses JSON **réelles** (liste + détail admin/commerciale) collées. Front #93→#96 peuvent démarrer. ### Vérifs - `make test` : **574 tests OK** (suite complète verte) - `make php-cs-fixer-allow-risky` : 0 correction --------- Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #71 Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #71.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user