[ERP-48] Écrire les tests PHPUnit RG-1.01 à RG-1.17 (#20)
Auto Tag Develop / tag (push) Successful in 9s

## Mode stacked PR — DERNIER ticket back du M0

**Cible : `feature/ERP-47-declarer-module-catalog-rbac`** (PAS develop).
Quand la MR ERP-47 sera mergée sur develop, repointer la cible de cette MR vers develop.

## Résumé

Suite PHPUnit complète qui mappe chaque RG (1.01 → 1.17) de la spec M0 Catalog vers un ou plusieurs tests ciblés. **63 nouveaux tests** dans 9 classes sous `tests/Module/Catalog/Api/`.

## Cahier de test

| RG | Test(s) |
|---|---|
| **RG-1.01** | `CategoryPermissionsTest::testPersonaWithoutCatalogPermissionGets403*` (4 personas × 4 verbes) + `testAnonymousGets401*` + `testAdminGets{200,201,204}*` + `testUserWithViewPermissionGets200*` |
| **RG-1.02** | `CategoryValidationTest::testNameRequiredReturns422` + `testNameEmptyStringReturns422` + `testNameWhitespaceOnlyReturns422` |
| **RG-1.03** | `CategoryValidationTest::testNameIsTrimmedOnCreate` |
| **RG-1.04** | `CategoryValidationTest::testNameTooShortReturns422` + `testNameTooLongReturns422` + `testNameAtMaxLengthIs201` |
| **RG-1.05** | `CategoryValidationTest::testCategoryTypeRequiredReturns422` + `testCategoryTypeNullIsRejected` |
| **RG-1.06** | `CategoryValidationTest::testCategoryTypeMustExistReturns4xx` |
| **RG-1.07** | `CategoryUniqueTest::testDuplicateNameSameTypeReturns409` + `testDuplicateNameCaseInsensitiveReturns409` + `testSameNameDifferentTypeAllowed` + `testRecreateAfterSoftDeleteAllowed` |
| **RG-1.08** | `CategoryListTest::testListExcludesSoftDeletedByDefault` |
| **RG-1.09** | `CategoryListTest::testIncludeDeletedFlagSurfacesSoftDeleted` |
| **RG-1.10** | `CategoryListTest::testDefaultSortIsNameAsc` |
| **RG-1.11** | `CategoryGetTest::testGetSoftDeletedReturns404` + `testGetSoftDeletedWithFlagReturns200` + `testGetNonExistentReturns404` + `testGetActiveCategoryReturns200` |
| **RG-1.12** | `CategoryDeleteTest::testDeleteReturns204AndPersistsSoftDelete` |
| **RG-1.13** | `CategoryDeleteTest::testPatchCannotSetDeletedAt` |
| **RG-1.11 étendue (404 sur soft-deleted)** | `CategoryDeleteTest::testPatchOnSoftDeletedReturns404` + `testDeleteOnSoftDeletedReturns404` |
| **Audit** | `CategoryAuditTest::testAuditLogOnCreate` + `testAuditLogOnUpdate` + `testAuditLogOnSoftDelete` + `testAuditLogPerformerCarriesAuthenticatedUsername` |
| **RG-1.15** | `CategoryTimestampableBlamableTest::testCreatedByAdminOnPost` + `testCreatedByNullInConsoleContext` |
| **RG-1.16** | `CategoryTimestampableBlamableTest::testPatchUpdatesUpdatedFieldsOnly` + `testSoftDeleteAlsoUpdatesUpdatedFields` |
| **RG-1.17** | `EntitiesAreTimestampableBlamableTest::testAllBusinessEntitiesImplementBothInterfaces` (déjà livré ERP-52, reste vert avec `Category` détectée Timestampable/Blamable et `CategoryType` whitelistée) |

## Side fixes révélés par la suite

### 1. `Category.php` — `normalizer: 'trim'` sur Assert\NotBlank + Length

Avant le fix, POST `{name: "   "}` retournait **201** au lieu de **422** : le Processor trim après validation, mais NotBlank ne fait pas de trim natif. La RG-1.02 (whitespace-only → 422) combinée à la RG-1.03 (trim serveur) exige le `normalizer: 'trim'`. 1 ligne, aligne le contrat sans réordonnancer Validate/Process.

### 2. `makefile` — recréer l'index partiel `uq_category_name_type_active` après `schema:update`

`doctrine:schema:update --env=test --force` drop systématiquement l'index partiel `uq_category_name_type_active` (l'ORM ne sait pas exprimer un index fonctionnel + partiel via attribut Doctrine, donc le voit comme orphelin). Conséquence : POST doublon `(name, type)` retournait **201** au lieu de **409** (la `UniqueConstraintViolation` ne se déclenche plus). Fix : `dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS ..."` ajouté en fin de `test-db-setup`. Approche chirurgicale validée avec Matthieu, ne touche pas à `fake_site_aware_entity` (dépend de schema:update pour exister avant le purger fixtures:load).

## Helpers livrés

`tests/Module/Catalog/Api/AbstractCatalogApiTestCase.php` :
- Factories : `createCategory()`, `createCategoryType()`
- Auth : `createAdminClient()`, `createManageClient()`, `createViewClient()`, `createPersonaClient(string \$label)` (4 personas MALIO sans permission catalog)
- Cleanup : purge complète Category + CategoryType (aucune fixture au M0) + users/roles `test_*`

## Vérifications

-  `make php-cs-fixer-allow-risky` (auto-applied via pre-commit)
-  `make db-reset` (index partiel restauré, vérifié `\d category`)
-  `make test` → **311 tests, 1071 assertions, 0 failure, 0 risky**
  (248 existants + 63 nouveaux, dont 6 deprecations + 6 notices héritées des tests Core RBAC pré-existants — pas de régression introduite par ce ticket)

## Suite

DERNIER ticket back du M0. ERP-52, ERP-43, ERP-44, ERP-45, ERP-46, ERP-47, ERP-48 sont tous en review.
Quand la MR ERP-47 sera mergée sur develop, Matthieu repointera la cible de cette MR vers develop. Tristan reviewe la stack en série.

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #20
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #20.
This commit is contained in:
2026-05-28 09:48:16 +00:00
committed by admin malio
parent fceb1e0e83
commit 4824690923
11 changed files with 1499 additions and 2 deletions
@@ -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).',
);
}
}