createAdminClient(); $this->seedProvider('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-prestataires-', $disposition); self::assertMatchesRegularExpression( '/filename="repertoire-prestataires-\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 prestataire', $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->seedProvider('Active One'); $this->seedProvider('Archived One', [self::SITE_86], 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->seedProvider('Searchable Alpha'); $this->seedProvider('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(); $provider = $this->seedProvider('Contact Co'); // position 1 (secondaire) insere en premier... $this->addContact($provider, 'Bob', 'Secondaire', '0600000001', 'bob@contact.co', 1); // ...position 0 (principal) insere ensuite : c'est lui qui doit gagner. $principal = $this->addContact($provider, '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 EN DIRECT par * le prestataire (RG-3.03), contrairement au fournisseur M2 (via l'adresse). */ public function testExportPopulatesCategoryAndSiteColumns(): void { $client = $this->createAdminClient(); $this->seedProvider('Hydrate Co', [self::SITE_86], false, 'NETTOYAGE'); $flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent())); // Colonne « Catégories » : libelle de la categorie PRESTATAIRE (getName()). // Derive du helper de base (idempotent) plutot que de hardcoder le prefixe. self::assertStringContainsString((string) $this->providerCategory('NETTOYAGE')->getName(), $flat); // Colonne « Sites » : site rattache en direct au prestataire (RG-3.03). self::assertStringContainsString((string) $this->site(self::SITE_86)->getName(), $flat); } public function testSirenColumnPresentWithAccountingView(): void { // L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN. $client = $this->createAdminClient(); $this->seedProvider('Siren Co', [self::SITE_86], false, 'NETTOYAGE', '123456789'); $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 providers.view. $this->createAdminClient(); $this->seedProvider('No Siren Co', [self::SITE_86], false, 'NETTOYAGE', '987654321'); $creds = $this->createUserWithPermission('technique.providers.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 technique.providers.view + * technique.providers.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 est couvert par testSirenColumnAbsentWithoutAccountingView. */ public function testSirenColumnPresentForMinimalUserWithAccountingView(): void { // Seed via admin, puis relecture par un user non-admin a 2 permissions. $this->createAdminClient(); $this->seedProvider('Gated Siren Co', [self::SITE_86], false, 'NETTOYAGE', '456789123'); $creds = $this->createUserWithPermissions([ 'technique.providers.view', 'technique.providers.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 : un prestataire portant >= 2 categories PRESTATAIRE est multiplie par * la jointure (selection/hydratation des collections) ; l'export doit le rendre * sur UNE SEULE ligne. On seede un prestataire a 2 categories et on assert qu'il * n'apparait qu'une fois dans la colonne « Nom prestataire ». */ public function testExportDeduplicatesProviderWithMultipleCategories(): void { $client = $this->createAdminClient(); $provider = $this->seedProvider('Multi Cat Co', [self::SITE_86], false, 'NETTOYAGE'); // 2e categorie PRESTATAIRE sur le meme prestataire. $provider->addCategory($this->providerCategory('SECURITE')); $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 prestataire multi-categories doit apparaitre sur une seule ligne (dedup).', ); } /** * Cloisonnement par site (RG-3.17, § 2.13) : un user non-bypass cloisonne sur * le site 86 n'exporte QUE les prestataires rattaches au site 86 — les * prestataires des sites 17 / 82 sont exclus, comme dans la liste. Pendant * export de ProviderSiteScopeTest::testListIsScopedToCurrentSiteForNonBypassUser. */ public function testExportIsScopedToCurrentSiteForNonBypassUser(): void { // Pre-requis : module Sites actif (sinon currentSite = null, cloisonnement // no-op et ce test perd son sens). $this->skipIfSitesModuleDisabled(); $this->createAdminClient(); $this->seedProvider('Presta Site 86', [self::SITE_86]); $this->seedProvider('Presta Site 17', [self::SITE_17]); $this->seedProvider('Presta Site 82', [self::SITE_82]); $creds = $this->createScopedUser( ['technique.providers.view'], sitePostalCodes: [self::SITE_86], currentSitePostalCode: self::SITE_86, ); $client = $this->authenticatedClient($creds['username'], $creds['password']); $names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent()); self::assertContains('PRESTA SITE 86', $names); self::assertNotContains('PRESTA SITE 17', $names); self::assertNotContains('PRESTA SITE 82', $names); } public function testForbiddenWithoutProvidersViewPermission(): 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 prestataire » (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, )); } }