Merge develop into feature/ERP-62-page-repertoire-clients
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m55s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m18s

Résolution du conflit de contrat de sérialisation (Client.php) après merge
du fix #45 (ERP-80/81/82/83) dans develop :

- GetCollection : ajout category:read + site:read + accesseur getSites()
  (delta nécessaire à ERP-62, absent du contrat mergé) — un seul endroit.
- Get : client_rib:read NON réintroduit (fix sécu #45 conservé : contenu RIB
  gaté par client:read:accounting, plus de fuite IBAN/BIC).
- getSites() conservé en sus du gating RIB de develop.
- Repository : fetch-joins categories/addresses/sites conservés (anti N+1 liste).
This commit is contained in:
Matthieu
2026-06-02 12:06:44 +02:00
5 changed files with 332 additions and 14 deletions
@@ -0,0 +1,281 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
use App\Module\Commercial\Domain\Entity\ClientAddress;
use App\Module\Commercial\Domain\Entity\ClientContact;
use App\Module\Commercial\Domain\Entity\ClientRib;
use App\Module\Sites\Domain\Entity\Site;
/**
* Tests anti-regression du CONTRAT DE SERIALISATION du repertoire clients (M1).
*
* Captures reelles du 02/06/2026 (cf. docs/specs/M2-suppliers/spec-back.md
* § 4.0.ter) ayant revele 4 bugs silencieux du contrat (aucune erreur levee) :
* - #81 : booleens d'adresse (isProspect/isDelivery/isBilling) absents du JSON
* (Groups sur la propriete `isX`, getter `isX()` derivant l'attribut `x`).
* - #80 : fuite RIB (IBAN/BIC) vers un user sans accounting.view.
* - #82 : code/libelle de Category et Site non embarques (stub IRI nu).
* - 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.
*
* Limite connue (dependance module Sites) : l'entite Site ne porte PAS de champ
* `code` (ni SiteInterface) — son libelle est `name`. Les « codes 86/17/82 » de
* la spec M2 correspondent en realite au prefixe du code postal des 3 sites
* fixtures (86100/17400/82400). On asserte donc le libelle `name` du site
* embarque ; l'ajout d'un `Site.code` reste un ticket cote module Sites.
*
* @internal
*/
final class ClientSerializationContractTest extends AbstractCommercialApiTestCase
{
private const string LD = 'application/ld+json';
private const string VALID_IBAN = 'FR1420041010050500013M02606';
private const string VALID_BIC = 'BNPAFRPPXXX';
// === #81 — Booleens d'adresse presents dans le JSON ===
public function testAddressBooleansArePresentInDetail(): void
{
$this->skipIfSitesModuleDisabled();
$seed = $this->seedCompleteClient('Bool Addr Co');
$id = $seed->getId();
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('addresses', $data);
self::assertNotEmpty($data['addresses']);
$address = $data['addresses'][0];
// Le bug droppait TOTALEMENT ces cles. Apres correctif (Groups +
// SerializedName sur le getter), elles sont presentes ET typees bool.
self::assertArrayHasKey('isProspect', $address);
self::assertArrayHasKey('isDelivery', $address);
self::assertArrayHasKey('isBilling', $address);
// L'adresse seedee est livraison + facturation (prospect exclusif, RG-1.06).
// Prouve qu'un booleen `true` est bien serialise (le bug masquait meme les true).
self::assertFalse($address['isProspect']);
self::assertTrue($address['isDelivery']);
self::assertTrue($address['isBilling']);
}
// === #80 — Gating des RIB par accounting.view ===
public function testRibsPresentForAdminWithAccountingView(): void
{
$this->skipIfSitesModuleDisabled();
$seed = $this->seedCompleteClient('Rib Admin Co');
$id = $seed->getId();
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/clients/'.$id, ['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();
$seed = $this->seedCompleteClient('Rib Commerciale Co');
$id = $seed->getId();
// Commerciale : commercial.clients.view SANS accounting.view.
$creds = $this->createUserWithPermission('commercial.clients.view');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$data = $http->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
// La cle `ribs` est ABSENTE (pas null) : le groupe client:read:accounting
// n'est pas ajoute au contexte -> getRibs() jamais serialise. Fin de la
// fuite IBAN/BIC.
self::assertArrayNotHasKey('ribs', $data);
}
// === #80.bis — Gating par OMISSION des scalaires comptables ===
public function testAccountingScalarsGatedByOmission(): void
{
$this->skipIfSitesModuleDisabled();
$seed = $this->seedCompleteClient('Compta Gating Co');
$id = $seed->getId();
// Admin : scalaires comptables presents.
$admin = $this->createAdminClient();
$adminData = $admin->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('siren', $adminData);
self::assertSame('123456789', $adminData['siren']);
self::assertArrayHasKey('accountNumber', $adminData);
// Commerciale : scalaires comptables ABSENTS (omission, pas null).
$creds = $this->createUserWithPermission('commercial.clients.view');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$data = $http->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayNotHasKey('siren', $data);
self::assertArrayNotHasKey('accountNumber', $data);
self::assertArrayNotHasKey('nTva', $data);
self::assertArrayNotHasKey('ribs', $data);
}
// === #82 — Embed code/libelle des Category et Site ===
public function testCategoriesEmbedCodeAndLabel(): void
{
$this->skipIfSitesModuleDisabled();
$seed = $this->seedCompleteClient('Embed Cat Co');
$id = $seed->getId();
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
self::assertNotEmpty($data['categories']);
$category = $data['categories'][0];
// Avant correctif : seuls @id/@type/createdAt/updatedAt (category:read
// absent du contexte). Apres : code + name (libelle) embarques.
self::assertArrayHasKey('code', $category);
self::assertArrayHasKey('name', $category);
self::assertNotSame('', $category['code']);
}
public function testAddressSitesEmbedLabel(): void
{
$this->skipIfSitesModuleDisabled();
$seed = $this->seedCompleteClient('Embed Site Co');
$id = $seed->getId();
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
$address = $data['addresses'][0];
self::assertArrayHasKey('sites', $address);
self::assertNotEmpty($address['sites']);
// Site embarque : libelle `name` present (avant : stub @id/@type nu).
// NB : Site n'a pas de champ `code` (cf. note de classe) -> on asserte name.
self::assertArrayHasKey('name', $address['sites'][0]);
self::assertNotSame('', $address['sites'][0]['name']);
// L'adresse seedee est multi-sites : preuve que l'embed parcourt la collection.
self::assertGreaterThanOrEqual(2, count($address['sites']));
// Categories d'adresse : code embarque (category:read dans le contexte).
self::assertArrayHasKey('categories', $address);
self::assertNotEmpty($address['categories']);
self::assertArrayHasKey('code', $address['categories'][0]);
}
// === Enveloppe AP4 (sans prefixe hydra:) + exclusion des archives ===
public function testCollectionEnvelopeShapeAndArchivedExcluded(): void
{
$http = $this->createAdminClient();
$prefix = 'EnvCheck'.substr(bin2hex(random_bytes(3)), 0, 6);
$this->seedClient($prefix.' Active');
$this->seedClient($prefix.' Archived', true);
// Liste par defaut filtree sur le prefixe : enveloppe member/totalItems
// sans prefixe hydra:, archive EXCLU du totalItems (RG-1.24).
$default = $http->request('GET', '/api/clients?search='.$prefix, ['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/clients?search='.$prefix.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertSame(2, $all['totalItems']);
// `view` (PartialCollectionView) sans prefixe hydra: : force le multi-page
// via itemsPerPage=1 sur les 2 resultats archives inclus.
$paged = $http->request('GET', '/api/clients?search='.$prefix.'&includeArchived=true&itemsPerPage=1', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('view', $paged);
self::assertArrayNotHasKey('hydra:view', $paged);
}
// === Helper ===
/**
* Seede un client COMPLET (sans passer par l'API, validations applicatives
* non rejouees mais CHECK BDD respectes) : bloc comptable non nul, >= 1 RIB,
* >= 1 adresse multi-sites avec categories, >= 1 contact, >= 1 categorie.
*
* L'adresse est livraison + facturation (prospect exclusif, RG-1.06 ; email
* de facturation present, RG-1.11) afin de poser des booleens `true`
* serialisables tout en respectant les CHECK Postgres.
*/
private function seedCompleteClient(string $companyName): ClientEntity
{
$em = $this->getEm();
// Nom unique parmi les actifs (index partiel uq_client_company_name_active).
$suffix = substr(bin2hex(random_bytes(3)), 0, 6);
$client = new ClientEntity();
$client->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8'));
$client->setLastName('Complet');
$client->setPhonePrimary('0102030405');
$client->setEmail('complet'.$suffix.'@seed.test');
$client->addCategory($this->createCategory('SECTEUR'));
// Bloc comptable non nul (gating par omission cote Commerciale).
$client->setSiren('123456789');
$client->setAccountNumber('C0001');
$client->setNTva('FR00123456789');
$em->persist($client);
// >= 2 sites fixtures pour une adresse multi-sites (RG-1.10).
$sites = $em->getRepository(Site::class)->findBy([], null, 2);
self::assertGreaterThanOrEqual(2, count($sites), 'Au moins 2 sites fixtures requis (SitesFixtures).');
$address = new ClientAddress();
$address->setClient($client);
$address->setIsProspect(false);
$address->setIsDelivery(true);
$address->setIsBilling(true);
$address->setBillingEmail('billing'.$suffix.'@seed.test');
$address->setPostalCode('86000');
$address->setCity('Poitiers');
$address->setStreet('12 rue des Acacias');
foreach ($sites as $site) {
$address->addSite($site);
}
$address->addCategory($this->createCategory('SECTEUR'));
$em->persist($address);
$rib = new ClientRib();
$rib->setClient($client);
$rib->setLabel('Compte principal');
$rib->setBic(self::VALID_BIC);
$rib->setIban(self::VALID_IBAN);
$em->persist($rib);
$contact = new ClientContact();
$contact->setClient($client);
$contact->setFirstName('Marie');
$contact->setLastName('Martin');
$em->persist($contact);
$em->flush();
return $client;
}
}