test(technique) : couvrir RG-3.x PHPUnit + capturer le contrat JSON (ProviderSerializationContractTest, ProviderAuditTest, fixtures démo) (ERP-139)
Ajoute provider:read:accounting sur les réfs comptables partagées (TvaMode/PaymentDelay/PaymentType/Bank) pour embarquer {id,code,label} au lieu d un IRI nu (réplique fix ERP-92). Helper seedCompleteProvider, anti-N+1 + pagination=false + filtre typeCode, restauration conflit 409, fixtures démo idempotentes. Captures JSON réelles collées dans spec § 4.0.bis.
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
/**
|
||||
* Tests anti-regression du CONTRAT DE SERIALISATION du repertoire prestataires
|
||||
* (M3, spec-back § 4.0 / § 4.0.bis). Jumeau du
|
||||
* {@see \App\Tests\Module\Commercial\Api\SupplierSerializationContractTest} (M2),
|
||||
* il reverifie sur le JSON REEL les pieges silencieux herites du M1/M2 :
|
||||
* - #4 : fuite RIB (IBAN/BIC) vers un user sans accounting.view -> 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<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