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,276 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Catalog\Domain\Entity\CategoryType;
use App\Module\Catalog\Domain\Entity\Product;
use App\Module\Catalog\Domain\Entity\StorageType;
use App\Module\Sites\Domain\Entity\Site;
use DateTimeImmutable;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* Classe de base des tests fonctionnels de l'entite Product (M6, module Catalog).
*
* Etend la base Catalog (factories Category / CategoryType + helpers d'auth) et
* ajoute ce qu'il faut pour exercer l'API produit de bout en bout :
* - `productType()` : recupere (ou cree) le CategoryType `PRODUIT`. Necessaire
* car le cleanup parent purge TOUS les category_type entre deux tests Catalog,
* donc le type seede par la migration M6 disparait : on le re-materialise a la
* volee pour que les POST passent RG-6.05.
* - `productCategory()` / `nonProductCategory()` : categories de test rattachees
* (ou non) au type PRODUIT.
* - `seedStorageType()` : type de stockage de test (prefixe code pour cleanup),
* rattachable a des sites precis (RG-6.06).
* - `siteByCode()` / `firstSite()` : sites fixtures (86 / 17 / 82).
* - `authView()` : user non-admin portant la permission `catalog.products.view`.
* - `validProductPayload()` : payload POST de reference (IRIs category/sites/
* storageTypes), surchargeable par cle.
* - `iri()` / `memberById()` : utilitaires Hydra.
*
* Cleanup : on purge les produits (toute la table — aucune fixture produit en
* env test) AVANT le parent, car product reference category / site / storage_type
* en FK ON DELETE RESTRICT (le parent supprime ensuite les categories/types). Les
* types de stockage de test (prefixe code) sont purges dans la foulee.
*
* @internal
*/
abstract class AbstractProductApiTestCase extends AbstractCatalogApiTestCase
{
protected const string LD = 'application/ld+json';
protected const string MERGE = 'application/merge-patch+json';
/** Code du type de categorie produit, seede par la migration M6 (§ 2.5). */
protected const string PRODUCT_TYPE_CODE = 'PRODUIT';
/** Prefixe des codes de StorageType seedes par ces tests (purge ciblee). */
protected const string TEST_STORAGE_TYPE_PREFIX = 'TESTPRD';
protected function tearDown(): void
{
$em = $this->getEm();
// Produits d'abord : ils referencent category / site / storage_type en FK
// RESTRICT, donc le parent ne pourrait pas purger les categories tant
// qu'un produit les pointe. Les jonctions product_site /
// product_storage_type cascadent au niveau base (ON DELETE CASCADE).
$em->createQuery('DELETE FROM '.Product::class)->execute();
// Types de stockage de test (prefixe code) — libere storage_type_site.
$em->createQuery('DELETE FROM '.StorageType::class.' s WHERE s.code LIKE :prefix')
->setParameter('prefix', self::TEST_STORAGE_TYPE_PREFIX.'%')
->execute()
;
parent::tearDown();
}
/**
* Recupere le CategoryType `PRODUIT` (find-or-create). Le cleanup parent
* purge tous les category_type entre tests : on le recree au besoin pour que
* les POST produit satisfassent RG-6.05.
*/
protected function productType(): CategoryType
{
$em = $this->getEm();
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => self::PRODUCT_TYPE_CODE]);
if ($existing instanceof CategoryType) {
return $existing;
}
$type = new CategoryType();
$type->setCode(self::PRODUCT_TYPE_CODE);
$type->setLabel('Produit');
$em->persist($type);
$em->flush();
return $type;
}
/**
* Categorie de test rattachee au type PRODUIT (satisfait RG-6.05).
*/
protected function productCategory(?string $name = null): Category
{
// Nom laisse a null par defaut -> createCategory genere un nom aleatoire
// unique (uq_category_name_active impose LOWER(name) unique parmi les
// actives : deux categories de meme nom dans un test collisionneraient).
return $this->createCategory($name, $this->productType());
}
/**
* Categorie de test rattachee a un type NON-PRODUIT (viole RG-6.05).
*/
protected function nonProductCategory(): Category
{
return $this->createCategory(null, $this->createCategoryType());
}
/**
* Cree un type de stockage de test (code prefixe TESTPRD pour le cleanup),
* rattache aux sites passes (disponibilite — RG-6.06).
*/
protected function seedStorageType(string $label = 'Tas de test', Site ...$sites): StorageType
{
$em = $this->getEm();
$storageType = new StorageType();
$storageType->setCode($this->uniqueCode(self::TEST_STORAGE_TYPE_PREFIX));
$storageType->setLabel($label);
foreach ($sites as $site) {
$storageType->addSite($em->getReference(Site::class, (int) $site->getId()));
}
$em->persist($storageType);
$em->flush();
return $storageType;
}
protected function siteByCode(string $code): Site
{
$site = $this->getEm()->getRepository(Site::class)->findOneBy(['code' => $code]);
self::assertInstanceOf(Site::class, $site, sprintf('Le site de code "%s" doit etre seede.', $code));
return $site;
}
protected function firstSite(): Site
{
$site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
self::assertInstanceOf(Site::class, $site, 'Un site fixture est requis (SitesFixtures).');
return $site;
}
/**
* Client non-admin portant seulement `catalog.products.view`.
*/
protected function authView(): Client
{
$creds = $this->createUserWithPermission('catalog.products.view');
return $this->authenticatedClient($creds['username'], $creds['password']);
}
/**
* Payload POST de reference : un produit valide (categorie PRODUIT, 1 site,
* 1 type de stockage disponible sur ce site). Surchargeable par cle via
* $overrides (ex: ['states' => ['SALE'], 'code' => 'X']).
*
* @param array<string, mixed> $overrides
*
* @return array<string, mixed>
*/
protected function validProductPayload(array $overrides = []): array
{
$site = $this->firstSite();
$storageType = $this->seedStorageType('Tas test', $site);
$category = $this->productCategory();
$base = [
'code' => $this->uniqueCode('TESTPRD'),
'name' => 'Produit test',
'states' => [Product::STATE_PURCHASE],
'manufactured' => false,
'containsMolasses' => false,
'category' => $this->iri('categories', (int) $category->getId()),
'sites' => [$this->iri('sites', (int) $site->getId())],
'storageTypes' => [$this->iri('storage_types', (int) $storageType->getId())],
];
return array_replace($base, $overrides);
}
/**
* Seede un produit directement via l'EM (bypass Processor/Validator). Utile
* pour disposer d'un id existant (RBAC item, PATCH) ou d'un produit
* soft-deleted (reutilisation de code — RG-6.01). La categorie / le site / le
* type de stockage manquants sont crees a la volee.
*
* @param list<string> $states
*/
protected function seedProductEntity(
?string $code = null,
array $states = [Product::STATE_PURCHASE],
?DateTimeImmutable $deletedAt = null,
?Site $site = null,
?StorageType $storageType = null,
?Category $category = null,
): Product {
$em = $this->getEm();
$site ??= $this->firstSite();
$product = new Product();
$product->setCode($code ?? $this->uniqueCode(self::TEST_STORAGE_TYPE_PREFIX));
$product->setName('Produit seed');
$product->setStates($states);
$product->setManufactured(false);
$product->setContainsMolasses(false);
$product->setCategory($category ?? $this->productCategory());
$product->addSite($em->getReference(Site::class, (int) $site->getId()));
$product->addStorageType($storageType ?? $this->seedStorageType('Seed', $site));
$product->setDeletedAt($deletedAt);
$em->persist($product);
$em->flush();
return $product;
}
/**
* Construit un IRI API Platform (`/api/{resource}/{id}`).
*/
protected function iri(string $resource, int $id): string
{
return sprintf('/api/%s/%d', $resource, $id);
}
/**
* Code unique de test (prefixe + nonce). Deja en MAJUSCULE : stable apres la
* normalisation serveur (trim + UPPER, RG-6.07).
*/
protected function uniqueCode(string $prefix): string
{
return $prefix.'_'.strtoupper(substr(bin2hex(random_bytes(5)), 0, 10));
}
/**
* Extrait les `propertyPath` des violations d'une reponse 422 (sans lever sur
* le statut non-2xx). Sert a verifier que le back identifie bien le champ
* fautif (contrat consomme par useFormErrors cote front).
*
* @return list<string>
*/
protected function violationPaths(ResponseInterface $response): array
{
$body = $response->toArray(false);
return array_values(array_map(
static fn (array $violation): string => (string) ($violation['propertyPath'] ?? ''),
$body['violations'] ?? [],
));
}
/**
* Retrouve un membre d'une collection Hydra par son id (ou null).
*
* @param array<string, mixed> $list
*
* @return null|array<string, mixed>
*/
protected function memberById(array $list, int $id): ?array
{
foreach ($list['member'] ?? [] as $member) {
if (($member['id'] ?? null) === $id) {
return $member;
}
}
return null;
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
/**
* RG-6.05 : la categorie d'un produit doit etre de type PRODUIT. Une categorie
* d'un autre type est rejetee en 422 (Assert\Callback, propertyPath `category`).
*
* @internal
*/
final class ProductCategoryTypeTest extends AbstractProductApiTestCase
{
public function testNonProductCategoryIsRejected(): void
{
$client = $this->createAdminClient();
$category = $this->nonProductCategory();
$response = $client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload([
'category' => $this->iri('categories', (int) $category->getId()),
]),
]);
self::assertResponseStatusCodeSame(422);
self::assertContains('category', $this->violationPaths($response));
}
public function testProductCategoryIsAccepted(): void
{
$client = $this->createAdminClient();
$category = $this->productCategory();
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload([
'category' => $this->iri('categories', (int) $category->getId()),
]),
]);
self::assertResponseStatusCodeSame(201);
}
}
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use App\Module\Catalog\Domain\Entity\Product;
use DateTimeImmutable;
/**
* RG-6.01 : unicite GLOBALE du code produit parmi les ACTIFS.
*
* Couvre :
* - 409 sur doublon de code actif (pre-check deterministe du Processor) ;
* - normalisation (trim + UPPER, RG-6.07) prise en compte par l'unicite : un
* code casse / entoure d'espaces collisionne avec sa forme normalisee ;
* - reutilisation possible d'un code porte par un produit soft-deleted (l'index
* partiel uq_product_code_active ne contraint que les actifs).
*
* @internal
*/
final class ProductCodeUniquenessTest extends AbstractProductApiTestCase
{
public function testDuplicateActiveCodeReturns409(): void
{
$client = $this->createAdminClient();
$code = $this->uniqueCode('TESTPRD');
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload(['code' => $code]),
]);
self::assertResponseStatusCodeSame(201);
// Meme code -> conflit (RG-6.01).
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload(['code' => $code]),
]);
self::assertResponseStatusCodeSame(409);
}
public function testNormalizedCodeCollides(): void
{
$client = $this->createAdminClient();
$code = $this->uniqueCode('TESTPRD');
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload(['code' => $code]),
]);
self::assertResponseStatusCodeSame(201);
// Variante minuscule + espaces : trim + UPPER serveur (RG-6.07) la ramene
// a la meme forme normalisee -> meme collision 409.
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload(['code' => ' '.strtolower($code).' ']),
]);
self::assertResponseStatusCodeSame(409);
}
public function testSoftDeletedCodeCanBeReused(): void
{
$client = $this->createAdminClient();
$code = $this->uniqueCode('TESTPRD');
// Produit soft-deleted portant le code (seede directement, hors index actif).
$this->seedProductEntity(
code: $code,
states: [Product::STATE_PURCHASE],
deletedAt: new DateTimeImmutable(),
);
// Le meme code est libre cote actifs -> creation acceptee (201).
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload(['code' => $code]),
]);
self::assertResponseStatusCodeSame(201);
}
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
/**
* RG-6.03 : « Fabrique » / « Contient de la melasse » saisissables uniquement si
* `states` contient SALE ; sinon forces `false` cote serveur (Processor), quoi
* que le client envoie.
*
* @internal
*/
final class ProductConditionalFieldsTest extends AbstractProductApiTestCase
{
public function testConditionalFieldsForcedFalseWithoutSale(): void
{
$client = $this->createAdminClient();
// Pas de SALE dans les etats mais champs conditionnels a true cote client.
$created = $client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload([
'states' => ['PURCHASE', 'OTHER'],
'manufactured' => true,
'containsMolasses' => true,
]),
])->toArray();
self::assertResponseStatusCodeSame(201);
// Le serveur a force les deux a false (RG-6.03).
self::assertFalse($created['manufactured']);
self::assertFalse($created['containsMolasses']);
}
public function testConditionalFieldsKeptWithSale(): void
{
$client = $this->createAdminClient();
$created = $client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload([
'states' => ['SALE'],
'manufactured' => true,
'containsMolasses' => true,
]),
])->toArray();
self::assertResponseStatusCodeSame(201);
// SALE present -> les valeurs saisies sont conservees.
self::assertTrue($created['manufactured']);
self::assertTrue($created['containsMolasses']);
}
public function testConditionalFieldsResetOnPatchRemovingSale(): void
{
$client = $this->createAdminClient();
$created = $client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload([
'states' => ['SALE'],
'manufactured' => true,
'containsMolasses' => true,
]),
])->toArray();
self::assertResponseStatusCodeSame(201);
// On retire SALE en PATCH -> les conditionnels doivent retomber a false.
$patched = $client->request('PATCH', '/api/products/'.$created['id'], [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['states' => ['PURCHASE']],
])->toArray();
self::assertResponseStatusCodeSame(200);
self::assertFalse($patched['manufactured']);
self::assertFalse($patched['containsMolasses']);
}
}
@@ -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);
}
}
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
/**
* Contrat de serialisation du produit (M6, spec-back § 4.0 / § 4.0.bis).
* Jumeau du test de contrat M5 WeighingTicketSerializationContractTest.
*
* Capture le JSON REEL (liste + detail) via un produit cree par l'API (POST reel,
* normalisation serveur reelle) et reverifie les pieges du RETEX M1 transposes au
* M6 :
* #1 : `category` sort en OBJET embarque (category:read), jamais en IRI nu.
* #2 : `sites` / `storageTypes` sortent en TABLEAUX d'OBJETS (site:read /
* storage_type:read), jamais en tableaux d'IRI.
* #3 : `states` = tableau de chaines ; `manufactured` / `containsMolasses`
* presents (booleens).
* #4 : `code` present (= « Numero » de la liste).
*
* REGLE D'OR : on asserte sur le CORPS JSON reel, jamais sur les annotations.
* DoD (§ 4.0.bis) : avec PRODUCT_DOD_DUMP positionnee, ecrit les corps liste +
* detail sous /tmp pour les coller dans la spec avant les ecrans front.
*
* @internal
*/
final class ProductSerializationContractTest extends AbstractProductApiTestCase
{
public function testListAndDetailSerializationContract(): void
{
$client = $this->createAdminClient();
// Produit cree par un POST reel (mix Achat + Vendu pour exercer les
// champs conditionnels au passage).
$created = $client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload([
'states' => ['PURCHASE', 'SALE'],
'manufactured' => true,
'containsMolasses' => true,
]),
])->toArray();
self::assertResponseStatusCodeSame(201);
$id = (int) $created['id'];
$code = (string) $created['code'];
$detail = $client->request('GET', '/api/products/'.$id, [
'headers' => ['Accept' => self::LD],
])->toArray();
$list = $client->request('GET', '/api/products?search='.$code, [
'headers' => ['Accept' => self::LD],
])->toArray();
// Enveloppe Hydra AP4 (member/totalItems sans prefixe hydra:).
self::assertArrayHasKey('member', $list);
self::assertArrayNotHasKey('hydra:member', $list);
$row = $this->memberById($list, $id);
self::assertNotNull($row, 'Le produit cree doit apparaitre dans la liste filtree.');
// === Piege #4 : code present (= « Numero ») + name ===
self::assertArrayHasKey('code', $row);
self::assertSame($code, $row['code']);
self::assertSame('Produit test', $row['name']);
// === Piege #1 : category en OBJET embarque (pas IRI nu) ===
self::assertIsArray($row['category'], 'category doit etre un objet embarque (category:read), pas un IRI nu.');
self::assertArrayHasKey('name', $row['category']);
// === Piege #3 : states tableau de chaines + booleens presents ===
self::assertSame(['PURCHASE', 'SALE'], $row['states']);
self::assertArrayHasKey('manufactured', $row);
self::assertArrayHasKey('containsMolasses', $row);
self::assertTrue($row['manufactured']);
self::assertTrue($row['containsMolasses']);
// === DETAIL : category embarque + sites / storageTypes en objets ===
self::assertIsArray($detail['category']);
self::assertArrayHasKey('name', $detail['category']);
// === Piege #2 : sites / storageTypes = tableaux d'OBJETS (pas IRI) ===
self::assertIsArray($detail['sites']);
self::assertNotEmpty($detail['sites']);
self::assertIsArray($detail['sites'][0], 'sites doit etre un tableau d\'objets (site:read), pas d\'IRI.');
self::assertArrayHasKey('name', $detail['sites'][0]);
self::assertIsArray($detail['storageTypes']);
self::assertNotEmpty($detail['storageTypes']);
self::assertIsArray($detail['storageTypes'][0], 'storageTypes doit etre un tableau d\'objets (storage_type:read), pas d\'IRI.');
self::assertArrayHasKey('label', $detail['storageTypes'][0]);
self::assertArrayHasKey('code', $detail['storageTypes'][0]);
self::assertSame(['PURCHASE', 'SALE'], $detail['states']);
$this->dumpDodIfRequested($list, $detail);
}
/**
* DoD (§ 4.0.bis) : ecrit les corps JSON reels sous /tmp si PRODUCT_DOD_DUMP
* est positionnee (sinon no-op). A coller dans spec-back.md § 4.0.bis.
*
* @param array<string, mixed> $list
* @param array<string, mixed> $detail
*/
private function dumpDodIfRequested(array $list, array $detail): void
{
if (false === getenv('PRODUCT_DOD_DUMP')) {
return;
}
$flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
file_put_contents('/tmp/product-dod-list.json', json_encode($list, $flags));
file_put_contents('/tmp/product-dod-detail.json', json_encode($detail, $flags));
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
/**
* RG-6.02 : `states` = multi-select ⊆ {PURCHASE, SALE, OTHER}, au moins 1 requis.
*
* Couvre :
* - tableau d'etats vide -> 422 (Assert\Count(min: 1)) sur le champ `states` ;
* - valeur hors enum -> 422 (Assert\Choice) sur le champ `states` ;
* - un seul etat valide -> 201 (borne basse acceptee).
*
* @internal
*/
final class ProductStatesValidationTest extends AbstractProductApiTestCase
{
public function testEmptyStatesIsRejected(): void
{
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload(['states' => []]),
]);
self::assertResponseStatusCodeSame(422);
self::assertContains('states', $this->violationPaths($response));
}
public function testUnknownStateValueIsRejected(): void
{
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload(['states' => ['PURCHASE', 'FOOBAR']]),
]);
self::assertResponseStatusCodeSame(422);
self::assertContains('states', $this->violationPaths($response));
}
public function testSingleValidStateIsAccepted(): void
{
$client = $this->createAdminClient();
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload(['states' => ['OTHER']]),
]);
self::assertResponseStatusCodeSame(201);
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
/**
* RG-6.06 : chaque type de stockage retenu doit etre disponible sur au moins un
* des sites selectionnes. Un type de stockage hors des sites du produit est
* rejete en 422 (Assert\Callback, propertyPath `storageTypes`).
*
* @internal
*/
final class ProductStorageTypeBySiteTest extends AbstractProductApiTestCase
{
public function testStorageTypeNotOnSelectedSitesIsRejected(): void
{
$client = $this->createAdminClient();
$siteA = $this->siteByCode('86');
$siteB = $this->siteByCode('17');
// Type de stockage disponible uniquement sur le site B...
$storageType = $this->seedStorageType('Cellule site B', $siteB);
// ... mais produit declare sur le site A seulement -> 422.
$response = $client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload([
'sites' => [$this->iri('sites', (int) $siteA->getId())],
'storageTypes' => [$this->iri('storage_types', (int) $storageType->getId())],
]),
]);
self::assertResponseStatusCodeSame(422);
self::assertContains('storageTypes', $this->violationPaths($response));
}
public function testStorageTypeOnSelectedSiteIsAccepted(): void
{
$client = $this->createAdminClient();
$siteA = $this->siteByCode('86');
$storageType = $this->seedStorageType('Tas site A', $siteA);
$client->request('POST', '/api/products', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validProductPayload([
'sites' => [$this->iri('sites', (int) $siteA->getId())],
'storageTypes' => [$this->iri('storage_types', (int) $storageType->getId())],
]),
]);
self::assertResponseStatusCodeSame(201);
}
}