feat(catalog) : M6 — Catalogue produits (ERP-197 → ERP-203) (#154)
Auto Tag Develop / tag (push) Successful in 11s
Auto Tag Develop / tag (push) Successful in 11s
Module **M6 — Catalogue produits** (ERP-197 → ERP-203), pile consolidée en une seule MR vers `develop` pour un CI unique. Contenu (commits) : - ERP-197 — permissions `catalog.products.*` + sidebar + 3 miroirs RBAC - ERP-198 — migration schéma M6 (storage_type, product, jonctions, type PRODUIT) - ERP-199 — entités Product + StorageType + repositories + contrat de sérialisation - ERP-200 — ProductProvider + ProductProcessor (unicité code, RG-6.03/05/06, normalisation) - ERP-201 — référentiel StorageType exposé (filtre site) + seed Figma + catégories PRODUIT - ERP-202 — export XLSX du catalogue produits (filtres liste) - ERP-203 — tests PHPUnit RG-6.01→6.10 + capture du contrat JSON produit - fix review M6 — default jsonb mort (states) + constante préfixe storage-type de test Remplace et clôt les MR #148, #149, #150, #151, #152, #153 (commits intégralement inclus ici). --------- Co-authored-by: admin malio <malio@yuno.malio.fr> Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #154
This commit was merged in pull request #154.
This commit is contained in:
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Architecture;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||
use App\Module\Commercial\Domain\Entity\Bank;
|
||||
use App\Module\Commercial\Domain\Entity\Country;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
||||
@@ -55,6 +56,10 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
|
||||
* - CategoryType : referentiel statique (codes de typage des categories),
|
||||
* pas de besoin de tracabilite user-driven (cree par migration/seed,
|
||||
* pas pilote utilisateur au M0). Cf. spec-back § 2.8.bis + RG-1.17.
|
||||
* - StorageType (M6, ERP-199) : referentiel PROVISOIRE des types de stockage
|
||||
* (en attente liste Aurore — HP-M6-02), cree par migration + seede (ERP-201),
|
||||
* lecture seule au M6. Pas de tracabilite user-driven, meme justification que
|
||||
* CategoryType. Cf. spec-back M6 § 2.4 + § 2.6.
|
||||
* - TvaMode / PaymentDelay / PaymentType / Bank (M1 Commercial) : referentiels
|
||||
* comptables statiques (id/code/label/position), seedes par migration +
|
||||
* CommercialReferentialFixtures, lecture seule au M1 (HP-M2-2). Pas de
|
||||
@@ -75,6 +80,7 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
|
||||
Permission::class,
|
||||
Site::class,
|
||||
CategoryType::class,
|
||||
StorageType::class,
|
||||
TvaMode::class,
|
||||
PaymentDelay::class,
|
||||
PaymentType::class,
|
||||
|
||||
@@ -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,291 @@
|
||||
<?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';
|
||||
|
||||
/** Prefixe des codes de types de stockage seedes par ce test (cible du cleanup tearDown). */
|
||||
private const string TEST_STORAGE_PREFIX = 'TEST_';
|
||||
|
||||
/**
|
||||
* 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_STORAGE_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(self::TEST_STORAGE_PREFIX.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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user