Files
Starseed/tests/Module/Commercial/Api/SupplierExportControllerTest.php
T
Matthieu 380b0c9ef1
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m29s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m6s
test(commercial) : SupplierExportControllerTest sur base fournisseurs (catégories FOURNISSEUR, dédup F3) (ERP-113)
Fait étendre SupplierExportControllerTest à AbstractSupplierApiTestCase au
lieu d'AbstractCommercialApiTestCase. Supprime le seedSupplier() privé (qui
seedait des catégories de type CLIENT via createCategory(), violant RG-2.10)
et le tearDown() redondant, désormais portés par la base sur des catégories
FOURNISSEUR.

Le contact principal utilise le helper addContact() de la base ; le téléphone
secondaire, non porté par ce helper, est posé via le setter sur le contact
retourné. L'assertion de la colonne Catégories dérive le libellé attendu de
supplierCategory('NEGOCIANT') au lieu de hardcoder le préfixe de nom de test.
2026-06-08 09:49:33 +02:00

259 lines
9.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\SupplierAddress;
use App\Module\Sites\Domain\Entity\Site;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
* Tests fonctionnels de l'export XLSX du repertoire fournisseurs (M2, § 4.6).
* Jumeau du {@see ClientExportControllerTest} (M1).
*
* Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des
* archives par defaut, respect du filtre ?search, peuplement des colonnes
* contact principal / categories / sites, gating de la colonne SIREN selon
* commercial.suppliers.accounting.view, 403 sans commercial.suppliers.view,
* 401 anonyme.
*
* @internal
*/
final class SupplierExportControllerTest extends AbstractSupplierApiTestCase
{
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
private const string EXPORT_URL = '/api/suppliers/export.xlsx';
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
{
$client = $this->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));
}
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<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 « Nom fournisseur » (1re colonne) des lignes de donnees.
*
* @return list<string>
*/
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<int, mixed>
*/
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<int, array<int, mixed>> $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,
));
}
}