7075f0f95d
- Storage.setStates() renormalise en liste séquentielle (array_values) : un states posté en objet JSON ne peut plus être persisté en JSONB objet (jsonb_array_length → 500). Doublons rejetés en 422 via Assert\Unique. - PhpSpreadsheetExporter écrit les cellules chaîne en TYPE_STRING explicite : neutralise l'injection de formules/DDE sur toutes les valeurs saisies (corrige aussi Produit/Client/Logistique/Supplier/Provider/Carrier). - StorageListFilters : source unique de parsing des filtres (?search, ?siteId[], ?storageTypeId, ?state), consommée par le provider ET l'export → fin des divergences (numéro « 0 » coercé à null, param tableau en 400, id non positif). - Export en streaming (toIterable + clear par lot) au lieu de getResult() : mémoire bornée. - Tests : doublon/objet states, normalisation trim RG-7.06, 422 relations nulles, absence de deletedAt, soft-delete liste discriminant, neutralisation formule, parité ?search=0, robustesse param tableau ; garde-fou Assert\Unique enregistré.
249 lines
9.3 KiB
PHP
249 lines
9.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Module\Catalog\Api;
|
|
|
|
use App\Module\Catalog\Domain\Entity\Storage;
|
|
use DateTimeImmutable;
|
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
|
|
|
/**
|
|
* Tests fonctionnels de l'export XLSX des stockages (M7, § 4.5) — ERP-214.
|
|
*
|
|
* Couvre : reponse 200 (Content-Type + Content-Disposition + en-tetes de colonnes),
|
|
* exclusion des stockages soft-deleted par defaut (RG-7.07), respect des filtres
|
|
* ?search (numero) / ?storageTypeId / ?state, peuplement des colonnes metier
|
|
* (displayName, site « Nom (Code) », type, numero, etats joints, dates), 403 sans
|
|
* catalog.storages.view, 401 anonyme.
|
|
*
|
|
* @internal
|
|
*/
|
|
final class StorageExportControllerTest extends AbstractStorageApiTestCase
|
|
{
|
|
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
|
private const string EXPORT_URL = '/api/storages/export.xlsx';
|
|
|
|
public function testExportReturnsXlsxResponseWithHeaderRow(): void
|
|
{
|
|
$client = $this->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 testFormulaInjectionIsNeutralized(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
|
|
// Numero malicieux commencant par « = » (injection de formule / DDE). Seede en
|
|
// direct (le numero contournerait de toute facon le normalizer, qui ne fait
|
|
// qu'un trim). L'export doit le restituer comme TEXTE litteral, jamais comme
|
|
// une formule evaluee : si la cellule etait une formule, IOFactory::load la
|
|
// calculerait (resultat 3 ou erreur) et « =1+2 » serait absent de la colonne.
|
|
$this->seedStorageEntity('=1+2');
|
|
|
|
$numeros = $this->numeros($client->request('GET', self::EXPORT_URL)->getContent());
|
|
|
|
self::assertContains('=1+2', $numeros, 'Le numero « =1+2 » doit etre stocke en texte, pas evalue.');
|
|
}
|
|
|
|
public function testExportKeepsSearchTermZero(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
$this->seedStorageEntity('0');
|
|
$this->seedStorageEntity('X1');
|
|
|
|
// « 0 » est un numero valide : le filtre ?search=0 NE DOIT PAS etre coerce a
|
|
// null (parite stricte avec la liste a l'ecran via StorageListFilters).
|
|
$numeros = $this->numeros($client->request('GET', self::EXPORT_URL.'?search=0')->getContent());
|
|
|
|
self::assertContains('0', $numeros);
|
|
self::assertNotContains('X1', $numeros);
|
|
}
|
|
|
|
public function testExportToleratesArrayShapedScalarParam(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
$this->seedStorageEntity('NUM-ARR');
|
|
|
|
// ?search[]=foo : parametre tableau la ou un scalaire est attendu. L'export ne
|
|
// doit pas planter en 400 (la liste le tolere) : la valeur est simplement
|
|
// ignoree -> 200 avec tous les stockages.
|
|
$response = $client->request('GET', self::EXPORT_URL.'?search[]=foo');
|
|
|
|
self::assertResponseIsSuccessful();
|
|
self::assertContains('NUM-ARR', $this->numeros($response->getContent()));
|
|
}
|
|
|
|
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<int, array<int, mixed>>
|
|
*/
|
|
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<string>
|
|
*/
|
|
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<int, mixed>
|
|
*/
|
|
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;
|
|
}
|
|
}
|