3fe0f676f6
Auto Tag Develop / tag (push) Successful in 11s
Ticket Lesstime #139 (M3 — Répertoire prestataires, position 1.9). DoD back avant le front : suite PHPUnit consolidée sur la matrice § 8.1 + captures JSON réelles dans la spec § 4.0.bis. ## Contenu - **Fix réfs comptables** : `provider:read:accounting` ajouté sur `TvaMode`/`PaymentDelay`/`PaymentType`/`Bank` — sans ça elles sortaient en IRI nu dans le détail prestataire (réplique du fix ERP-92 du M2, piège #1 § 4.0.bis). - **`ProviderSerializationContractTest`** (13 tests) : gating RIB/scalaires par omission, réfs compta en objet `{id,code,label}`, `isArchived`, embed categories/sites liste+détail, sous-collections, enveloppe AP4 ; `testDodReferenceJsonShape` dumpe le JSON réel (`PROVIDER_DOD_DUMP=1`). - **`ProviderAuditTest`** (5 tests) : create/update/archive (`technique.Provider`), iban/bic dans le diff (`technique.ProviderRib`, pas dAuditIgnore), trace M2M `sites`. - **`ProviderListTest`** étendu : `?pagination=false`, anti-N+1, filtre `?typeCode=PRESTATAIRE`. - **`ProviderRbacGatingTest`** étendu : restauration en conflit de nom → 409 (RG-3.14). - **`ProviderFixtures`** (§ 8.4) : démo idempotente (complet VIREMENT+banque+RIB, LCR+RIB, CHEQUE multi-cat, minimal, archivé) répartie sur sites 86/17/82 ; skip en env `test`. - Helper `seedCompleteProvider` ; spec § 4.0.bis : gabarits remplacés par les captures réelles (liste + détail avec/sans accounting.view). ## Vérifications - `make php-cs-fixer-allow-risky` → 0 fichier - `make test` → OK, 677 tests, 3328 assertions (garde-fous globaux verts) ## Notes - MR stackée sur ERP-138 (base = sa branche). - Fixtures démo exercées en dev via `make fixtures` (autowiring vérifié). --------- Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #100
366 lines
17 KiB
PHP
366 lines
17 KiB
PHP
<?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;
|
|
}
|
|
}
|