createAdminClient(); $this->seedSupplier('Export Alpha'); $response = $client->request('GET', self::EXPORT_URL); self::assertResponseIsSuccessful(); $headers = $response->getHeaders(false); self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? ''); $disposition = $headers['content-disposition'][0] ?? ''; self::assertStringContainsString('attachment; filename="repertoire-fournisseurs-', $disposition); self::assertMatchesRegularExpression( '/filename="repertoire-fournisseurs-\d{8}\.xlsx"/', $disposition, ); // Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes. $grid = $this->gridFromResponse($response->getContent()); $headers = $grid[0]; self::assertSame('Nom fournisseur', $headers[0]); self::assertContains('Contact principal', $headers); self::assertContains('Téléphone principal', $headers); self::assertContains('Téléphone secondaire', $headers); self::assertContains('Email', $headers); self::assertContains('Catégories', $headers); self::assertContains('Sites', $headers); self::assertContains('Date de création', $headers); } public function testExportExcludesArchivedByDefault(): void { $client = $this->createAdminClient(); $this->seedSupplier('Active One'); $this->seedSupplier('Archived One', true); $names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent()); self::assertContains('ACTIVE ONE', $names); self::assertNotContains('ARCHIVED ONE', $names); } public function testExportRespectsSearchFilter(): void { $client = $this->createAdminClient(); $this->seedSupplier('Searchable Alpha'); $this->seedSupplier('Other Beta'); $names = $this->companyNames( $client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(), ); self::assertContains('SEARCHABLE ALPHA', $names); self::assertNotContains('OTHER BETA', $names); } /** * Les colonnes contact sont alimentees par le CONTACT PRINCIPAL : le contact * de plus petit `position` (decision D2, § 4.6). On seede deux contacts en * ordre de position inverse pour garantir que c'est bien le principal (et non * le premier insere) qui alimente la ligne. */ public function testExportUsesPrincipalContactColumns(): void { $client = $this->createAdminClient(); $supplier = $this->seedSupplier('Contact Co'); // position 1 (secondaire) insere en premier... $this->addContact($supplier, 'Bob', 'Secondaire', '0600000001', 'bob@contact.co', 1); // ...position 0 (principal) insere ensuite : c'est lui qui doit gagner. $principal = $this->addContact($supplier, 'Alice', 'Principal', '0612345678', 'alice@contact.co', 0); // Le telephone secondaire n'est pas porte par le helper de base : on le pose // directement sur le contact principal pour alimenter la colonne dediee. $principal->setPhoneSecondary('0698765432'); $this->getEm()->flush(); $row = $this->rowFor($client->request('GET', self::EXPORT_URL)->getContent(), 'CONTACT CO'); self::assertNotNull($row, 'Ligne « CONTACT CO » introuvable dans l\'export.'); self::assertSame('Principal Alice', $row[1]); self::assertSame('0612345678', $row[2]); self::assertSame('0698765432', $row[3]); self::assertSame('alice@contact.co', $row[4]); } /** * Colonnes « Catégories » et « Sites » : un oubli d'hydratation les rendrait * vides sans erreur (cf. ERP-100 cote client). Le site est porte par l'adresse * (RG-2.06). */ public function testExportPopulatesCategoryAndSiteColumns(): void { $client = $this->createAdminClient(); $supplier = $this->seedSupplier('Hydrate Co', false, 'NEGOCIANT'); $em = $this->getEm(); $site = $em->getRepository(Site::class)->findOneBy([]); self::assertNotNull($site, 'Aucun site seede : impossible de tester la colonne Sites.'); $address = new SupplierAddress(); $address->setSupplier($supplier); $address->setAddressType('DEPART'); $address->setPostalCode('86100'); $address->setCity('Châtellerault'); $address->setStreet('1 rue du Test'); $address->addSite($site); $em->persist($address); $em->flush(); $flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent())); // Colonne « Catégories » : libelle de la categorie FOURNISSEUR du fournisseur // (getName()). On le derive du helper de base (idempotent) plutot que de // hardcoder le prefixe de nom de test. self::assertStringContainsString((string) $this->supplierCategory('NEGOCIANT')->getName(), $flat); // Colonne « Sites » : site agrege depuis l'adresse (RG-2.06). self::assertStringContainsString((string) $site->getName(), $flat); } public function testSirenColumnPresentWithAccountingView(): void { // L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN. $client = $this->createAdminClient(); $supplier = $this->seedSupplier('Siren Co'); $em = $this->getEm(); $supplier->setSiren('123456789'); $em->flush(); $grid = $this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()); self::assertContains('SIREN', $grid[0]); self::assertStringContainsString('123456789', $this->flatten($grid)); } public function testSirenColumnAbsentWithoutAccountingView(): void { // Seed via admin, puis relecture par un user qui n'a QUE suppliers.view. $admin = $this->createAdminClient(); $supplier = $this->seedSupplier('No Siren Co'); $em = $this->getEm(); $supplier->setSiren('987654321'); $em->flush(); $creds = $this->createUserWithPermission('commercial.suppliers.view'); $viewer = $this->authenticatedClient($creds['username'], $creds['password']); $grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent()); self::assertNotContains('SIREN', $grid[0]); self::assertStringNotContainsString('987654321', $this->flatten($grid)); } /** * Gating SIREN prouve via une permission EXPLICITE (et non le bypass admin) : * un user minimal portant uniquement commercial.suppliers.view + * commercial.suppliers.accounting.view voit bien la colonne SIREN et sa * valeur. Complement de testSirenColumnPresentWithAccountingView (admin), qui * ne prouve pas que accounting.view SEULE suffit (l'admin bypasse le RBAC). * Le pendant negatif (sans accounting.view -> colonne absente) est couvert par * testSirenColumnAbsentWithoutAccountingView. */ public function testSirenColumnPresentForMinimalUserWithAccountingView(): void { // Seed via admin, puis relecture par un user non-admin a 2 permissions. $this->createAdminClient(); $supplier = $this->seedSupplier('Gated Siren Co'); $em = $this->getEm(); $supplier->setSiren('456789123'); $em->flush(); $creds = $this->createUserWithPermissions([ 'commercial.suppliers.view', 'commercial.suppliers.accounting.view', ]); $viewer = $this->authenticatedClient($creds['username'], $creds['password']); $grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent()); self::assertContains('SIREN', $grid[0]); self::assertStringContainsString('456789123', $this->flatten($grid)); } /** * Dedup F3 : un fournisseur portant >= 2 categories FOURNISSEUR est multiplie * par la jointure (selection/hydratation des collections) ; l'export doit le * rendre sur UNE SEULE ligne. On seede un fournisseur a 2 categories et on * assert qu'il n'apparait qu'une fois dans la colonne « Nom fournisseur ». */ public function testExportDeduplicatesSupplierWithMultipleCategories(): void { $client = $this->createAdminClient(); $supplier = $this->seedSupplier('Multi Cat Co', false, 'NEGOCIANT'); // 2e categorie FOURNISSEUR sur le meme fournisseur (RG-2.10). $supplier->addCategory($this->supplierCategory('GROSSISTE')); $this->getEm()->flush(); $names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent()); $occurrences = count(array_filter($names, static fn (string $name): bool => 'MULTI CAT CO' === $name)); self::assertSame( 1, $occurrences, 'Un fournisseur multi-categories doit apparaitre sur une seule ligne (dedup F3).', ); } public function testForbiddenWithoutSuppliersViewPermission(): void { $creds = $this->createUserWithPermission('core.users.view'); $client = $this->authenticatedClient($creds['username'], $creds['password']); $client->request('GET', self::EXPORT_URL); self::assertResponseStatusCodeSame(403); } public function testUnauthorizedWhenAnonymous(): void { $client = self::createClient(); $client->request('GET', self::EXPORT_URL); self::assertResponseStatusCodeSame(401); } /** * Relit le binaire XLSX d'une reponse et renvoie la grille de cellules. * * @return array> */ private function gridFromResponse(string $binary): array { $tmp = tempnam(sys_get_temp_dir(), 'xlsx_export_test_'); self::assertIsString($tmp); file_put_contents($tmp, $binary); try { return IOFactory::load($tmp)->getActiveSheet()->toArray(); } finally { @unlink($tmp); } } /** * Extrait la colonne « Nom fournisseur » (1re colonne) des lignes de donnees. * * @return list */ private function companyNames(string $binary): array { $grid = $this->gridFromResponse($binary); $rows = array_slice($grid, 1); // saute l'en-tete return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows)); } /** * Renvoie la ligne de donnees dont la 1re colonne (nom) vaut $companyName. * * @return null|array */ private function rowFor(string $binary, string $companyName): ?array { foreach (array_slice($this->gridFromResponse($binary), 1) as $row) { if ((string) ($row[0] ?? '') === $companyName) { return $row; } } return null; } /** * Aplatit toute la grille en une chaine, pour les assertions de presence. * * @param array> $grid */ private function flatten(array $grid): string { return implode('|', array_map( static fn (array $row): string => implode('|', array_map(static fn ($cell): string => (string) $cell, $row)), $grid, )); } }