cle `ribs` * ABSENTE pour un profil type Commerciale (gating par omission). * - #3 : booleens droppes (Groups sur la propriete `isX`, getter derivant `x`) * -> isArchived present dans le detail. * - #1 : categories embarquees sans code/name -> code + name presents en LISTE * ET DETAIL (provider ET adresse). * - #2 : sites embarques en IRI nu -> name + postalCode presents en LISTE * (relation DIRECTE provider.sites — RG-3.03) ET DETAIL (addresses[].sites[]). * - ERP-92 : refs comptables (tvaMode/paymentDelay/paymentType/bank) embarquees * {id, code, label} et non IRI nu (le groupe provider:read:accounting doit * etre porte par les entites partagees — fix ERP-139, sinon IRI nu). * Plus l'enveloppe AP4 (member/totalItems/view sans prefixe hydra:, archives exclus). * * 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 ProviderSerializationContractTest extends AbstractProviderApiTestCase { // === #4 — Gating des RIB par accounting.view === public function testRibsPresentForAdminWithAccountingView(): void { $this->skipIfSitesModuleDisabled(); $provider = $this->seedCompleteProvider('Rib Admin Co'); $http = $this->createAdminClient(); $data = $http->request('GET', '/api/providers/'.$provider->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 testRibsAbsentForUserWithoutAccountingView(): void { $this->skipIfSitesModuleDisabled(); $provider = $this->seedCompleteProvider('Rib Commerciale Co'); // Profil type Commerciale : technique.providers.view SANS accounting.view. // createUserWithPermissions n'attache pas de currentSite -> pas de // cloisonnement, on isole le gating comptable du comportement site. $creds = $this->createUserWithPermissions(['technique.providers.view']); $http = $this->authenticatedClient($creds['username'], $creds['password']); $data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); // La cle `ribs` est ABSENTE (pas null) : le groupe provider: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(); $provider = $this->seedCompleteProvider('Compta Gating Co'); $id = $provider->getId(); // Admin : scalaires comptables presents. $admin = $this->createAdminClient(); $adminData = $admin->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray(); self::assertArrayHasKey('siren', $adminData); self::assertSame('987654321', $adminData['siren']); self::assertArrayHasKey('accountNumber', $adminData); self::assertArrayHasKey('paymentType', $adminData); // Sans accounting.view : scalaires comptables ABSENTS (omission, pas null). $creds = $this->createUserWithPermissions(['technique.providers.view']); $http = $this->authenticatedClient($creds['username'], $creds['password']); $data = $http->request('GET', '/api/providers/'.$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); } // === ERP-139 — Refs comptables embarquees {id, code, label} et non IRI nu === public function testAccountingReferentialsEmbedIdCodeLabel(): void { $this->skipIfSitesModuleDisabled(); // Reglement VIREMENT -> banque renseignee : on couvre les 4 referentiels. $provider = $this->seedCompleteProvider('Refs Embed Co', 'VIREMENT'); $http = $this->createAdminClient(); $data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); // Avant fix ERP-139 : ces refs sortaient en IRI nu ("/api/tva_modes/30") // car les entites partagees ne portaient que client:/supplier:read:accounting, // pas provider:read:accounting. Apres fix : objet {id, code, label} embarque // (le front consultation/edition affiche le libelle sans fetch — § 4.0.bis). 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 — Booleen isArchived present dans le JSON === public function testProviderIsArchivedBooleanIsPresentInDetail(): void { $this->skipIfSitesModuleDisabled(); $provider = $this->seedCompleteProvider('Bool Archived Co'); $http = $this->createAdminClient(); $data = $http->request('GET', '/api/providers/'.$provider->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(); $provider = $this->seedCompleteProvider('Embed Cat Detail Co'); $http = $this->createAdminClient(); $data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); self::assertNotEmpty($data['categories']); $category = $data['categories'][0]; // Avant correctif : seuls @id/@type (category:read absent du contexte). // Apres : code + name embarques. self::assertArrayHasKey('code', $category); self::assertArrayHasKey('name', $category); self::assertSame('NETTOYAGE', $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); $provider = $this->seedCompleteProvider($token); $http = $this->createAdminClient(); $list = $http->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray(); $row = $this->memberById($list, (int) $provider->getId()); self::assertNotNull($row, 'Le prestataire 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('NETTOYAGE', $row['categories'][0]['code']); } // === #2 — Embed name/postalCode des Site (liste via relation directe + detail) === public function testSitesEmbedNameAndPostalCodeInList(): void { $this->skipIfSitesModuleDisabled(); $token = 'SiteList'.substr(bin2hex(random_bytes(3)), 0, 6); $provider = $this->seedCompleteProvider($token); $http = $this->createAdminClient(); $list = $http->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray(); $row = $this->memberById($list, (int) $provider->getId()); self::assertNotNull($row); // sites en relation DIRECTE provider.sites (RG-3.03) : 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(); $provider = $this->seedCompleteProvider('Site Detail Co'); $http = $this->createAdminClient(); $data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); // Sites du formulaire principal (relation directe). self::assertArrayHasKey('sites', $data); self::assertGreaterThanOrEqual(2, count($data['sites'])); self::assertArrayHasKey('name', $data['sites'][0]); self::assertArrayHasKey('postalCode', $data['sites'][0]); // Sites de l'adresse (addresses[].sites[]). $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(); $provider = $this->seedCompleteProvider('Embed Subres Co'); $http = $this->createAdminClient(); $data = $http->request('GET', '/api/providers/'.$provider->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']); // M3 : adresse simplifiee, PAS de addressType. self::assertArrayNotHasKey('addressType', $data['addresses'][0]); self::assertSame('Poitiers', $data['addresses'][0]['city']); self::assertNotEmpty($data['ribs']); } // === refonte-contact V0.2 : pas de contact inline sur le prestataire === public function testProviderHasNoInlineContactFields(): void { $this->skipIfSitesModuleDisabled(); $provider = $this->seedCompleteProvider('No Inline Contact Co'); $http = $this->createAdminClient(); $data = $http->request('GET', '/api/providers/'.$provider->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 prestataire.', $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->seedProvider($token.' Active', [self::SITE_86]); $this->seedProvider($token.' Archived', [self::SITE_86], isArchived: true); // Liste par defaut filtree sur le token : enveloppe member/totalItems sans // prefixe hydra:, archive EXCLU du totalItems (RG-3.16). $default = $http->request('GET', '/api/providers?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/providers?search='.$token.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray(); self::assertSame(2, $all['totalItems']); // `view` (PartialCollectionView) sans prefixe hydra:. $paged = $http->request('GET', '/api/providers?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 sans accounting.view) pour les coller dans la spec avant de lancer les * tickets front. Le test asserte la forme ; si la variable d'env * PROVIDER_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); $provider = $this->seedCompleteProvider($token); $id = (int) $provider->getId(); $admin = $this->createAdminClient(); $list = $admin->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray(); $detailAdmin = $admin->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray(); $creds = $this->createUserWithPermissions(['technique.providers.view']); $restricted = $this->authenticatedClient($creds['username'], $creds['password']); $detailRestricted = $restricted->request('GET', '/api/providers/'.$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', $detailRestricted); self::assertArrayNotHasKey('ribs', $detailRestricted); if (false !== getenv('PROVIDER_DOD_DUMP')) { $flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES; file_put_contents('/tmp/provider-dod-list.json', json_encode($list, $flags)); file_put_contents('/tmp/provider-dod-detail-admin.json', json_encode($detailAdmin, $flags)); file_put_contents('/tmp/provider-dod-detail-restricted.json', json_encode($detailRestricted, $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; } }