4207a4ae12
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
277 lines
10 KiB
PHP
277 lines
10 KiB
PHP
<?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;
|
|
}
|
|
}
|