feat(catalog) : add stable auto-generated code to Category
Nouvelle colonne Category.code (NOT NULL, unique partiel uq_category_code), slug MAJUSCULE du nom genere par CategoryCodeGenerator et fige a la creation, expose en lecture seule. CategoryInterface::getCode() ajoute au contrat Shared. Retrofit COMMENT (Version20260528120000) rendu conscient des colonnes pour tolerer l'ajout de code au catalogue.
This commit is contained in:
@@ -39,20 +39,40 @@ final class Version20260528120000 extends AbstractMigration
|
|||||||
|
|
||||||
public function up(Schema $schema): void
|
public function up(Schema $schema): void
|
||||||
{
|
{
|
||||||
// Ne commente que les tables deja presentes a ce stade de la chaine de
|
// Ne commente que les tables ET colonnes deja presentes a ce stade de la
|
||||||
// migrations. Les modules crees plus tard (ex: M1 Commercial, 06-01)
|
// chaine de migrations. Les tables des modules crees plus tard (M1
|
||||||
// figurent desormais dans le catalogue partage mais leurs tables
|
// Commercial, 06-01) ET les colonnes ajoutees ensuite sur une table
|
||||||
// n'existent pas encore ici : elles posent leurs propres COMMENT dans
|
// existante (ex: category.code, ERP-78 06-02) figurent desormais dans le
|
||||||
// leur migration dediee (regle ABSOLUE n°12). Garde-fou indispensable,
|
// catalogue partage mais n'existent pas encore ici : elles posent leur
|
||||||
// sinon l'ajout d'un module au catalogue casse ce retrofit avec un
|
// propre COMMENT dans leur migration dediee (regle ABSOLUE n°12). Garde-fou
|
||||||
// "relation X does not exist".
|
// indispensable (table + colonne), sinon enrichir le catalogue casse ce
|
||||||
$existingTables = array_values(array_filter(
|
// retrofit avec un "relation/column X does not exist".
|
||||||
array_keys(ColumnCommentsCatalog::comments()),
|
foreach (ColumnCommentsCatalog::comments() as $table => $entries) {
|
||||||
static fn (string $table): bool => $schema->hasTable($table),
|
if (!$schema->hasTable($table)) {
|
||||||
));
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (ColumnCommentsCatalog::toSqlStatements($existingTables) as $sql) {
|
$dbTable = $schema->getTable($table);
|
||||||
$this->addSql($sql);
|
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||||
|
|
||||||
|
foreach ($entries as $column => $description) {
|
||||||
|
if ('_table' === $column) {
|
||||||
|
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$dbTable->hasColumn($column)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addSql(sprintf(
|
||||||
|
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||||
|
$quotedTable,
|
||||||
|
'"'.str_replace('"', '""', $column).'"',
|
||||||
|
$description,
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Application\Service;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
|
||||||
|
use Symfony\Component\String\Slugger\AsciiSlugger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genere le code technique stable d'une Category a partir de son nom (ERP-78).
|
||||||
|
*
|
||||||
|
* Regle (decision produit 02/06) : `code` est obligatoire et auto-genere — un
|
||||||
|
* slug MAJUSCULE du nom, sans accent, separateurs non alphanumeriques reduits a
|
||||||
|
* `_`, borne a 50 caracteres (longueur colonne). Exemples :
|
||||||
|
* - « Distributeur » -> DISTRIBUTEUR
|
||||||
|
* - « Agro-alimentaire » -> AGRO_ALIMENTAIRE
|
||||||
|
* - « Transport/Logistique » -> TRANSPORT_LOGISTIQUE
|
||||||
|
*
|
||||||
|
* Le code est FIGE a la creation (jamais recalcule sur renommage) afin de rester
|
||||||
|
* une cle deterministe stable entre environnements (RG-1.03 / RG-1.29 cote M1).
|
||||||
|
*
|
||||||
|
* Unicite : l'index partiel `uq_category_code` (WHERE deleted_at IS NULL) impose
|
||||||
|
* l'unicite parmi les categories actives. Deux noms distincts peuvent produire
|
||||||
|
* le meme slug (« Agro alimentaire » / « Agro-alimentaire ») : on suffixe alors
|
||||||
|
* le code par `_2`, `_3`... jusqu'a obtenir un code libre.
|
||||||
|
*/
|
||||||
|
final class CategoryCodeGenerator
|
||||||
|
{
|
||||||
|
/** Longueur maximale de la colonne `category.code`. */
|
||||||
|
private const int MAX_LENGTH = 50;
|
||||||
|
|
||||||
|
private readonly AsciiSlugger $slugger;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly CategoryRepositoryInterface $categoryRepository,
|
||||||
|
) {
|
||||||
|
$this->slugger = new AsciiSlugger();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slug brut (sans garantie d'unicite) — utile pour les seeds deterministes.
|
||||||
|
*/
|
||||||
|
public function slugify(string $name): string
|
||||||
|
{
|
||||||
|
$slug = $this->slugger->slug($name, '_')->upper()->toString();
|
||||||
|
|
||||||
|
// Borne a la longueur colonne, puis retire un eventuel `_` terminal
|
||||||
|
// introduit par la troncature.
|
||||||
|
$slug = substr($slug, 0, self::MAX_LENGTH);
|
||||||
|
$slug = trim($slug, '_');
|
||||||
|
|
||||||
|
// Garde-fou : un nom uniquement compose de caracteres non alphanumeriques
|
||||||
|
// (theorique, le nom est NotBlank + Length>=2) donnerait un slug vide.
|
||||||
|
return '' === $slug ? 'CATEGORY' : $slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Code unique parmi les categories actives : slug du nom, suffixe `_N` en
|
||||||
|
* cas de collision. `$excludeId` ignore la categorie courante (PATCH).
|
||||||
|
*/
|
||||||
|
public function generateUnique(string $name, ?int $excludeId = null): string
|
||||||
|
{
|
||||||
|
$base = $this->slugify($name);
|
||||||
|
$candidate = $base;
|
||||||
|
$suffix = 2;
|
||||||
|
|
||||||
|
while ($this->categoryRepository->existsActiveByCode($candidate, $excludeId)) {
|
||||||
|
$suffixStr = '_'.$suffix;
|
||||||
|
// Retronque la base pour que `base + suffixe` tienne dans 50 caracteres.
|
||||||
|
$candidate = substr($base, 0, self::MAX_LENGTH - strlen($suffixStr)).$suffixStr;
|
||||||
|
++$suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,10 +74,11 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
)]
|
)]
|
||||||
#[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)]
|
#[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)]
|
||||||
#[ORM\Table(name: 'category')]
|
#[ORM\Table(name: 'category')]
|
||||||
// Index nommes pour matcher la migration (cf. Role/Permission/Site). L'index
|
// Index nommes pour matcher la migration (cf. Role/Permission/Site). Les index
|
||||||
// unique partiel `uq_category_name_type_active` (LOWER(name), category_type_id
|
// uniques partiels `uq_category_name_type_active` (LOWER(name), category_type_id
|
||||||
// WHERE deleted_at IS NULL) reste possede par la seule migration : Doctrine ORM
|
// WHERE deleted_at IS NULL) et `uq_category_code` (code WHERE deleted_at IS NULL)
|
||||||
// ne sait pas exprimer un index fonctionnel + partiel via attribut.
|
// restent possedes par la seule migration : Doctrine ORM ne sait pas exprimer un
|
||||||
|
// index partiel via attribut.
|
||||||
#[ORM\Index(name: 'idx_category_deleted_at', columns: ['deleted_at'])]
|
#[ORM\Index(name: 'idx_category_deleted_at', columns: ['deleted_at'])]
|
||||||
#[ORM\Index(name: 'idx_category_type_id', columns: ['category_type_id'])]
|
#[ORM\Index(name: 'idx_category_type_id', columns: ['category_type_id'])]
|
||||||
#[ORM\Index(name: 'idx_category_created_by', columns: ['created_by'])]
|
#[ORM\Index(name: 'idx_category_created_by', columns: ['created_by'])]
|
||||||
@@ -109,6 +110,16 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
|
|||||||
#[Groups(['category:read', 'category:write'])]
|
#[Groups(['category:read', 'category:write'])]
|
||||||
private ?string $name = null;
|
private ?string $name = null;
|
||||||
|
|
||||||
|
// Code technique stable (slug MAJUSCULE du nom) — NOT NULL + unique parmi les
|
||||||
|
// actifs (index partiel `uq_category_code` possede par la migration). Genere
|
||||||
|
// par le CategoryProcessor a la creation puis fige (jamais recalcule sur
|
||||||
|
// renommage) : sert de cle metier deterministe (RG-1.03 / RG-1.29). Lecture
|
||||||
|
// seule cote API (hors groupe category:write) : le front filtre dessus mais
|
||||||
|
// ne le saisit pas.
|
||||||
|
#[ORM\Column(length: 50)]
|
||||||
|
#[Groups(['category:read'])]
|
||||||
|
private ?string $code = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: CategoryType::class)]
|
#[ORM\ManyToOne(targetEntity: CategoryType::class)]
|
||||||
#[ORM\JoinColumn(name: 'category_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
#[ORM\JoinColumn(name: 'category_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||||
#[Assert\NotNull(message: 'Type de catégorie obligatoire.')]
|
#[Assert\NotNull(message: 'Type de catégorie obligatoire.')]
|
||||||
@@ -141,6 +152,21 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implemente CategoryInterface : code technique stable de la categorie.
|
||||||
|
*/
|
||||||
|
public function getCode(): ?string
|
||||||
|
{
|
||||||
|
return $this->code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCode(string $code): static
|
||||||
|
{
|
||||||
|
$this->code = $code;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getCategoryType(): ?CategoryType
|
public function getCategoryType(): ?CategoryType
|
||||||
{
|
{
|
||||||
return $this->categoryType;
|
return $this->categoryType;
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ interface CategoryRepositoryInterface
|
|||||||
|
|
||||||
public function save(Category $category): void;
|
public function save(Category $category): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrai si une categorie active (deleted_at IS NULL) porte deja ce code.
|
||||||
|
* `$excludeId` exclut une categorie precise du test (cas PATCH). Sert a
|
||||||
|
* garantir l'unicite du code generee par le CategoryCodeGenerator (ERP-78).
|
||||||
|
*/
|
||||||
|
public function existsActiveByCode(string $code, ?int $excludeId = null): bool;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construit un QueryBuilder de liste avec filtre soft-delete et tri par defaut.
|
* Construit un QueryBuilder de liste avec filtre soft-delete et tri par defaut.
|
||||||
* - $includeDeleted = false : exclut les categories soft-deleted (RG-1.08)
|
* - $includeDeleted = false : exclut les categories soft-deleted (RG-1.08)
|
||||||
|
|||||||
+17
-4
@@ -7,6 +7,7 @@ namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor;
|
|||||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Module\Catalog\Application\Service\CategoryCodeGenerator;
|
||||||
use App\Module\Catalog\Domain\Entity\Category;
|
use App\Module\Catalog\Domain\Entity\Category;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||||
@@ -16,10 +17,13 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
|
|||||||
/**
|
/**
|
||||||
* Processor Category : applique les regles de gestion en ecriture.
|
* Processor Category : applique les regles de gestion en ecriture.
|
||||||
*
|
*
|
||||||
* - POST / PATCH : trim du nom (RG-1.03) puis delegation au persist_processor
|
* - POST / PATCH : trim du nom (RG-1.03) ; a la CREATION, generation du `code`
|
||||||
* Doctrine ORM. Toute UniqueConstraintViolationException remontee par Postgres
|
* technique stable (slug MAJUSCULE du nom, unique parmi les actifs — ERP-78)
|
||||||
* (collision sur l'index partiel uq_category_name_type_active) est traduite
|
* via CategoryCodeGenerator ; puis delegation au persist_processor Doctrine
|
||||||
* en HTTP 409 avec le message attendu par la spec (RG-1.07).
|
* ORM. Le code est FIGE a la creation (jamais recalcule sur PATCH). Toute
|
||||||
|
* UniqueConstraintViolationException remontee par Postgres (collision sur
|
||||||
|
* l'index partiel uq_category_name_type_active) est traduite en HTTP 409 avec
|
||||||
|
* le message attendu par la spec (RG-1.07).
|
||||||
* - DELETE : soft delete (RG-1.12). On NE delegue PAS au remove_processor ;
|
* - DELETE : soft delete (RG-1.12). On NE delegue PAS au remove_processor ;
|
||||||
* on pose deletedAt = now() puis on delegue au persist_processor pour que
|
* on pose deletedAt = now() puis on delegue au persist_processor pour que
|
||||||
* le UPDATE Doctrine parte et que le TimestampableBlamableSubscriber mette
|
* le UPDATE Doctrine parte et que le TimestampableBlamableSubscriber mette
|
||||||
@@ -32,6 +36,7 @@ final class CategoryProcessor implements ProcessorInterface
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||||
private readonly ProcessorInterface $persistProcessor,
|
private readonly ProcessorInterface $persistProcessor,
|
||||||
|
private readonly CategoryCodeGenerator $codeGenerator,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||||
@@ -62,6 +67,14 @@ final class CategoryProcessor implements ProcessorInterface
|
|||||||
$data->setName(trim($data->getName()));
|
$data->setName(trim($data->getName()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ERP-78 : le code est genere a la CREATION puis fige. On le (re)genere
|
||||||
|
// uniquement s'il est absent (POST, ou entite seedee sans code) — un PATCH
|
||||||
|
// sur une categorie existante conserve son code. Genere depuis le nom
|
||||||
|
// (NotBlank, deja trimme), unique parmi les actifs.
|
||||||
|
if (null === $data->getCode() && null !== $data->getName()) {
|
||||||
|
$data->setCode($this->codeGenerator->generateUnique($data->getName(), $data->getId()));
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
} catch (UniqueConstraintViolationException $e) {
|
} catch (UniqueConstraintViolationException $e) {
|
||||||
|
|||||||
@@ -31,6 +31,23 @@ class DoctrineCategoryRepository extends ServiceEntityRepository implements Cate
|
|||||||
$this->getEntityManager()->flush();
|
$this->getEntityManager()->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function existsActiveByCode(string $code, ?int $excludeId = null): bool
|
||||||
|
{
|
||||||
|
$qb = $this->createQueryBuilder('c')
|
||||||
|
->select('1')
|
||||||
|
->andWhere('c.code = :code')
|
||||||
|
->andWhere('c.deletedAt IS NULL')
|
||||||
|
->setParameter('code', $code)
|
||||||
|
->setMaxResults(1)
|
||||||
|
;
|
||||||
|
|
||||||
|
if (null !== $excludeId) {
|
||||||
|
$qb->andWhere('c.id != :excludeId')->setParameter('excludeId', $excludeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [] !== $qb->getQuery()->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
public function createListQueryBuilder(bool $includeDeleted = false): QueryBuilder
|
public function createListQueryBuilder(bool $includeDeleted = false): QueryBuilder
|
||||||
{
|
{
|
||||||
$qb = $this->createQueryBuilder('c')
|
$qb = $this->createQueryBuilder('c')
|
||||||
|
|||||||
@@ -20,13 +20,25 @@ interface CategoryInterface
|
|||||||
|
|
||||||
public function getName(): ?string;
|
public function getName(): ?string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Code technique stable de la categorie (Category::code), ou null si non
|
||||||
|
* encore renseigne. Slug MAJUSCULE derive du nom a la creation, fige ensuite.
|
||||||
|
* Expose pour permettre a un module tiers de filtrer/valider par categorie
|
||||||
|
* metier sans dependre du libelle (`name`) ni de l'`id` (non deterministe
|
||||||
|
* entre environnements) ni importer la classe concrete Category (regle
|
||||||
|
* ABSOLUE n°1). Pilote, cote M1 Commercial :
|
||||||
|
* - RG-1.03 : un distributor doit referencer un client portant la categorie
|
||||||
|
* de code DISTRIBUTEUR (resp. COURTIER pour broker) ;
|
||||||
|
* - RG-1.29 : une adresse interdit les categories de code DISTRIBUTEUR /
|
||||||
|
* COURTIER (relations entre clients, pas des attributs d'adresse).
|
||||||
|
*/
|
||||||
|
public function getCode(): ?string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Code du type de categorie rattache (CategoryType::code), ou null si la
|
* Code du type de categorie rattache (CategoryType::code), ou null si la
|
||||||
* categorie n'a pas de type. Expose pour permettre a un module tiers de
|
* categorie n'a pas de type. Depuis ERP-78, le modele n'a plus qu'un seul
|
||||||
* raisonner sur le type metier (ex: M1 Commercial — RG-1.03 : un distributor
|
* type (CLIENT) : le filtrage metier passe desormais par getCode() ci-dessus.
|
||||||
* doit referencer un client categorise DISTRIBUTEUR ; RG-1.29 : categorie
|
* Conserve pour l'affichage / la retrocompatibilite.
|
||||||
* d'adresse limitee a SECTEUR/AUTRE) sans importer la classe concrete
|
|
||||||
* Category (regle ABSOLUE n°1).
|
|
||||||
*/
|
*/
|
||||||
public function getCategoryTypeCode(): ?string;
|
public function getCategoryTypeCode(): ?string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ final class ColumnCommentsCatalog
|
|||||||
'_table' => 'Categories M0 — referentiel type par category_type, soft-delete via deleted_at, unicite (LOWER(name), category_type_id) parmi les actifs.',
|
'_table' => 'Categories M0 — referentiel type par category_type, soft-delete via deleted_at, unicite (LOWER(name), category_type_id) parmi les actifs.',
|
||||||
'id' => 'Identifiant interne auto-incremente.',
|
'id' => 'Identifiant interne auto-incremente.',
|
||||||
'name' => 'Libelle de la categorie (≤ 120 caracteres) — unique par type parmi les actifs (RG-1.06).',
|
'name' => 'Libelle de la categorie (≤ 120 caracteres) — unique par type parmi les actifs (RG-1.06).',
|
||||||
|
'code' => 'Code technique stable (slug MAJUSCULE du nom, ≤ 50) — unique parmi les actifs (uq_category_code). Fige a la creation. DISTRIBUTEUR/COURTIER pilotent RG-1.03/1.29.',
|
||||||
'category_type_id' => 'Reference au type de la categorie — FK -> category_type.id, ON DELETE RESTRICT (un type ne peut etre supprime tant qu il a des categories).',
|
'category_type_id' => 'Reference au type de la categorie — FK -> category_type.id, ON DELETE RESTRICT (un type ne peut etre supprime tant qu il a des categories).',
|
||||||
'deleted_at' => 'Horodatage UTC du soft-delete (archivage logique) — null si la categorie est active.',
|
'deleted_at' => 'Horodatage UTC du soft-delete (archivage logique) — null si la categorie est active.',
|
||||||
] + self::timestampableBlamableComments(),
|
] + self::timestampableBlamableComments(),
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ abstract class AbstractCatalogApiTestCase extends AbstractApiTestCase
|
|||||||
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
|
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||||
$category = new Category();
|
$category = new Category();
|
||||||
$category->setName($name ?? self::TEST_CATEGORY_PREFIX.$suffix);
|
$category->setName($name ?? self::TEST_CATEGORY_PREFIX.$suffix);
|
||||||
|
// ERP-78 : code NOT NULL + unique parmi les actifs (uq_category_code).
|
||||||
|
// Nonce aleatoire -> unicite garantie entre seeds successifs du test.
|
||||||
|
$category->setCode('TEST_'.strtoupper($suffix));
|
||||||
$category->setCategoryType($type);
|
$category->setCategoryType($type);
|
||||||
if (null !== $deletedAt) {
|
if (null !== $deletedAt) {
|
||||||
$category->setDeletedAt($deletedAt);
|
$category->setDeletedAt($deletedAt);
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Catalog\Api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests ERP-78 : le `code` technique stable de Category.
|
||||||
|
*
|
||||||
|
* Cas couverts :
|
||||||
|
* - POST : le code est auto-genere (slug MAJUSCULE du nom) et expose en lecture ;
|
||||||
|
* - le code est en lecture seule : un `code` envoye dans le payload est ignore
|
||||||
|
* (genere depuis le nom) ;
|
||||||
|
* - deux noms produisant le meme slug recoivent des codes distincts (suffixe).
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class CategoryCodeTest extends AbstractCatalogApiTestCase
|
||||||
|
{
|
||||||
|
public function testPostGeneratesAndExposesCode(): void
|
||||||
|
{
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
|
||||||
|
$response = $client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => self::TEST_CATEGORY_PREFIX.'Agro-alimentaire',
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
|
||||||
|
$payload = $response->toArray();
|
||||||
|
// Slug MAJUSCULE du nom, separateurs non alphanumeriques -> `_`.
|
||||||
|
self::assertSame(
|
||||||
|
strtoupper(self::TEST_CATEGORY_PREFIX).'AGRO_ALIMENTAIRE',
|
||||||
|
$payload['code'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCodeIsReadOnlyAndIgnoredFromPayload(): void
|
||||||
|
{
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
|
||||||
|
$response = $client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => self::TEST_CATEGORY_PREFIX.'readonly',
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
// Le client tente d'imposer un code : doit etre ignore.
|
||||||
|
'code' => 'CLIENT_FORGED',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
|
||||||
|
$payload = $response->toArray();
|
||||||
|
self::assertNotSame('CLIENT_FORGED', $payload['code']);
|
||||||
|
self::assertSame(strtoupper(self::TEST_CATEGORY_PREFIX).'READONLY', $payload['code']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCollidingSlugsGetDistinctCodes(): void
|
||||||
|
{
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
|
||||||
|
// Deux noms differents (donc autorises par uq_category_name_type_active)
|
||||||
|
// mais qui produisent le meme slug -> codes distincts (suffixe `_2`).
|
||||||
|
$first = $client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => self::TEST_CATEGORY_PREFIX.'Agro Plus',
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
],
|
||||||
|
])->toArray();
|
||||||
|
|
||||||
|
$second = $client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => self::TEST_CATEGORY_PREFIX.'Agro-Plus',
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
],
|
||||||
|
])->toArray();
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
self::assertNotSame($first['code'], $second['code']);
|
||||||
|
self::assertStringEndsWith('_2', (string) $second['code']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user