createAdminClient(); $this->seedStorageEntity('NUM-A'); $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="stockages-', $disposition); self::assertMatchesRegularExpression('/filename="stockages-\d{8}\.xlsx"/', $disposition); // Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes (§ 4.5). $headerCells = $this->gridFromResponse($response->getContent())[0]; self::assertSame('Nom', $headerCells[0]); self::assertSame('Site', $headerCells[1]); self::assertSame('Type de stockage', $headerCells[2]); self::assertSame('Numéro', $headerCells[3]); self::assertSame('États', $headerCells[4]); self::assertSame('Créé le', $headerCells[5]); self::assertSame('Modifié le', $headerCells[6]); // Au moins une ligne de donnees (le stockage seede) reperee par son numero. self::assertContains('NUM-A', $this->numeros($response->getContent())); } public function testExportExcludesSoftDeletedByDefault(): void { $client = $this->createAdminClient(); $this->seedStorageEntity('NUM-ACTIVE'); $this->seedStorageEntity('NUM-DELETED', deletedAt: new DateTimeImmutable()); $numeros = $this->numeros($client->request('GET', self::EXPORT_URL)->getContent()); self::assertContains('NUM-ACTIVE', $numeros); self::assertNotContains('NUM-DELETED', $numeros); } public function testExportRespectsSearchFilter(): void { $client = $this->createAdminClient(); $this->seedStorageEntity('ALPHA-1'); $this->seedStorageEntity('BETA-2'); $numeros = $this->numeros( $client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(), ); self::assertContains('ALPHA-1', $numeros); self::assertNotContains('BETA-2', $numeros); } public function testExportRespectsStorageTypeFilter(): void { $client = $this->createAdminClient(); $typeA = $this->seedStorageType('Cellule A'); $typeB = $this->seedStorageType('Cellule B'); $this->seedStorageEntity('TYPE-A', storageType: $typeA); $this->seedStorageEntity('TYPE-B', storageType: $typeB); $numeros = $this->numeros( $client->request('GET', self::EXPORT_URL.'?storageTypeId='.$typeA->getId())->getContent(), ); self::assertContains('TYPE-A', $numeros); self::assertNotContains('TYPE-B', $numeros); } public function testExportRespectsStateFilter(): void { $client = $this->createAdminClient(); $this->seedStorageEntity('STATE-PROD', [Storage::STATE_PRODUCTION]); $this->seedStorageEntity('STATE-RECEP', [Storage::STATE_RECEPTION]); $numeros = $this->numeros( $client->request('GET', self::EXPORT_URL.'?state=PRODUCTION')->getContent(), ); self::assertContains('STATE-PROD', $numeros); self::assertNotContains('STATE-RECEP', $numeros); } public function testExportPopulatesAllBusinessColumns(): void { $client = $this->createAdminClient(); $site = $this->firstSite(); $type = $this->seedStorageType('Cellule'); $this->seedStorageEntity( 'C3', [Storage::STATE_RECEPTION, Storage::STATE_TRIAGE], site: $site, storageType: $type, ); $row = $this->rowForNumero($client->request('GET', self::EXPORT_URL)->getContent(), 'C3'); self::assertNotNull($row, 'Le stockage seede est absent de l\'export.'); // 0 Nom | 1 Site | 2 Type | 3 Numéro | 4 États | 5 Créé le | 6 Modifié le self::assertSame('Cellule C3', $row[0]); self::assertSame(sprintf('%s (%s)', $site->getName(), $site->getCode()), $row[1]); self::assertSame('Cellule', $row[2]); self::assertSame('C3', $row[3]); // Ordre canonique (Réception avant Triage) independamment de l'ordre en base. self::assertSame('Réception, Triage', $row[4]); // Dates renseignees (Timestampable) au format jj/mm/aaaa hh:mm. self::assertMatchesRegularExpression('#^\d{2}/\d{2}/\d{4} \d{2}:\d{2}$#', (string) $row[5]); self::assertMatchesRegularExpression('#^\d{2}/\d{2}/\d{4} \d{2}:\d{2}$#', (string) $row[6]); } public function testForbiddenWithoutStoragesViewPermission(): 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_storage_export_test_'); self::assertIsString($tmp); file_put_contents($tmp, $binary); try { return IOFactory::load($tmp)->getActiveSheet()->toArray(); } finally { @unlink($tmp); } } /** * Extrait la colonne « Numéro » (4e colonne, index 3) des lignes de donnees. * * @return list */ private function numeros(string $binary): array { $rows = array_slice($this->gridFromResponse($binary), 1); // saute l'en-tete return array_values(array_map(static fn (array $row): string => (string) ($row[3] ?? ''), $rows)); } /** * Renvoie la ligne de donnees dont la colonne « Numéro » vaut $numero, ou null. * * @return null|array */ private function rowForNumero(string $binary, string $numero): ?array { foreach (array_slice($this->gridFromResponse($binary), 1) as $row) { if ((string) ($row[3] ?? '') === $numero) { return $row; } } return null; } }