fix(commercial) : corrige le contrat de sérialisation du répertoire clients (ERP-80/81/82/83) (#45)
Auto Tag Develop / tag (push) Successful in 7s
Auto Tag Develop / tag (push) Successful in 7s
## Contexte
Correctifs des 4 bugs de contrat de sérialisation du répertoire clients M1, révélés par la capture du JSON réel le 02/06/2026 (cf. `docs/specs/M2-suppliers/spec-back.md` § 4.0.ter). Tous étaient des oublis **silencieux** (aucune erreur levée).
## Changements
- **ERP-80 — Fuite RIB (sécurité)** : `Client::getRibs()` et les propriétés de `ClientRib` passent sous le groupe gaté `client:read:accounting` (ajouté au contexte par `ClientReadGroupContextBuilder` uniquement si `accounting.view`). La clé `ribs` est désormais **absente** du détail pour la Commerciale. La sous-ressource autonome `/api/client_ribs/{id}` conserve `client_rib:read` (écriture/PATCH intacts).
- **ERP-81 — Booléens d'adresse** : `#[Groups]` + `#[SerializedName]` portés sur les **getters** `isProspect()/isDelivery()/isBilling()` (le getter booléen strippait le préfixe `is` et droppait la clé — même pattern que `Client::isArchived`).
- **ERP-82 — Embed Category/Site** : `category:read` + `site:read` ajoutés au `normalizationContext` du `Get` Client → `categories[].code/.name` et `addresses[].sites[].name` embarqués.
- **ERP-83 — Tests anti-régression** : nouveau `ClientSerializationContractTest` (7 tests, 64 assertions) assertant sur le **corps JSON réel**.
## Dépendance signalée
⚠️ L'entité **`Site` n'a pas de champ `code`** (ni `SiteInterface`) — son libellé est `name`. Les « codes 86/17/82 » de la spec M2 sont en réalité le préfixe du code postal des sites fixtures. À planifier côté module Sites si un `Site.code` est requis (notamment pour `getSiteCodes()` au M2).
## Vérifications
- `make test` : **460 tests, 1535 assertions, exit 0** ✅
- `make php-cs-fixer-allow-risky` : 0 fix ✅
- Capture JSON réelle AVANT/APRÈS (client 6 TRANSPORTS RAPIDES) :
- **Admin** : `ribs` présents, `siren`/`accountNumber`/`nTva` présents, `categories[].code/.name` + `addresses[].sites[].name` embarqués, booléens d'adresse présents.
- **Commerciale** : `ribs` **absent**, scalaires comptables **absents** (omission), embed Category/Site + booléens visibles.
Tickets : ERP-80, ERP-81, ERP-82, ERP-83 (passés « En review »).
---------
Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #45
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #45.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user