Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e31e1759c | |||
| 4824690923 | |||
| fceb1e0e83 | |||
| adda62c1e1 | |||
| 80fabcae91 | |||
| ff6086bc4d |
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
use App\Module\Catalog\CatalogModule;
|
||||
use App\Module\Commercial\CommercialModule;
|
||||
use App\Module\Core\CoreModule;
|
||||
use App\Module\Sites\SitesModule;
|
||||
@@ -9,4 +10,5 @@ return [
|
||||
CoreModule::class,
|
||||
CommercialModule::class,
|
||||
SitesModule::class,
|
||||
CatalogModule::class,
|
||||
];
|
||||
|
||||
@@ -54,6 +54,18 @@ doctrine:
|
||||
dir: '%kernel.project_dir%/src/Module/Sites/Domain/Entity'
|
||||
prefix: 'App\Module\Sites\Domain\Entity'
|
||||
alias: Sites
|
||||
# Mapping inconditionnel du module Catalog (meme logique que Sites) :
|
||||
# la structure DB (category, category_type) existe meme si
|
||||
# CatalogModule::class n'est pas encore wire dans config/modules.php
|
||||
# (declaration du module = ticket 0.5 / ERP-47). L'ORM doit connaitre
|
||||
# les entites pour que le schema soit en phase ; l'activation
|
||||
# fonctionnelle passe exclusivement par config/modules.php.
|
||||
Catalog:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Catalog/Domain/Entity'
|
||||
prefix: 'App\Module\Catalog\Domain\Entity'
|
||||
alias: Catalog
|
||||
controller_resolver:
|
||||
auto_mapping: false
|
||||
|
||||
|
||||
@@ -83,6 +83,13 @@ return [
|
||||
'module' => 'sites',
|
||||
'permission' => 'sites.view',
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.catalog.categories',
|
||||
'to' => '/admin/categories',
|
||||
'icon' => 'mdi:tag-multiple-outline',
|
||||
'module' => 'catalog',
|
||||
'permission' => 'catalog.categories.view',
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.core.audit_log',
|
||||
'to' => '/admin/audit-log',
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.43'
|
||||
app.version: '0.1.48'
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
},
|
||||
"sites": {
|
||||
"admin": "Sites"
|
||||
},
|
||||
"catalog": {
|
||||
"categories": "Gestion des catégories"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
|
||||
@@ -35,7 +35,7 @@ export interface Persona {
|
||||
// sidebar-visibility pour driver la matrice. Les valeurs correspondent
|
||||
// aux slugs de route (`/admin/<slug>`), volontairement stables quand
|
||||
// la copie/i18n change.
|
||||
expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log'>
|
||||
expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log' | 'categories'>
|
||||
}
|
||||
|
||||
const SHARED_PASSWORD = 'e2e-secret'
|
||||
@@ -47,7 +47,7 @@ export const personas: Record<PersonaKey, Persona> = {
|
||||
password: SHARED_PASSWORD,
|
||||
isAdmin: true,
|
||||
permissions: [],
|
||||
expectedAdminLinks: ['users', 'roles', 'sites', 'audit-log'],
|
||||
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
||||
},
|
||||
'user-full': {
|
||||
key: 'user-full',
|
||||
@@ -63,8 +63,10 @@ export const personas: Record<PersonaKey, Persona> = {
|
||||
'sites.view',
|
||||
'sites.manage',
|
||||
'sites.bypass_scope',
|
||||
'catalog.categories.view',
|
||||
'catalog.categories.manage',
|
||||
],
|
||||
expectedAdminLinks: ['users', 'roles', 'sites', 'audit-log'],
|
||||
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
||||
},
|
||||
'user-readonly': {
|
||||
key: 'user-readonly',
|
||||
@@ -109,4 +111,4 @@ export function getPersona(key: PersonaKey): Persona {
|
||||
return personas[key]
|
||||
}
|
||||
|
||||
export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'audit-log'] as const
|
||||
export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'categories', 'audit-log'] as const
|
||||
|
||||
@@ -200,12 +200,20 @@ migration-migrate:
|
||||
# en DB, le purger crash.
|
||||
# 3. fixtures -> sync-permissions : fixtures:load purge la table permission,
|
||||
# donc sync doit passer apres.
|
||||
# 4. recreation index `uq_category_name_type_active` : schema:update drop
|
||||
# les index orphelins du mapping ORM. L'index partiel (LOWER + WHERE) du
|
||||
# M0 Catalog n'est pas exprimable via les attributs Doctrine ORM 3
|
||||
# (fonctionnel + partiel), donc il disparait apres schema:update. On le
|
||||
# recree par dbal:run-sql pour que les tests RG-1.07 (unicite
|
||||
# case-insensitive) voient bien la contrainte SQL. Sans ce restore, les
|
||||
# POST doublons remontent 201 au lieu de 409.
|
||||
test-db-setup:
|
||||
$(SYMFONY_CONSOLE) doctrine:database:create --env=test --if-not-exists
|
||||
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --env=test --no-interaction
|
||||
$(SYMFONY_CONSOLE) doctrine:schema:update --env=test --force
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
|
||||
|
||||
fixtures:
|
||||
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog;
|
||||
|
||||
final class CatalogModule
|
||||
{
|
||||
public const string ID = 'catalog';
|
||||
public const string LABEL = 'Catalogue';
|
||||
// REQUIRED = true : Category sera FK NOT NULL cote futurs modules Tiers
|
||||
// (M-Clients, M-Fournisseurs, M-Prestataires). Desactiver Catalog casserait
|
||||
// tout le metier au boot Doctrine. Cf. review Tristan MR #12 + spec M0 § 2.1.
|
||||
public const bool REQUIRED = true;
|
||||
|
||||
/**
|
||||
* Liste declarative des permissions RBAC exposees par le module Catalog.
|
||||
*
|
||||
* Consommee par la commande `app:sync-permissions` (SyncPermissionsCommand)
|
||||
* qui se charge d'upserter ces entrees dans la table `permission`, de
|
||||
* reactiver les codes precedemment marques orphelins et de marquer comme
|
||||
* orphelins ceux qui ont disparu du code source.
|
||||
*
|
||||
* La cle `module` est auto-injectee par le sync command a partir de
|
||||
* `self::ID`, il est donc inutile de la repeter dans chaque entree.
|
||||
*
|
||||
* Convention de nommage des codes : `module.resource[.sub].action` en
|
||||
* snake_case, le prefixe module devant correspondre exactement a
|
||||
* `self::ID` (verifie par la commande de synchronisation).
|
||||
*
|
||||
* Granularite alignee sur Core (view + manage), pas view/create/edit/delete
|
||||
* (cf. spec M0 § 2.7).
|
||||
*
|
||||
* @return array<int, array{code: string, label: string}>
|
||||
*/
|
||||
public static function permissions(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'catalog.categories.view', 'label' => 'Voir les categories'],
|
||||
['code' => 'catalog.categories.manage', 'label' => 'Gerer les categories (creer, editer, supprimer)'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor\CategoryProcessor;
|
||||
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\CategoryProvider;
|
||||
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Categorie : referentiel metier classifiant les futurs tiers (clients,
|
||||
* fournisseurs, prestataires). Porte un `name` libre et un `categoryType`
|
||||
* (FK vers le referentiel statique CategoryType).
|
||||
*
|
||||
* - Soft delete via `deletedAt` (pas de hard delete) : la liste exclut par
|
||||
* defaut les categories supprimees (cf. CategoryProvider, ticket 0.3).
|
||||
* - Timestampable + Blamable via le Trait Shared : les 4 colonnes
|
||||
* created_at / updated_at / created_by / updated_by sont remplies
|
||||
* automatiquement par le TimestampableBlamableSubscriber.
|
||||
* - `#[Auditable]` : chaque create / update / delete (soft) est trace dans
|
||||
* audit_log par l'AuditListener du module Core.
|
||||
*
|
||||
* Provider (filtre soft-delete + ?includeDeleted + tri name ASC + 404 sur
|
||||
* soft-deleted) et Processor (trim, 409 sur doublon, soft delete) branches
|
||||
* au ticket 0.3 (ERP-45).
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('catalog.categories.view')",
|
||||
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
||||
provider: CategoryProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('catalog.categories.view')",
|
||||
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
||||
provider: CategoryProvider::class,
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('catalog.categories.manage')",
|
||||
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['category:write']],
|
||||
processor: CategoryProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('catalog.categories.manage')",
|
||||
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['category:write']],
|
||||
provider: CategoryProvider::class,
|
||||
processor: CategoryProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('catalog.categories.manage')",
|
||||
provider: CategoryProvider::class,
|
||||
processor: CategoryProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)]
|
||||
#[ORM\Table(name: 'category')]
|
||||
// Index nommes pour matcher la migration (cf. Role/Permission/Site). L'index
|
||||
// unique partiel `uq_category_name_type_active` (LOWER(name), category_type_id
|
||||
// WHERE deleted_at IS NULL) reste possede par la seule migration : Doctrine ORM
|
||||
// ne sait pas exprimer un index fonctionnel + partiel via attribut.
|
||||
#[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_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_category_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
class Category implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
// === Timestampable + Blamable ===
|
||||
// Les 4 colonnes (created_at, updated_at, created_by, updated_by) + leurs
|
||||
// getters/setters viennent du Trait Shared. Le TimestampableBlamableSubscriber
|
||||
// les remplit automatiquement au prePersist / preUpdate. Aucune redeclaration
|
||||
// manuelle de ces proprietes ici.
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['category:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
// RG-1.02 + RG-1.03 : un name compose uniquement d'espaces doit declencher
|
||||
// NotBlank. Le normalizer 'trim' fait le menage avant validation, alignant
|
||||
// le comportement sur le trim cote Processor (qui s'applique apres) : ainsi
|
||||
// POST {name: " "} -> 422 et POST {name: " Vis "} -> 201 avec "Vis"
|
||||
// persiste, sans contradiction entre l'ordre Validate / Process.
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Assert\NotBlank(message: 'Le nom est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(min: 2, max: 120, normalizer: 'trim')]
|
||||
#[Groups(['category:read', 'category:write'])]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: CategoryType::class)]
|
||||
#[ORM\JoinColumn(name: 'category_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||
#[Assert\NotNull(message: 'Type de catégorie obligatoire.')]
|
||||
#[Groups(['category:read', 'category:write'])]
|
||||
private ?CategoryType $categoryType = null;
|
||||
|
||||
/**
|
||||
* Soft delete : null = active, valeur = supprimee logiquement le {date}.
|
||||
* Pas exposee en ecriture (groupe category:write exclu) : seul le DELETE,
|
||||
* via le CategoryProcessor (ticket 0.3), pose la valeur.
|
||||
*/
|
||||
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
|
||||
#[Groups(['category:read'])]
|
||||
private ?DateTimeImmutable $deletedAt = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): static
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCategoryType(): ?CategoryType
|
||||
{
|
||||
return $this->categoryType;
|
||||
}
|
||||
|
||||
public function setCategoryType(?CategoryType $categoryType): static
|
||||
{
|
||||
$this->categoryType = $categoryType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeletedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->deletedAt;
|
||||
}
|
||||
|
||||
public function setDeletedAt(?DateTimeImmutable $deletedAt): static
|
||||
{
|
||||
$this->deletedAt = $deletedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryTypeRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
/**
|
||||
* Type de categorie : referentiel statique classifiant les Category
|
||||
* (ex: MATIERE, PRODUIT, SERVICE). Cree vide par la migration M0 ; son seed
|
||||
* initial et son CRUD admin sont hors perimetre M0 (cf. spec-back § 9 HP-1).
|
||||
*
|
||||
* Lecture seule au M0 : seules les operations GetCollection et Get sont
|
||||
* exposees, sous la meme permission que Category (catalog.categories.view).
|
||||
*
|
||||
* Referentiel statique : pas de Timestampable/Blamable (whiteliste dans
|
||||
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe `category:read`
|
||||
* est ajoute sur chaque propriete pour que le type soit embarque dans la
|
||||
* reponse d'une Category (cf. .claude/rules/backend.md § Serialization).
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('catalog.categories.view')",
|
||||
normalizationContext: ['groups' => ['category_type:read']],
|
||||
// Tri par defaut requis par la spec M0 § 4.6 : ordre alphabetique
|
||||
// stable pour alimenter le <MalioSelect> du formulaire Category.
|
||||
order: ['label' => 'ASC'],
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('catalog.categories.view')",
|
||||
normalizationContext: ['groups' => ['category_type:read']],
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineCategoryTypeRepository::class)]
|
||||
#[ORM\Table(name: 'category_type')]
|
||||
// Contrainte d'unicite nommee pour matcher la migration (cf. Role/Permission).
|
||||
#[ORM\UniqueConstraint(name: 'uq_category_type_code', columns: ['code'])]
|
||||
class CategoryType
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['category_type:read', 'category:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 40)]
|
||||
#[Groups(['category_type:read', 'category:read'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['category_type:read', 'category:read'])]
|
||||
private ?string $label = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCode(): ?string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
public function setCode(string $code): static
|
||||
{
|
||||
$this->code = $code;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(string $label): static
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Domain\Repository;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
interface CategoryRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?Category;
|
||||
|
||||
public function save(Category $category): void;
|
||||
|
||||
/**
|
||||
* Construit un QueryBuilder de liste avec filtre soft-delete et tri par defaut.
|
||||
* - $includeDeleted = false : exclut les categories soft-deleted (RG-1.08)
|
||||
* - Tri : name ASC (RG-1.10).
|
||||
*/
|
||||
public function createListQueryBuilder(bool $includeDeleted = false): QueryBuilder;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Domain\Repository;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||
|
||||
interface CategoryTypeRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?CategoryType;
|
||||
|
||||
/**
|
||||
* @return list<CategoryType>
|
||||
*/
|
||||
public function findAllOrderedByLabel(): array;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
/**
|
||||
* Processor Category : applique les regles de gestion en ecriture.
|
||||
*
|
||||
* - POST / PATCH : trim du nom (RG-1.03) puis delegation au persist_processor
|
||||
* Doctrine ORM. 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 ;
|
||||
* on pose deletedAt = now() puis on delegue au persist_processor pour que
|
||||
* le UPDATE Doctrine parte et que le TimestampableBlamableSubscriber mette
|
||||
* a jour updatedAt / updatedBy (RG-1.16) en plus de l'AuditListener.
|
||||
*
|
||||
* @implements ProcessorInterface<Category, null|Category>
|
||||
*/
|
||||
final class CategoryProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof Category) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
// RG-1.12 : soft delete au lieu d'un remove physique. On bascule la DELETE
|
||||
// en UPDATE en posant deletedAt, puis on persiste via le persist_processor.
|
||||
if ($operation instanceof DeleteOperationInterface) {
|
||||
$data->setDeletedAt(new DateTimeImmutable());
|
||||
|
||||
try {
|
||||
$this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
} catch (UniqueConstraintViolationException $e) {
|
||||
// Par construction, le soft delete ne peut pas violer l'index
|
||||
// partiel (il LIBERE le couple (name, type) au lieu de le creer).
|
||||
// On laisse remonter en 500 pour signaler une anomalie reelle.
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// POST / PATCH : trim du nom avant validation et persistance (RG-1.03).
|
||||
if (null !== $data->getName()) {
|
||||
$data->setName(trim($data->getName()));
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
} catch (UniqueConstraintViolationException $e) {
|
||||
// RG-1.07 : doublon (LOWER(name), category_type_id) parmi les non-soft-deleted.
|
||||
throw new HttpException(
|
||||
409,
|
||||
sprintf('Une catégorie nommée "%s" existe déjà pour ce type.', $data->getName() ?? ''),
|
||||
$e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Provider Category : applique le filtre soft-delete par defaut (RG-1.08),
|
||||
* accepte ?includeDeleted=true pour inclure les soft-deleted (RG-1.09),
|
||||
* trie par name ASC (RG-1.10), et renvoie 404 sur Get d'une soft-deleted
|
||||
* sans le flag (RG-1.11, via retour null).
|
||||
*
|
||||
* Choix d'implementation : QueryBuilder via le repository custom plutot
|
||||
* qu'un filtre Doctrine global (cf. spec § 2.3 et arbitrage ticket 0.3).
|
||||
* Avantage : pas de magie globale, lisibilite directe du code, controle
|
||||
* fin du flag includeDeleted par requete.
|
||||
*
|
||||
* @implements ProviderInterface<Category>
|
||||
*/
|
||||
final class CategoryProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository')]
|
||||
private readonly CategoryRepositoryInterface $repository,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Category|iterable|null
|
||||
{
|
||||
$includeDeleted = $this->readIncludeDeleted($context);
|
||||
|
||||
if ($operation instanceof CollectionOperationInterface) {
|
||||
return $this->repository
|
||||
->createListQueryBuilder($includeDeleted)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
// Get unitaire : recharger l'entite, puis appliquer le filtre soft-delete.
|
||||
$id = $uriVariables['id'] ?? null;
|
||||
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$category = $this->repository->findById((int) $id);
|
||||
if (null === $category) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// RG-1.11 : 404 si soft-deleted et pas de flag includeDeleted.
|
||||
if (!$includeDeleted && null !== $category->getDeletedAt()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le flag includeDeleted depuis les filtres API Platform.
|
||||
* Accepte "true" / "1" / true (booleen).
|
||||
*/
|
||||
private function readIncludeDeleted(array $context): bool
|
||||
{
|
||||
$raw = $context['filters']['includeDeleted'] ?? false;
|
||||
|
||||
if (is_bool($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
|
||||
if (is_string($raw)) {
|
||||
return in_array(strtolower($raw), ['true', '1'], true);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Category>
|
||||
*/
|
||||
class DoctrineCategoryRepository extends ServiceEntityRepository implements CategoryRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Category::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Category
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function save(Category $category): void
|
||||
{
|
||||
$this->getEntityManager()->persist($category);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function createListQueryBuilder(bool $includeDeleted = false): QueryBuilder
|
||||
{
|
||||
$qb = $this->createQueryBuilder('c')
|
||||
->orderBy('c.name', 'ASC')
|
||||
;
|
||||
|
||||
if (!$includeDeleted) {
|
||||
$qb->andWhere('c.deletedAt IS NULL');
|
||||
}
|
||||
|
||||
return $qb;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||
use App\Module\Catalog\Domain\Repository\CategoryTypeRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<CategoryType>
|
||||
*/
|
||||
class DoctrineCategoryTypeRepository extends ServiceEntityRepository implements CategoryTypeRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, CategoryType::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?CategoryType
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<CategoryType>
|
||||
*/
|
||||
public function findAllOrderedByLabel(): array
|
||||
{
|
||||
return $this->findBy([], ['label' => 'ASC']);
|
||||
}
|
||||
}
|
||||
@@ -184,6 +184,8 @@ final class SeedE2ECommand extends Command
|
||||
'sites.view',
|
||||
'sites.manage',
|
||||
'sites.bypass_scope',
|
||||
'catalog.categories.view',
|
||||
'catalog.categories.manage',
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Architecture;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||
use App\Module\Core\Domain\Entity\Permission;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
@@ -45,15 +46,18 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
|
||||
* - Permission : idem Role (synchronise, pas pilote utilisateur).
|
||||
* - Site : referentiel admin-managed, a integrer dans un futur module Sites
|
||||
* v2 (cf. HP-10).
|
||||
* - 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.
|
||||
*
|
||||
* Les futurs referentiels statiques (ex: CategoryType au ticket 0.2)
|
||||
* s'ajoutent ici avec une justification.
|
||||
* Les futurs referentiels statiques s'ajoutent ici avec une justification.
|
||||
*/
|
||||
private const EXCLUDED = [
|
||||
User::class,
|
||||
Role::class,
|
||||
Permission::class,
|
||||
Site::class,
|
||||
CategoryType::class,
|
||||
];
|
||||
|
||||
public function testAllBusinessEntitiesImplementBothInterfaces(): void
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
<?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\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Tests\Module\Core\Api\AbstractApiTestCase;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* Classe de base pour les tests fonctionnels du module Catalog.
|
||||
*
|
||||
* Etend la base Core :
|
||||
* - factories `createCategoryType()` et `createCategory()` pour seeder vite
|
||||
* les referentiels et les entites metier dans les tests ;
|
||||
* - helpers d'authentification specifiques au M0 : `createAdminClient()`,
|
||||
* `createManageClient()`, `createViewClient()` et un helper persona
|
||||
* `createPersonaClient($label)` simulant les 4 roles MALIO sans permission
|
||||
* catalog (Bureau / Compta / Commerciale / Usine).
|
||||
*
|
||||
* Cleanup : les noms de Category sont prefixes `test_cat_` et les codes de
|
||||
* CategoryType sont prefixes `TEST_`. Le tearDown purge ces lignes, ainsi
|
||||
* que les users / roles `test_*` crees par `createUserWithPermission` et
|
||||
* `createPersonaClient`. Pas de DAMA en local, donc purge manuelle obligatoire.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
abstract class AbstractCatalogApiTestCase extends AbstractApiTestCase
|
||||
{
|
||||
protected const string TEST_CATEGORY_PREFIX = 'test_cat_';
|
||||
protected const string TEST_CATEGORY_TYPE_PREFIX = 'TEST_';
|
||||
protected const string TEST_USER_PREFIX = 'test_';
|
||||
protected const string TEST_ROLE_PREFIX = 'test_';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->cleanupCatalogTestData();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree un CategoryType de test. Le code est prefixe `TEST_` pour le
|
||||
* cleanup, suffixe par un nonce aleatoire pour eviter les collisions
|
||||
* inter-tests.
|
||||
*/
|
||||
protected function createCategoryType(?string $code = null, ?string $label = null): CategoryType
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||
$type = new CategoryType();
|
||||
$type->setCode($code ?? self::TEST_CATEGORY_TYPE_PREFIX.strtoupper($suffix));
|
||||
$type->setLabel($label ?? 'Test Type '.$suffix);
|
||||
|
||||
$em->persist($type);
|
||||
$em->flush();
|
||||
|
||||
return $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree une Category de test. Le nom est prefixe `test_cat_` pour le
|
||||
* cleanup. Si aucun type n'est fourni, un nouveau CategoryType est cree.
|
||||
* Le flag $deletedAt permet de seeder directement une categorie
|
||||
* soft-deleted (pour les tests RG-1.08 / RG-1.11).
|
||||
*/
|
||||
protected function createCategory(
|
||||
?string $name = null,
|
||||
?CategoryType $type = null,
|
||||
?DateTimeImmutable $deletedAt = null,
|
||||
): Category {
|
||||
$em = $this->getEm();
|
||||
|
||||
$type ??= $this->createCategoryType();
|
||||
|
||||
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||
$category = new Category();
|
||||
$category->setName($name ?? self::TEST_CATEGORY_PREFIX.$suffix);
|
||||
$category->setCategoryType($type);
|
||||
if (null !== $deletedAt) {
|
||||
$category->setDeletedAt($deletedAt);
|
||||
}
|
||||
|
||||
$em->persist($category);
|
||||
$em->flush();
|
||||
|
||||
return $category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client authentifie en tant qu'admin fixture (bypass via isAdmin).
|
||||
*/
|
||||
protected function createAdminClient(): Client
|
||||
{
|
||||
return $this->authenticatedClient('admin', 'admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Client non-admin portant la permission `catalog.categories.manage`.
|
||||
* Utilise pour prouver qu'un non-admin avec la permission obtient 200 /
|
||||
* 201 / 204 sur POST / PATCH / DELETE.
|
||||
*
|
||||
* @return array{client: Client, credentials: array{username: string, password: string}}
|
||||
*/
|
||||
protected function createManageClient(): array
|
||||
{
|
||||
$credentials = $this->createUserWithPermission('catalog.categories.manage');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
|
||||
return ['client' => $client, 'credentials' => $credentials];
|
||||
}
|
||||
|
||||
/**
|
||||
* Client non-admin portant la permission `catalog.categories.view`.
|
||||
*/
|
||||
protected function createViewClient(): Client
|
||||
{
|
||||
$credentials = $this->createUserWithPermission('catalog.categories.view');
|
||||
|
||||
return $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Client authentifie en tant qu'un des 4 personas metier MALIO sans
|
||||
* permission catalog. Les 4 roles (Bureau / Compta / Commerciale / Usine)
|
||||
* sont seules creees a la volee dans le test, sans aucune permission
|
||||
* catalog.categories.* attachee. Le user obtient donc systematiquement
|
||||
* 403 sur tous les endpoints `/api/categories*` et `/api/category_types*`.
|
||||
*
|
||||
* Note : ces roles ne sont pas seedes dans AppFixtures (cf. HP-8 de la
|
||||
* spec M0). Les tests les materialisent juste pour prouver que porter
|
||||
* un role metier sans la permission catalog donne bien 403.
|
||||
*/
|
||||
protected function createPersonaClient(string $personaLabel): Client
|
||||
{
|
||||
if (!self::$kernel) {
|
||||
self::bootKernel();
|
||||
}
|
||||
|
||||
$em = $this->getEm();
|
||||
|
||||
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||
$username = self::TEST_USER_PREFIX.strtolower($personaLabel).'_'.$suffix;
|
||||
$password = 'testpass';
|
||||
|
||||
/** @var UserPasswordHasherInterface $hasher */
|
||||
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
||||
|
||||
// Role nomme d'apres le persona MALIO, ZERO permission catalog.
|
||||
$role = new Role(
|
||||
self::TEST_ROLE_PREFIX.strtolower($personaLabel).'_'.$suffix,
|
||||
$personaLabel.' (test)',
|
||||
false,
|
||||
);
|
||||
$em->persist($role);
|
||||
|
||||
$user = new User();
|
||||
$user->setUsername($username);
|
||||
$user->setIsAdmin(false);
|
||||
$user->setPassword($hasher->hashPassword($user, $password));
|
||||
$user->addRbacRole($role);
|
||||
|
||||
// Rattachement aux sites pour rester aligne sur createUserWithPermission.
|
||||
foreach ($em->getRepository(Site::class)->findAll() as $site) {
|
||||
$user->addSite($site);
|
||||
}
|
||||
|
||||
$em->persist($user);
|
||||
$em->flush();
|
||||
$em->clear();
|
||||
|
||||
return $this->authenticatedClient($username, $password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge des donnees Catalog crees par les tests.
|
||||
*
|
||||
* Strategie : purge complete des tables `category` et `category_type`
|
||||
* (aucune fixture ne les remplit au M0 — la migration cree les tables
|
||||
* vides, cf. spec-back § 1 + HP-1). On evite ainsi les pieges de
|
||||
* cleanup par prefixe quand un test valide le mauvais payload (ex:
|
||||
* name="" persiste sans matcher le LIKE) et laisse des orphelins
|
||||
* bloquant le DELETE category_type par FK violation.
|
||||
*
|
||||
* Ordre :
|
||||
* 1. Categories d'abord (FK ON DELETE RESTRICT vers category_type) ;
|
||||
* 2. CategoryTypes ensuite ;
|
||||
* 3. Users / Roles `test_*` enfin (FK created_by/updated_by sur
|
||||
* category est ON DELETE SET NULL, mais on a deja purge category).
|
||||
*/
|
||||
private function cleanupCatalogTestData(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
$em->createQuery('DELETE FROM '.Category::class)->execute();
|
||||
$em->createQuery('DELETE FROM '.CategoryType::class)->execute();
|
||||
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix'
|
||||
)->setParameter('prefix', self::TEST_USER_PREFIX.'%')->execute();
|
||||
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix'
|
||||
)->setParameter('prefix', self::TEST_ROLE_PREFIX.'%')->execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Tests Audit : l'attribut `#[Auditable]` porte sur Category, donc chaque
|
||||
* POST / PATCH / DELETE doit produire une ligne dans `audit_log` via le
|
||||
* AuditListener + AuditLogWriter (cf. spec audit-log.md).
|
||||
*
|
||||
* Verifications :
|
||||
* - une ligne `entity_type='catalog.Category'` apparait apres chaque
|
||||
* operation HTTP authentifiee comme admin ;
|
||||
* - l'action est `create` / `update` (le soft delete est trace comme
|
||||
* `update` puisque c'est un UPDATE Doctrine, cf. spec § 6.1) ;
|
||||
* - `performed_by` est le username du user authentifie ;
|
||||
* - `changes` est non vide (snapshot complet pour insert, diff pour update).
|
||||
*
|
||||
* Lecture via la connexion DBAL `audit` (pattern de AuditLogApiTest).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryAuditTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
private const string ENTITY_TYPE = 'catalog.Category';
|
||||
|
||||
private ?Connection $auditConnection = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
self::bootKernel();
|
||||
|
||||
/** @var Connection $conn */
|
||||
$conn = self::getContainer()->get('doctrine.dbal.audit_connection');
|
||||
$this->auditConnection = $conn;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (null !== $this->auditConnection) {
|
||||
$this->auditConnection->close();
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testAuditLogOnCreate(): 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.'audit_create',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
$createdId = (string) $response->toArray()['id'];
|
||||
|
||||
$rows = $this->fetchAuditRows($createdId, 'create');
|
||||
self::assertCount(1, $rows, 'Un audit_log "create" doit etre genere apres POST.');
|
||||
self::assertSame('admin', $rows[0]['performed_by']);
|
||||
|
||||
$changes = $this->decodeChanges($rows[0]['changes']);
|
||||
// Snapshot complet : au moins le name doit etre dedans.
|
||||
self::assertArrayHasKey('name', $changes);
|
||||
self::assertSame(
|
||||
self::TEST_CATEGORY_PREFIX.'audit_create',
|
||||
$changes['name'] ?? null,
|
||||
'Le snapshot create doit porter le name persiste.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testAuditLogOnUpdate(): void
|
||||
{
|
||||
$category = $this->createCategory();
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('PATCH', '/api/categories/'.$category->getId(), [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['name' => self::TEST_CATEGORY_PREFIX.'audit_patched'],
|
||||
]);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$rows = $this->fetchAuditRows((string) $category->getId(), 'update');
|
||||
self::assertGreaterThanOrEqual(1, count($rows), 'Un audit_log "update" doit etre genere apres PATCH.');
|
||||
// On prend la ligne la plus recente.
|
||||
$latest = $rows[0];
|
||||
self::assertSame('admin', $latest['performed_by']);
|
||||
|
||||
$changes = $this->decodeChanges($latest['changes']);
|
||||
// L'update doit contenir la diff sur `name` : {old: ..., new: 'audit_patched'}.
|
||||
self::assertArrayHasKey('name', $changes);
|
||||
self::assertIsArray($changes['name']);
|
||||
self::assertArrayHasKey('new', $changes['name']);
|
||||
self::assertSame(self::TEST_CATEGORY_PREFIX.'audit_patched', $changes['name']['new']);
|
||||
}
|
||||
|
||||
public function testAuditLogOnSoftDelete(): void
|
||||
{
|
||||
$category = $this->createCategory();
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('DELETE', '/api/categories/'.$category->getId());
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
|
||||
// Le soft delete = UPDATE Doctrine -> action 'update' en audit, avec
|
||||
// la diff sur deletedAt (RG-1.12 + spec § 6.1).
|
||||
$rows = $this->fetchAuditRows((string) $category->getId(), 'update');
|
||||
self::assertGreaterThanOrEqual(1, count($rows), 'Un audit_log doit tracer le soft delete (en tant qu\'update).');
|
||||
$latest = $rows[0];
|
||||
$changes = $this->decodeChanges($latest['changes']);
|
||||
|
||||
self::assertArrayHasKey('deletedAt', $changes, 'La diff doit contenir deletedAt.');
|
||||
self::assertIsArray($changes['deletedAt']);
|
||||
self::assertArrayHasKey('new', $changes['deletedAt']);
|
||||
self::assertNotNull(
|
||||
$changes['deletedAt']['new'],
|
||||
'deletedAt.new doit etre rempli (timestamp ISO ou tableau Doctrine).',
|
||||
);
|
||||
}
|
||||
|
||||
public function testAuditLogPerformerCarriesAuthenticatedUsername(): void
|
||||
{
|
||||
// Manage user (non-admin) : prouve que performed_by suit l'auth, pas
|
||||
// un mock hardcode "admin".
|
||||
$type = $this->createCategoryType();
|
||||
$manage = $this->createManageClient();
|
||||
$client = $manage['client'];
|
||||
$managerUsername = $manage['credentials']['username'];
|
||||
|
||||
$response = $client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'audit_manager',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
$createdId = (string) $response->toArray()['id'];
|
||||
|
||||
$rows = $this->fetchAuditRows($createdId, 'create');
|
||||
self::assertCount(1, $rows);
|
||||
self::assertSame(
|
||||
$managerUsername,
|
||||
$rows[0]['performed_by'],
|
||||
'performed_by doit refleter le user authentifie (pas l\'admin par defaut).',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Category::class lookups via entity_id + action
|
||||
*
|
||||
* @return list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string, performed_by: string}>
|
||||
*/
|
||||
private function fetchAuditRows(string $entityId, string $action): array
|
||||
{
|
||||
/** @var list<array<string, string>> $rows */
|
||||
return $this->auditConnection->fetchAllAssociative(
|
||||
'SELECT id, entity_type, entity_id, action, changes, performed_by '
|
||||
.'FROM audit_log '
|
||||
.'WHERE entity_type = :type AND entity_id = :id AND action = :action '
|
||||
.'ORDER BY performed_at DESC',
|
||||
[
|
||||
'type' => self::ENTITY_TYPE,
|
||||
'id' => $entityId,
|
||||
'action' => $action,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decodeChanges(string $raw): array
|
||||
{
|
||||
/** @var array<string, mixed> $decoded */
|
||||
return json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Tests RG-1.12 / RG-1.13 : suppression et soft-delete de Category.
|
||||
*
|
||||
* - RG-1.12 : DELETE pose `deletedAt` au lieu d'un hard delete (la ligne
|
||||
* reste en BDD avec `deleted_at IS NOT NULL`) et renvoie 204.
|
||||
* - RG-1.13 : PATCH ne peut pas ecrire `deletedAt` (groupe `category:write`
|
||||
* l'exclut), donc une tentative d'override est silencieusement ignoree.
|
||||
* - Provider sur PATCH/DELETE : 404 si la categorie cible est deja
|
||||
* soft-deleted (cf. CategoryProvider, ticket 0.3).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryDeleteTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
public function testDeleteReturns204AndPersistsSoftDelete(): void
|
||||
{
|
||||
$category = $this->createCategory();
|
||||
$categoryId = $category->getId();
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('DELETE', '/api/categories/'.$categoryId);
|
||||
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
|
||||
// RG-1.12 : la ligne doit toujours exister en BDD avec deletedAt non null.
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
|
||||
/** @var null|Category $reloaded */
|
||||
$reloaded = $em->getRepository(Category::class)->find($categoryId);
|
||||
self::assertNotNull($reloaded, 'La ligne ne doit PAS etre supprimee physiquement (soft delete).');
|
||||
self::assertNotNull($reloaded->getDeletedAt(), 'deletedAt doit etre rempli apres DELETE.');
|
||||
}
|
||||
|
||||
public function testPatchCannotSetDeletedAt(): void
|
||||
{
|
||||
// RG-1.13 : le groupe `category:write` ne contient pas `deletedAt`,
|
||||
// donc une tentative d'override doit etre silencieusement ignoree.
|
||||
$category = $this->createCategory();
|
||||
$categoryId = $category->getId();
|
||||
self::assertNull($category->getDeletedAt());
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('PATCH', '/api/categories/'.$categoryId, [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => [
|
||||
'deletedAt' => new DateTimeImmutable()->format(DateTimeImmutable::ATOM),
|
||||
],
|
||||
]);
|
||||
|
||||
// Le code precis depend d'API Platform : 200 (champ ignore) ou 400.
|
||||
// Quoi qu'il arrive, deletedAt en BDD doit rester null.
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
|
||||
/** @var Category $reloaded */
|
||||
$reloaded = $em->getRepository(Category::class)->find($categoryId);
|
||||
self::assertNull(
|
||||
$reloaded->getDeletedAt(),
|
||||
'PATCH ne doit JAMAIS pouvoir ecrire deletedAt (RG-1.13).',
|
||||
);
|
||||
}
|
||||
|
||||
public function testPatchOnSoftDeletedReturns404(): void
|
||||
{
|
||||
// Le Provider est cable sur PATCH (cf. Category::class § Patch). Une
|
||||
// categorie deja soft-deletee n'est pas visible en lecture, donc le
|
||||
// PATCH doit recevoir 404 (route resolved by API Platform retournee
|
||||
// par le provider) — comme un Get unitaire (RG-1.11 etendue).
|
||||
$category = $this->createCategory(
|
||||
null,
|
||||
null,
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('PATCH', '/api/categories/'.$category->getId(), [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['name' => self::TEST_CATEGORY_PREFIX.'try_patch'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testDeleteOnSoftDeletedReturns404(): void
|
||||
{
|
||||
// Idem PATCH : un DELETE sur une categorie deja soft-deletee est un
|
||||
// 404 (le Provider la masque), pas une operation idempotente silencieuse.
|
||||
$category = $this->createCategory(
|
||||
null,
|
||||
null,
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('DELETE', '/api/categories/'.$category->getId());
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Tests RG-1.11 : GET /api/categories/{id}.
|
||||
*
|
||||
* - Category soft-deleted sans flag → 404 ;
|
||||
* - Category soft-deleted avec `?includeDeleted=true` → 200 ;
|
||||
* - Category inexistante → 404.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryGetTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
public function testGetActiveCategoryReturns200(): void
|
||||
{
|
||||
$category = $this->createCategory();
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories/'.$category->getId());
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertSame($category->getId(), $response->toArray()['id']);
|
||||
}
|
||||
|
||||
public function testGetSoftDeletedReturns404(): void
|
||||
{
|
||||
$category = $this->createCategory(
|
||||
null,
|
||||
null,
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('GET', '/api/categories/'.$category->getId());
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testGetSoftDeletedWithFlagReturns200(): void
|
||||
{
|
||||
$category = $this->createCategory(
|
||||
null,
|
||||
null,
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories/'.$category->getId().'?includeDeleted=true');
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
$data = $response->toArray();
|
||||
self::assertSame($category->getId(), $data['id']);
|
||||
self::assertNotNull($data['deletedAt'], 'Le champ deletedAt doit etre expose dans la reponse.');
|
||||
}
|
||||
|
||||
public function testGetNonExistentReturns404(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('GET', '/api/categories/9999999');
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Tests RG-1.08 / RG-1.09 / RG-1.10 : comportement de GET /api/categories.
|
||||
*
|
||||
* - RG-1.08 : par defaut, les categories soft-deleted sont exclues ;
|
||||
* - RG-1.09 : `?includeDeleted=true` inclut les soft-deleted ;
|
||||
* - RG-1.10 : tri par defaut `name ASC` cote serveur.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryListTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
public function testListExcludesSoftDeletedByDefault(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'alpha', $type);
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'beta', $type);
|
||||
$this->createCategory(
|
||||
self::TEST_CATEGORY_PREFIX.'gone',
|
||||
$type,
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories');
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$data = $response->toArray();
|
||||
$members = $data['member'];
|
||||
|
||||
// On filtre sur le prefix test_cat_ pour ne pas etre pollue par
|
||||
// d'autres entrees presentes en base (fixtures, autres tests).
|
||||
$names = array_values(array_filter(
|
||||
array_map(fn (array $m): string => $m['name'], $members),
|
||||
fn (string $n): bool => str_starts_with($n, self::TEST_CATEGORY_PREFIX),
|
||||
));
|
||||
|
||||
self::assertContains(self::TEST_CATEGORY_PREFIX.'alpha', $names);
|
||||
self::assertContains(self::TEST_CATEGORY_PREFIX.'beta', $names);
|
||||
self::assertNotContains(
|
||||
self::TEST_CATEGORY_PREFIX.'gone',
|
||||
$names,
|
||||
'Les categories soft-deleted doivent etre exclues par defaut (RG-1.08).',
|
||||
);
|
||||
}
|
||||
|
||||
public function testIncludeDeletedFlagSurfacesSoftDeleted(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'alpha2', $type);
|
||||
$this->createCategory(
|
||||
self::TEST_CATEGORY_PREFIX.'gone2',
|
||||
$type,
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories?includeDeleted=true');
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$names = array_values(array_filter(
|
||||
array_map(fn (array $m): string => $m['name'], $response->toArray()['member']),
|
||||
fn (string $n): bool => str_starts_with($n, self::TEST_CATEGORY_PREFIX),
|
||||
));
|
||||
|
||||
self::assertContains(self::TEST_CATEGORY_PREFIX.'alpha2', $names);
|
||||
self::assertContains(
|
||||
self::TEST_CATEGORY_PREFIX.'gone2',
|
||||
$names,
|
||||
'?includeDeleted=true doit faire apparaitre les soft-deleted (RG-1.09).',
|
||||
);
|
||||
}
|
||||
|
||||
public function testDefaultSortIsNameAsc(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
// Insertion volontairement dans le desordre pour prouver le tri.
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'zorro', $type);
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'alpha_sort', $type);
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'mid', $type);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories');
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$names = array_values(array_filter(
|
||||
array_map(fn (array $m): string => $m['name'], $response->toArray()['member']),
|
||||
fn (string $n): bool => str_starts_with($n, self::TEST_CATEGORY_PREFIX),
|
||||
));
|
||||
|
||||
// Verifie que la sous-liste de nos 3 entrees est triee croissante.
|
||||
$expectedSubset = [
|
||||
self::TEST_CATEGORY_PREFIX.'alpha_sort',
|
||||
self::TEST_CATEGORY_PREFIX.'mid',
|
||||
self::TEST_CATEGORY_PREFIX.'zorro',
|
||||
];
|
||||
|
||||
$filtered = array_values(array_intersect($names, $expectedSubset));
|
||||
self::assertSame(
|
||||
$expectedSubset,
|
||||
$filtered,
|
||||
'Les categories doivent etre retournees triees par name ASC (RG-1.10).',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
|
||||
/**
|
||||
* Tests RG-1.01 : permissions RBAC catalog.categories.view / manage.
|
||||
*
|
||||
* Verifie que :
|
||||
* - les 4 personas metier MALIO (Bureau / Compta / Commerciale / Usine) sans
|
||||
* permission catalog.categories.* obtiennent 403 sur tous les verbes des
|
||||
* endpoints `/api/categories*` et `/api/category_types*` ;
|
||||
* - un utilisateur anonyme (sans JWT) obtient 401 ;
|
||||
* - l'admin (bypass via isAdmin) obtient le code attendu (200 / 201 / 204).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryPermissionsTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
// ============ /api/categories — collection ============
|
||||
|
||||
#[DataProvider('personaProvider')]
|
||||
public function testPersonaWithoutCatalogPermissionGets403OnGetCollection(string $personaLabel): void
|
||||
{
|
||||
$client = $this->createPersonaClient($personaLabel);
|
||||
$client->request('GET', '/api/categories');
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testAnonymousGets401OnGetCollection(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client->request('GET', '/api/categories');
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testAdminGets200OnGetCollection(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('GET', '/api/categories');
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
}
|
||||
|
||||
public function testUserWithViewPermissionGets200OnGetCollection(): void
|
||||
{
|
||||
$client = $this->createViewClient();
|
||||
$client->request('GET', '/api/categories');
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
}
|
||||
|
||||
// ============ /api/categories — POST ============
|
||||
|
||||
#[DataProvider('personaProvider')]
|
||||
public function testPersonaWithoutManagePermissionGets403OnPost(string $personaLabel): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createPersonaClient($personaLabel);
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'forbidden',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testAnonymousGets401OnPost(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$client = self::createClient();
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'anon',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testAdminGets201OnPost(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'admin_create',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
public function testUserWithOnlyViewPermissionGets403OnPost(): void
|
||||
{
|
||||
// Prouve qu'avoir `view` ne suffit pas a POSTer (manage requis).
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createViewClient();
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'view_only',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
// ============ /api/categories/{id} — PATCH ============
|
||||
|
||||
#[DataProvider('personaProvider')]
|
||||
public function testPersonaWithoutManagePermissionGets403OnPatch(string $personaLabel): void
|
||||
{
|
||||
$category = $this->createCategory();
|
||||
$client = $this->createPersonaClient($personaLabel);
|
||||
$client->request('PATCH', '/api/categories/'.$category->getId(), [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['name' => self::TEST_CATEGORY_PREFIX.'patched'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
// ============ /api/categories/{id} — DELETE ============
|
||||
|
||||
#[DataProvider('personaProvider')]
|
||||
public function testPersonaWithoutManagePermissionGets403OnDelete(string $personaLabel): void
|
||||
{
|
||||
$category = $this->createCategory();
|
||||
$client = $this->createPersonaClient($personaLabel);
|
||||
$client->request('DELETE', '/api/categories/'.$category->getId());
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testAdminGets204OnDelete(): void
|
||||
{
|
||||
$category = $this->createCategory();
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('DELETE', '/api/categories/'.$category->getId());
|
||||
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
}
|
||||
|
||||
// ============ /api/category_types — referentiel ============
|
||||
|
||||
#[DataProvider('personaProvider')]
|
||||
public function testPersonaWithoutCatalogPermissionGets403OnCategoryTypes(string $personaLabel): void
|
||||
{
|
||||
$client = $this->createPersonaClient($personaLabel);
|
||||
$client->request('GET', '/api/category_types');
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function personaProvider(): iterable
|
||||
{
|
||||
yield 'Bureau' => ['Bureau'];
|
||||
|
||||
yield 'Compta' => ['Compta'];
|
||||
|
||||
yield 'Commerciale' => ['Commerciale'];
|
||||
|
||||
yield 'Usine' => ['Usine'];
|
||||
}
|
||||
|
||||
public function testAnonymousGets401OnCategoryTypes(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client->request('GET', '/api/category_types');
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testAdminGets200OnCategoryTypes(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('GET', '/api/category_types');
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
}
|
||||
|
||||
public function testUserWithViewPermissionGets200OnCategoryTypes(): void
|
||||
{
|
||||
// Le referentiel reutilise la meme permission catalog.categories.view.
|
||||
$client = $this->createViewClient();
|
||||
$client->request('GET', '/api/category_types');
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Tests RG-1.15 / RG-1.16 : le TimestampableBlamableSubscriber doit remplir
|
||||
* automatiquement les 4 colonnes au prePersist (RG-1.15) et au preUpdate
|
||||
* (RG-1.16), sans qu'aucun champ ne soit modifiable par l'API client.
|
||||
*
|
||||
* - POST authentifie : createdAt = updatedAt = now, createdBy = updatedBy = user
|
||||
* - Persist hors HTTP (console context) : dates remplies, blame null
|
||||
* - PATCH par un user different : updatedAt + updatedBy changent, createdAt /
|
||||
* createdBy restent figes
|
||||
* - DELETE : deletedAt rempli ET updatedAt + updatedBy mis a jour (UPDATE
|
||||
* Doctrine declenche le subscriber)
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
public function testCreatedByAdminOnPost(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
|
||||
/** @var User $admin */
|
||||
$admin = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'admin']);
|
||||
self::assertNotNull($admin);
|
||||
$adminId = $admin->getId();
|
||||
|
||||
$before = new DateTimeImmutable();
|
||||
// Petit decalage pour absorber les arrondis a la seconde de Postgres.
|
||||
sleep(1);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'tsb_admin',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
$createdId = $response->toArray()['id'];
|
||||
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
|
||||
/** @var Category $reloaded */
|
||||
$reloaded = $em->getRepository(Category::class)->find($createdId);
|
||||
|
||||
// RG-1.15 — dates remplies, egales au prePersist
|
||||
self::assertNotNull($reloaded->getCreatedAt());
|
||||
self::assertNotNull($reloaded->getUpdatedAt());
|
||||
self::assertGreaterThanOrEqual(
|
||||
$before->getTimestamp(),
|
||||
$reloaded->getCreatedAt()->getTimestamp(),
|
||||
'createdAt doit etre post-test-start.',
|
||||
);
|
||||
self::assertSame(
|
||||
$reloaded->getCreatedAt()->getTimestamp(),
|
||||
$reloaded->getUpdatedAt()->getTimestamp(),
|
||||
'Au POST, createdAt et updatedAt doivent etre identiques.',
|
||||
);
|
||||
|
||||
// RG-1.15 — blame remplis avec le user authentifie (admin)
|
||||
self::assertNotNull($reloaded->getCreatedBy());
|
||||
self::assertNotNull($reloaded->getUpdatedBy());
|
||||
self::assertSame($adminId, $reloaded->getCreatedBy()->getId());
|
||||
self::assertSame($adminId, $reloaded->getUpdatedBy()->getId());
|
||||
}
|
||||
|
||||
public function testCreatedByNullInConsoleContext(): void
|
||||
{
|
||||
// RG-1.15 : persist sans contexte HTTP -> Security::getUser() retourne
|
||||
// null -> blame reste null, mais les dates restent remplies.
|
||||
// On utilise la factory createCategory() qui fait un persist Doctrine
|
||||
// direct (pas via le client HTTP).
|
||||
$category = $this->createCategory(self::TEST_CATEGORY_PREFIX.'console');
|
||||
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
|
||||
/** @var Category $reloaded */
|
||||
$reloaded = $em->getRepository(Category::class)->find($category->getId());
|
||||
|
||||
// Dates remplies par le subscriber.
|
||||
self::assertNotNull($reloaded->getCreatedAt());
|
||||
self::assertNotNull($reloaded->getUpdatedAt());
|
||||
|
||||
// Blame null (pas de Security::getUser() dispo hors HTTP).
|
||||
self::assertNull(
|
||||
$reloaded->getCreatedBy(),
|
||||
'createdBy doit etre null hors contexte HTTP (RG-1.15 fallback).',
|
||||
);
|
||||
self::assertNull($reloaded->getUpdatedBy());
|
||||
}
|
||||
|
||||
public function testPatchUpdatesUpdatedFieldsOnly(): void
|
||||
{
|
||||
// Etape 1 : creation par admin pour figer createdBy=admin.
|
||||
$type = $this->createCategoryType();
|
||||
$adminClient = $this->createAdminClient();
|
||||
|
||||
$response = $adminClient->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'tsb_patch',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
$createdId = $response->toArray()['id'];
|
||||
|
||||
// Snapshot des valeurs initiales pour comparaison apres PATCH.
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
|
||||
/** @var Category $initial */
|
||||
$initial = $em->getRepository(Category::class)->find($createdId);
|
||||
$initialCreatedAt = $initial->getCreatedAt();
|
||||
$initialUpdatedAt = $initial->getUpdatedAt();
|
||||
$initialCreatedById = $initial->getCreatedBy()->getId();
|
||||
|
||||
// Decalage temporel suffisant pour que la precision PG (seconde)
|
||||
// capte un updatedAt different.
|
||||
sleep(1);
|
||||
|
||||
// Etape 2 : PATCH par un autre user (manager non-admin) — simule "bob".
|
||||
$manage = $this->createManageClient();
|
||||
$bobClient = $manage['client'];
|
||||
|
||||
/** @var User $bob */
|
||||
$bob = $this->getEm()->getRepository(User::class)->findOneBy(['username' => $manage['credentials']['username']]);
|
||||
$bobId = $bob->getId();
|
||||
self::assertNotSame($initialCreatedById, $bobId, 'Le test exige deux users distincts.');
|
||||
|
||||
$bobClient->request('PATCH', '/api/categories/'.$createdId, [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['name' => self::TEST_CATEGORY_PREFIX.'tsb_patched_by_bob'],
|
||||
]);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
// Etape 3 : verifications RG-1.16
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
|
||||
/** @var Category $patched */
|
||||
$patched = $em->getRepository(Category::class)->find($createdId);
|
||||
|
||||
// createdAt / createdBy figes
|
||||
self::assertSame(
|
||||
$initialCreatedAt->getTimestamp(),
|
||||
$patched->getCreatedAt()->getTimestamp(),
|
||||
'createdAt doit etre fige au PATCH (RG-1.16).',
|
||||
);
|
||||
self::assertSame(
|
||||
$initialCreatedById,
|
||||
$patched->getCreatedBy()->getId(),
|
||||
'createdBy doit etre fige au PATCH (RG-1.16).',
|
||||
);
|
||||
|
||||
// updatedAt / updatedBy mis a jour
|
||||
self::assertGreaterThan(
|
||||
$initialUpdatedAt->getTimestamp(),
|
||||
$patched->getUpdatedAt()->getTimestamp(),
|
||||
'updatedAt doit avancer apres PATCH (RG-1.16).',
|
||||
);
|
||||
self::assertSame(
|
||||
$bobId,
|
||||
$patched->getUpdatedBy()->getId(),
|
||||
'updatedBy doit refleter le user PATCH (RG-1.16).',
|
||||
);
|
||||
}
|
||||
|
||||
public function testSoftDeleteAlsoUpdatesUpdatedFields(): void
|
||||
{
|
||||
// RG-1.16 : le soft delete est un UPDATE Doctrine, donc le subscriber
|
||||
// doit aussi avancer updatedAt et updatedBy en plus de poser deletedAt.
|
||||
$type = $this->createCategoryType();
|
||||
$adminClient = $this->createAdminClient();
|
||||
|
||||
$response = $adminClient->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'tsb_delete',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
$createdId = $response->toArray()['id'];
|
||||
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
|
||||
/** @var Category $initial */
|
||||
$initial = $em->getRepository(Category::class)->find($createdId);
|
||||
$initialUpdatedAt = $initial->getUpdatedAt();
|
||||
|
||||
sleep(1);
|
||||
|
||||
// Soft delete par un manager non-admin.
|
||||
$manage = $this->createManageClient();
|
||||
$bobClient = $manage['client'];
|
||||
|
||||
/** @var User $bob */
|
||||
$bob = $this->getEm()->getRepository(User::class)->findOneBy(['username' => $manage['credentials']['username']]);
|
||||
$bobId = $bob->getId();
|
||||
|
||||
$bobClient->request('DELETE', '/api/categories/'.$createdId);
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
|
||||
/** @var Category $deleted */
|
||||
$deleted = $em->getRepository(Category::class)->find($createdId);
|
||||
|
||||
// deletedAt rempli
|
||||
self::assertNotNull($deleted->getDeletedAt(), 'deletedAt doit etre rempli apres DELETE.');
|
||||
|
||||
// updatedAt avance, updatedBy = bob
|
||||
self::assertGreaterThan(
|
||||
$initialUpdatedAt->getTimestamp(),
|
||||
$deleted->getUpdatedAt()->getTimestamp(),
|
||||
'updatedAt doit avancer au soft delete (RG-1.16).',
|
||||
);
|
||||
self::assertSame(
|
||||
$bobId,
|
||||
$deleted->getUpdatedBy()->getId(),
|
||||
'updatedBy doit refleter l\'auteur du soft delete (RG-1.16).',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
/**
|
||||
* Tests RG-1.07 : unicite case-insensitive de (LOWER(name), category_type_id)
|
||||
* parmi les categories non soft-deleted. L'index Postgres partiel
|
||||
* `uq_category_name_type_active` est traduit en 409 Conflict par le
|
||||
* CategoryProcessor.
|
||||
*
|
||||
* Cas couverts :
|
||||
* - doublon strict (meme name + meme type) → 409 ;
|
||||
* - doublon case-insensitive (Vis / vis sur meme type) → 409 ;
|
||||
* - meme name sur 2 types differents → les deux passent (pas de doublon) ;
|
||||
* - recreation apres soft delete → 201 (l'index partiel libere le couple).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryUniqueTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
public function testDuplicateNameSameTypeReturns409(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
// 1er POST : doit reussir.
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'unique',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
// 2eme POST : meme name + meme type → doublon strict.
|
||||
$response = $client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'unique',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertSame(409, $response->getStatusCode());
|
||||
|
||||
// Message attendu par la spec RG-1.07.
|
||||
$payload = $response->toArray(false);
|
||||
$description = $payload['description'] ?? $payload['detail'] ?? $payload['hydra:description'] ?? '';
|
||||
self::assertStringContainsString(
|
||||
'existe déjà pour ce type',
|
||||
$description,
|
||||
'Le message d\'erreur 409 doit citer la spec ("existe deja pour ce type").',
|
||||
);
|
||||
}
|
||||
|
||||
public function testDuplicateNameCaseInsensitiveReturns409(): void
|
||||
{
|
||||
// RG-1.07 : la collision est case-insensitive (index sur LOWER(name)).
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'Vis',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
$response = $client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
// Meme prefix mais variation de casse → meme LOWER → collision.
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'VIS',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertSame(409, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testSameNameDifferentTypeAllowed(): void
|
||||
{
|
||||
// RG-1.07 : la contrainte est SUR (name, type), pas sur name seul.
|
||||
// Le meme nom doit etre acceptable sur deux types differents.
|
||||
$type1 = $this->createCategoryType();
|
||||
$type2 = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'shared',
|
||||
'categoryType' => '/api/category_types/'.$type1->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'shared',
|
||||
'categoryType' => '/api/category_types/'.$type2->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
public function testRecreateAfterSoftDeleteAllowed(): void
|
||||
{
|
||||
// RG-1.07 : l'index Postgres est partiel (WHERE deleted_at IS NULL).
|
||||
// Apres un soft delete, le couple (name, type) est libere et un
|
||||
// nouveau POST identique doit reussir.
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
// 1) creation
|
||||
$response = $client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'recreate',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
$created = $response->toArray();
|
||||
|
||||
// 2) soft delete
|
||||
$client->request('DELETE', '/api/categories/'.$created['id']);
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
|
||||
// 3) recreation : meme name + meme type → autorise (couple libere).
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'recreate',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
|
||||
/**
|
||||
* Tests des regles de validation POST/PATCH sur Category :
|
||||
* - RG-1.02 : `name` obligatoire (NotBlank) ;
|
||||
* - RG-1.03 : `name` trim cote serveur via CategoryProcessor ;
|
||||
* - RG-1.04 : `name` longueur 2..120 (Length) ;
|
||||
* - RG-1.05 : `categoryType` obligatoire ;
|
||||
* - RG-1.06 : `categoryType` doit pointer un type existant.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryValidationTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
// ============ RG-1.02 — name NotBlank ============
|
||||
|
||||
public function testNameRequiredReturns422(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
// name absent
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testNameEmptyStringReturns422(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => '',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testNameWhitespaceOnlyReturns422(): void
|
||||
{
|
||||
// Le Processor trim avant la validation : " " devient "" -> NotBlank
|
||||
// doit declencher 422 (RG-1.02 combinee a RG-1.03).
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => ' ',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
// ============ RG-1.03 — name trim cote serveur ============
|
||||
|
||||
public function testNameIsTrimmedOnCreate(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
$payloadName = ' '.self::TEST_CATEGORY_PREFIX.'trim ';
|
||||
$expected = trim($payloadName);
|
||||
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => $payloadName,
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
// Verification cote base : la valeur stockee est trimee.
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
$stored = $em->getRepository(Category::class)->findOneBy(['name' => $expected]);
|
||||
self::assertNotNull($stored, 'La categorie trimee doit etre persistee sous "'.$expected.'"');
|
||||
self::assertSame($expected, $stored->getName());
|
||||
}
|
||||
|
||||
// ============ RG-1.04 — longueur 2..120 ============
|
||||
|
||||
public function testNameTooShortReturns422(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => 'A',
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testNameTooLongReturns422(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => str_repeat('a', 121),
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testNameAtMaxLengthIs201(): void
|
||||
{
|
||||
// Borne haute : 120 caracteres doit passer (l'index est sur LOWER, name
|
||||
// est unique en collision avec d'autres tests donc on prefixe la marque
|
||||
// test_cat_ pour le cleanup et completons jusqu'a 120 caracteres).
|
||||
$prefix = self::TEST_CATEGORY_PREFIX;
|
||||
$name = $prefix.str_repeat('z', 120 - strlen($prefix));
|
||||
self::assertSame(120, strlen($name));
|
||||
|
||||
$type = $this->createCategoryType();
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => $name,
|
||||
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
// ============ RG-1.05 — categoryType obligatoire ============
|
||||
|
||||
public function testCategoryTypeRequiredReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'no_type',
|
||||
// categoryType absent
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testCategoryTypeNullIsRejected(): void
|
||||
{
|
||||
// `categoryType: null` echoue a la deserialization IRI (API Platform
|
||||
// renvoie 400) bien avant la validation Assert\NotNull. La spec § 4.3
|
||||
// accepte les deux : on assert le contrat fort "ne passe pas en BDD".
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'null_type',
|
||||
'categoryType' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertContains(
|
||||
$response->getStatusCode(),
|
||||
[400, 422],
|
||||
'categoryType=null doit etre rejete (400 deserialization ou 422 validation).',
|
||||
);
|
||||
}
|
||||
|
||||
// ============ RG-1.06 — categoryType doit exister ============
|
||||
|
||||
public function testCategoryTypeMustExistReturns4xx(): void
|
||||
{
|
||||
// IRI vers un id qui n'existe pas. API Platform peut renvoyer 400
|
||||
// (resolution IRI echouee) ou 422 (validation NotNull declenchee).
|
||||
// La spec § 4.3 accepte les deux : on assert le contrat "ne passe pas".
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('POST', '/api/categories', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => self::TEST_CATEGORY_PREFIX.'ghost_type',
|
||||
'categoryType' => '/api/category_types/9999999',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertContains(
|
||||
$response->getStatusCode(),
|
||||
[400, 404, 422],
|
||||
'IRI categoryType inexistante doit etre rejetee (400/404/422 selon API Platform).',
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user