test(catalog) : ERP-203 — tests PHPUnit RG-6.01→6.10 + capture du contrat JSON produit

Couvre les RG produit (M6) en tests fonctionnels API et capture le contrat de
serialisation reel (DoD spec-back § 4.0.bis).

Tests (tests/Module/Catalog/Api/) :
- AbstractProductApiTestCase : base commune (productType find-or-create PRODUIT,
  productCategory, seedStorageType par sites, validProductPayload, dump DoD).
- ProductSerializationContractTest : POST reel + GET liste/detail ; category en
  objet embarque, sites/storageTypes en tableaux d'objets, states en tableau,
  manufactured/containsMolasses booleens presents. Dump regenerable via
  PRODUCT_DOD_DUMP=1.
- ProductCodeUniquenessTest (RG-6.01) : 409 doublon actif, collision sur forme
  normalisee, reutilisation d'un code soft-deleted (index partiel).
- ProductStatesValidationTest (RG-6.02) : >=1 etat requis, valeur hors enum 422.
- ProductConditionalFieldsTest (RG-6.03) : manufactured/containsMolasses forces
  false sans SALE (POST et PATCH), conserves avec SALE.
- ProductCategoryTypeTest (RG-6.05) : 422 si categorie non-PRODUIT.
- ProductStorageTypeBySiteTest (RG-6.06) : 422 si storageType hors sites choisis.
- ProductRBACMatrixTest : Admin 200/201 ; Bureau/Compta/Commerciale/Usine 403
  (view + manage) ; view lit mais ne gere pas.

Fix contrat de serialisation : le getter containsMolasses() ne respectait pas la
convention d'accesseur (get/is/has) -> le serialiseur n'exposait PAS le champ
dans le JSON, alors que le DoD l'exige. Renomme en isContainsMolasses() (+ unique
appelant dans ProductExportController). Defaut capte par le test de contrat.

Spec : JSON reel colle dans spec-back § 4.0.bis (remplace l'esquisse). Le contrat
reel est plus riche : la liste porte deja sites/storageTypes embarques, category
embarque categoryTypes + audit, createdBy/updatedBy en IRI, sites avec adresse.

make test vert (897 tests) ; php-cs-fixer conforme.
This commit is contained in:
Matthieu
2026-06-25 12:53:57 +02:00
parent cbc445a539
commit 5dc5e703e3
11 changed files with 1497 additions and 2 deletions
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
/**
* RBAC du catalogue produit (M6, spec-back § 5.2 — admin-only, C7).
*
* La matrice est volontairement tres restrictive : seul l'Admin porte
* `catalog.products.view` / `.manage`. Les 4 personas metier MALIO (Bureau,
* Compta, Commerciale, Usine) n'ont AUCUNE permission produit -> 403 partout.
*
* On prouve aussi que l'acces n'est pas « admin only » par hasard mais bien
* porte par les permissions : un non-admin avec `view` lit (200) mais ne peut
* pas creer (403, refus au niveau securite avant denormalisation).
*
* Note : on ne teste pas « un non-admin avec `manage` cree un produit » — ce role
* n'existe dans aucun persona (catalogue admin-only) et un tel user ne pourrait
* de toute facon pas resoudre les IRI sites / categories / storage_types lors de
* la denormalisation (ces ressources portent leur propre controle d'acces). La
* creation par un porteur de `manage` est couverte par l'Admin (qui bypass).
*
* @internal
*/
final class ProductRBACMatrixTest extends AbstractProductApiTestCase
{
/** Personas metier sans permission produit (§ 5.2). */
private const array PERSONAS = ['Bureau', 'Compta', 'Commerciale', 'Usine'];
public function testAdminHasFullAccess(): void
{
$client = $this->createAdminClient();
$client->request('GET', '/api/products', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload(),
]);
self::assertResponseStatusCodeSame(201);
}
public function testBusinessPersonasAreForbiddenEverywhere(): void
{
// Produit existant cible des operations item (seede par l'admin via l'EM).
$product = $this->seedProductEntity();
$id = (int) $product->getId();
foreach (self::PERSONAS as $persona) {
$client = $this->createPersonaClient($persona);
$client->request('GET', '/api/products', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403, $persona.' ne doit pas lister les produits.');
$client->request('GET', '/api/products/'.$id, ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403, $persona.' ne doit pas consulter un produit.');
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload(),
]);
self::assertResponseStatusCodeSame(403, $persona.' ne doit pas creer de produit.');
$client->request('PATCH', '/api/products/'.$id, [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['name' => 'Renomme par '.$persona],
]);
self::assertResponseStatusCodeSame(403, $persona.' ne doit pas modifier un produit.');
}
}
public function testViewPermissionReadsButCannotManage(): void
{
$product = $this->seedProductEntity();
$client = $this->authView();
$client->request('GET', '/api/products', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
$client->request('GET', '/api/products/'.$product->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// view sans manage : creation refusee au niveau securite (403 avant que la
// denormalisation ne tente de resoudre les IRI -> pas de 400 parasite).
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload(),
]);
self::assertResponseStatusCodeSame(403);
}
}