6f9bb68170
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>
372 lines
17 KiB
PHP
372 lines
17 KiB
PHP
<?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;
|
|
}
|
|
}
|