feat(catalog) : ERP-202 — export XLSX du catalogue produits (filtres liste)
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use App\Module\Catalog\Domain\Entity\Product;
|
||||
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use DateTimeImmutable;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'export XLSX du catalogue produits (M6, § 4.5).
|
||||
*
|
||||
* Couvre : reponse 200 (Content-Type + Content-Disposition + en-tetes de
|
||||
* colonnes), exclusion des produits soft-deleted par defaut (RG-6.09), respect
|
||||
* des filtres ?search et ?state, peuplement des colonnes metier (etats joints,
|
||||
* categorie, sites, types de stockage, fabrique / contient melasse), 403 sans
|
||||
* catalog.products.view, 401 anonyme.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProductExportControllerTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
private const string EXPORT_URL = '/api/products/export.xlsx';
|
||||
|
||||
/**
|
||||
* Purge des produits + types de stockage de test AVANT le cleanup parent :
|
||||
* product reference category / site / storage_type en FK ON DELETE RESTRICT,
|
||||
* donc les categories ne peuvent etre supprimees tant que des produits les
|
||||
* referencent. La suppression des produits cascade les jonctions
|
||||
* product_site / product_storage_type au niveau base (ON DELETE CASCADE).
|
||||
*/
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$em->createQuery('DELETE FROM '.Product::class)->execute();
|
||||
$em->createQuery('DELETE FROM '.StorageType::class.' s WHERE s.code LIKE :prefix')
|
||||
->setParameter('prefix', self::TEST_CATEGORY_TYPE_PREFIX.'%')
|
||||
->execute()
|
||||
;
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testExportReturnsXlsxResponseWithHeaderRow(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProduct('TEST_PRD_A', '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="catalogue-produits-', $disposition);
|
||||
self::assertMatchesRegularExpression(
|
||||
'/filename="catalogue-produits-\d{8}\.xlsx"/',
|
||||
$disposition,
|
||||
);
|
||||
|
||||
// Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes.
|
||||
$grid = $this->gridFromResponse($response->getContent());
|
||||
$headerCells = $grid[0];
|
||||
self::assertSame('Numéro', $headerCells[0]);
|
||||
self::assertSame('Nom', $headerCells[1]);
|
||||
self::assertContains('États', $headerCells);
|
||||
self::assertContains('Catégorie', $headerCells);
|
||||
self::assertContains('Sites', $headerCells);
|
||||
self::assertContains('Types de stockage', $headerCells);
|
||||
self::assertContains('Fabriqué', $headerCells);
|
||||
self::assertContains('Contient mélasse', $headerCells);
|
||||
|
||||
// Au moins une ligne de donnees (le produit seede).
|
||||
self::assertContains('TEST_PRD_A', $this->codes($response->getContent()));
|
||||
}
|
||||
|
||||
public function testExportExcludesSoftDeletedByDefault(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProduct('TEST_PRD_ACTIVE', 'Active One');
|
||||
$this->seedProduct('TEST_PRD_DELETED', 'Deleted One', deletedAt: new DateTimeImmutable());
|
||||
|
||||
$codes = $this->codes($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertContains('TEST_PRD_ACTIVE', $codes);
|
||||
self::assertNotContains('TEST_PRD_DELETED', $codes);
|
||||
}
|
||||
|
||||
public function testExportRespectsSearchFilter(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProduct('TEST_PRD_SRCH', 'Searchable Alpha');
|
||||
$this->seedProduct('TEST_PRD_OTHER', 'Other Beta');
|
||||
|
||||
$codes = $this->codes(
|
||||
$client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
|
||||
);
|
||||
|
||||
self::assertContains('TEST_PRD_SRCH', $codes);
|
||||
self::assertNotContains('TEST_PRD_OTHER', $codes);
|
||||
}
|
||||
|
||||
public function testExportRespectsStateFilter(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProduct('TEST_PRD_SALE', 'Sold One', [Product::STATE_SALE]);
|
||||
$this->seedProduct('TEST_PRD_BUY', 'Bought One', [Product::STATE_PURCHASE]);
|
||||
|
||||
$codes = $this->codes(
|
||||
$client->request('GET', self::EXPORT_URL.'?state=SALE')->getContent(),
|
||||
);
|
||||
|
||||
self::assertContains('TEST_PRD_SALE', $codes);
|
||||
self::assertNotContains('TEST_PRD_BUY', $codes);
|
||||
}
|
||||
|
||||
public function testExportPopulatesAllBusinessColumns(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$site = $this->firstSite();
|
||||
$storageType = $this->seedStorageType('TEST_STP', 'Tas de test');
|
||||
$category = $this->createCategory('test_cat_export_produit');
|
||||
|
||||
$this->seedProduct(
|
||||
'TEST_PRD_FULL',
|
||||
'Complet',
|
||||
[Product::STATE_PURCHASE, Product::STATE_SALE],
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
$site,
|
||||
$storageType,
|
||||
$category,
|
||||
);
|
||||
|
||||
$row = $this->rowForCode($client->request('GET', self::EXPORT_URL)->getContent(), 'TEST_PRD_FULL');
|
||||
self::assertNotNull($row, 'Le produit seede est absent de l\'export.');
|
||||
|
||||
// 0 Numéro | 1 Nom | 2 États | 3 Catégorie | 4 Sites | 5 Types de stockage | 6 Fabriqué | 7 Contient mélasse
|
||||
self::assertSame('TEST_PRD_FULL', $row[0]);
|
||||
self::assertSame('Complet', $row[1]);
|
||||
self::assertSame('Achat, Vendu', $row[2]);
|
||||
self::assertSame((string) $category->getName(), $row[3]);
|
||||
self::assertSame((string) $site->getName(), $row[4]);
|
||||
self::assertSame('Tas de test', $row[5]);
|
||||
self::assertSame('Oui', $row[6]);
|
||||
self::assertSame('Oui', $row[7]);
|
||||
}
|
||||
|
||||
public function testForbiddenWithoutProductsViewPermission(): 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un produit complet (categorie + 1 site + 1 type de stockage par
|
||||
* defaut). Les relations omises sont creees a la volee. Persistance directe
|
||||
* via l'EM : on bypasse le Processor/Validator (non teste ici).
|
||||
*
|
||||
* @param list<string> $states
|
||||
*/
|
||||
private function seedProduct(
|
||||
string $code,
|
||||
string $name,
|
||||
array $states = [Product::STATE_PURCHASE],
|
||||
bool $manufactured = false,
|
||||
bool $containsMolasses = false,
|
||||
?DateTimeImmutable $deletedAt = null,
|
||||
?Site $site = null,
|
||||
?StorageType $storageType = null,
|
||||
?Category $category = null,
|
||||
): Product {
|
||||
$em = $this->getEm();
|
||||
|
||||
$product = new Product();
|
||||
$product->setCode($code);
|
||||
$product->setName($name);
|
||||
$product->setStates($states);
|
||||
$product->setManufactured($manufactured);
|
||||
$product->setContainsMolasses($containsMolasses);
|
||||
$product->setCategory($category ?? $this->createCategory());
|
||||
$product->addSite($site ?? $this->firstSite());
|
||||
$product->addStorageType($storageType ?? $this->seedStorageType('TEST_'.strtoupper(substr(bin2hex(random_bytes(4)), 0, 8))));
|
||||
$product->setDeletedAt($deletedAt);
|
||||
|
||||
$em->persist($product);
|
||||
$em->flush();
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree un type de stockage de test (code prefixe TEST_ pour le cleanup).
|
||||
*/
|
||||
private function seedStorageType(string $code, string $label = 'Type de stockage de test'): StorageType
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
$storageType = new StorageType();
|
||||
$storageType->setCode($code);
|
||||
$storageType->setLabel($label);
|
||||
|
||||
$em->persist($storageType);
|
||||
$em->flush();
|
||||
|
||||
return $storageType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Premier site seede (les sites existent en base de test, comme dans les
|
||||
* autres tests d'export).
|
||||
*/
|
||||
private function firstSite(): Site
|
||||
{
|
||||
$site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
|
||||
self::assertNotNull($site, 'Aucun site seede : impossible de seeder un produit.');
|
||||
|
||||
return $site;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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_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 » (1re colonne) des lignes de donnees.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function codes(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 colonne « Numéro » vaut $code, ou null.
|
||||
*
|
||||
* @return null|array<int, mixed>
|
||||
*/
|
||||
private function rowForCode(string $binary, string $code): ?array
|
||||
{
|
||||
$grid = $this->gridFromResponse($binary);
|
||||
foreach (array_slice($grid, 1) as $row) {
|
||||
if ((string) ($row[0] ?? '') === $code) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user