9cda225bdf
Auto Tag Develop / tag (push) Successful in 8s
Correctifs issus de la review lead du stack M2 fournisseurs (ERP-84→113), répartis en priorités. Base : `develop`. Suite verte : `make test` 577 tests / 2475 assertions, `php-cs-fixer` 0 correction. ## P1 — défauts bloquants - **ERP-89** — Le message de complétude Information ne fuit plus le nom de champ technique (`(champ "%s")` retiré). Correction miroir appliquée aux deux validators (Supplier + Client), accent uniformisé. Le `propertyPath` est conservé pour le mapping inline front. - **ERP-112** — La fixture fournisseurs résout désormais la catégorie en filtrant sur le type `FOURNISSEUR` (via `CategoryInterface::getCategoryTypeCode()`), évitant de rattacher une catégorie homonyme d'un autre type (RG-2.10). - **ERP-113** — Tests d'export complétés : dédup F3 (fournisseur multi-catégories rendu sur une seule ligne) ; gating SIREN prouvé via un utilisateur minimal non-admin portant `suppliers.view` + `suppliers.accounting.view` (nouveau helper `createUserWithPermissions`). ## P2 / P3 - **ERP-86** — `maxMessage` explicite sur `competitors` (Supplier). - **ERP-92** — Garde `skipIfSitesModuleDisabled()` sur le test POST adresse sans site (évite un faux positif si le module Sites est désactivé). - **ERP-89 bis** — Nouveau test : Admin authentifié non-Commerciale + Information incomplète → 200 (distinct du cas `user=null`). - **ERP-85** — `down()` de la migration fournisseurs en `DROP TABLE IF EXISTS`. - **ERP-87** — Reset de la mémoïsation payload en début de `process()` du SupplierProcessor + documentation du filtre `?archivedOnly` de l'export (parité avec le provider liste). - **spec-back.md (M2)** — Alignée sur le code (le code fait foi) : security PATCH `manage or accounting.manage`, gating accounting par ajout de groupe (`SupplierReadGroupContextBuilder`), anti-N+1 via `hydrateListCollections` (pas de fetch-join), types de colonnes réels (`IDENTITY` / `TIMESTAMP(0)`). ## Alignement M1 ↔ M2 - **ERP-86/87 (Client)** — Mêmes corrections appliquées aux jumeaux M1 : message `competitors` explicite + reset mémoïsation `ClientProcessor`. ## Décision actée - **RG-2.10 (catégorie)** : court-circuit conservé (une seule violation sur `categories`). Les violations partageant path + message sont fusionnées côté front ; ERP-101 (toutes les erreurs en un aller-retour) est déjà respecté car le Callback n'interrompt pas la validation des autres champs. --------- Co-authored-by: admin malio <malio@yuno.malio.fr> Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #74 Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
314 lines
12 KiB
PHP
314 lines
12 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 (admin ET user minimal a permission
|
|
* explicite), dedup F3 (fournisseur multi-categories rendu sur une seule ligne),
|
|
* 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));
|
|
}
|
|
|
|
/**
|
|
* Gating SIREN prouve via une permission EXPLICITE (et non le bypass admin) :
|
|
* un user minimal portant uniquement commercial.suppliers.view +
|
|
* commercial.suppliers.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 (sans accounting.view -> colonne absente) est couvert par
|
|
* testSirenColumnAbsentWithoutAccountingView.
|
|
*/
|
|
public function testSirenColumnPresentForMinimalUserWithAccountingView(): void
|
|
{
|
|
// Seed via admin, puis relecture par un user non-admin a 2 permissions.
|
|
$this->createAdminClient();
|
|
$supplier = $this->seedSupplier('Gated Siren Co');
|
|
$em = $this->getEm();
|
|
$supplier->setSiren('456789123');
|
|
$em->flush();
|
|
|
|
$creds = $this->createUserWithPermissions([
|
|
'commercial.suppliers.view',
|
|
'commercial.suppliers.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 F3 : un fournisseur portant >= 2 categories FOURNISSEUR est multiplie
|
|
* par la jointure (selection/hydratation des collections) ; l'export doit le
|
|
* rendre sur UNE SEULE ligne. On seede un fournisseur a 2 categories et on
|
|
* assert qu'il n'apparait qu'une fois dans la colonne « Nom fournisseur ».
|
|
*/
|
|
public function testExportDeduplicatesSupplierWithMultipleCategories(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
$supplier = $this->seedSupplier('Multi Cat Co', false, 'NEGOCIANT');
|
|
// 2e categorie FOURNISSEUR sur le meme fournisseur (RG-2.10).
|
|
$supplier->addCategory($this->supplierCategory('GROSSISTE'));
|
|
$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 fournisseur multi-categories doit apparaitre sur une seule ligne (dedup F3).',
|
|
);
|
|
}
|
|
|
|
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,
|
|
));
|
|
}
|
|
}
|