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; } }