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 $collection * * @return array|null */ private function memberById(array $collection, int $id): ?array { foreach ($collection['member'] ?? [] as $member) { if (($member['id'] ?? null) === $id) { return $member; } } return null; } }