From 48246909236c68550be3064b29d151ee3a181f03 Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Thu, 28 May 2026 09:48:16 +0000 Subject: [PATCH] =?UTF-8?q?[ERP-48]=20=C3=89crire=20les=20tests=20PHPUnit?= =?UTF-8?q?=20RG-1.01=20=C3=A0=20RG-1.17=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/20 Co-authored-by: THOLOT DECHENE Matthieu Co-committed-by: THOLOT DECHENE Matthieu --- makefile | 8 + src/Module/Catalog/Domain/Entity/Category.php | 9 +- .../Api/AbstractCatalogApiTestCase.php | 213 ++++++++++++++++ .../Module/Catalog/Api/CategoryAuditTest.php | 186 ++++++++++++++ .../Module/Catalog/Api/CategoryDeleteTest.php | 107 ++++++++ tests/Module/Catalog/Api/CategoryGetTest.php | 66 +++++ tests/Module/Catalog/Api/CategoryListTest.php | 112 ++++++++ .../Catalog/Api/CategoryPermissionsTest.php | 207 +++++++++++++++ .../Api/CategoryTimestampableBlamableTest.php | 239 ++++++++++++++++++ .../Module/Catalog/Api/CategoryUniqueTest.php | 144 +++++++++++ .../Catalog/Api/CategoryValidationTest.php | 210 +++++++++++++++ 11 files changed, 1499 insertions(+), 2 deletions(-) create mode 100644 tests/Module/Catalog/Api/AbstractCatalogApiTestCase.php create mode 100644 tests/Module/Catalog/Api/CategoryAuditTest.php create mode 100644 tests/Module/Catalog/Api/CategoryDeleteTest.php create mode 100644 tests/Module/Catalog/Api/CategoryGetTest.php create mode 100644 tests/Module/Catalog/Api/CategoryListTest.php create mode 100644 tests/Module/Catalog/Api/CategoryPermissionsTest.php create mode 100644 tests/Module/Catalog/Api/CategoryTimestampableBlamableTest.php create mode 100644 tests/Module/Catalog/Api/CategoryUniqueTest.php create mode 100644 tests/Module/Catalog/Api/CategoryValidationTest.php diff --git a/makefile b/makefile index 8b57452..bf774a7 100644 --- a/makefile +++ b/makefile @@ -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 diff --git a/src/Module/Catalog/Domain/Entity/Category.php b/src/Module/Catalog/Domain/Entity/Category.php index b1ac374..6bbb52d 100644 --- a/src/Module/Catalog/Domain/Entity/Category.php +++ b/src/Module/Catalog/Domain/Entity/Category.php @@ -97,9 +97,14 @@ class Category implements TimestampableInterface, BlamableInterface #[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.')] - #[Assert\Length(min: 2, max: 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; diff --git a/tests/Module/Catalog/Api/AbstractCatalogApiTestCase.php b/tests/Module/Catalog/Api/AbstractCatalogApiTestCase.php new file mode 100644 index 0000000..80bc7ed --- /dev/null +++ b/tests/Module/Catalog/Api/AbstractCatalogApiTestCase.php @@ -0,0 +1,213 @@ +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(); + } +} diff --git a/tests/Module/Catalog/Api/CategoryAuditTest.php b/tests/Module/Catalog/Api/CategoryAuditTest.php new file mode 100644 index 0000000..e9b4749 --- /dev/null +++ b/tests/Module/Catalog/Api/CategoryAuditTest.php @@ -0,0 +1,186 @@ +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 + */ + private function fetchAuditRows(string $entityId, string $action): array + { + /** @var list> $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 + */ + private function decodeChanges(string $raw): array + { + /** @var array $decoded */ + return json_decode($raw, true, flags: JSON_THROW_ON_ERROR); + } +} diff --git a/tests/Module/Catalog/Api/CategoryDeleteTest.php b/tests/Module/Catalog/Api/CategoryDeleteTest.php new file mode 100644 index 0000000..c491a15 --- /dev/null +++ b/tests/Module/Catalog/Api/CategoryDeleteTest.php @@ -0,0 +1,107 @@ +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); + } +} diff --git a/tests/Module/Catalog/Api/CategoryGetTest.php b/tests/Module/Catalog/Api/CategoryGetTest.php new file mode 100644 index 0000000..7a22697 --- /dev/null +++ b/tests/Module/Catalog/Api/CategoryGetTest.php @@ -0,0 +1,66 @@ +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); + } +} diff --git a/tests/Module/Catalog/Api/CategoryListTest.php b/tests/Module/Catalog/Api/CategoryListTest.php new file mode 100644 index 0000000..0074e51 --- /dev/null +++ b/tests/Module/Catalog/Api/CategoryListTest.php @@ -0,0 +1,112 @@ +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).', + ); + } +} diff --git a/tests/Module/Catalog/Api/CategoryPermissionsTest.php b/tests/Module/Catalog/Api/CategoryPermissionsTest.php new file mode 100644 index 0000000..a18f677 --- /dev/null +++ b/tests/Module/Catalog/Api/CategoryPermissionsTest.php @@ -0,0 +1,207 @@ +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 + */ + 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); + } +} diff --git a/tests/Module/Catalog/Api/CategoryTimestampableBlamableTest.php b/tests/Module/Catalog/Api/CategoryTimestampableBlamableTest.php new file mode 100644 index 0000000..75a90ba --- /dev/null +++ b/tests/Module/Catalog/Api/CategoryTimestampableBlamableTest.php @@ -0,0 +1,239 @@ +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).', + ); + } +} diff --git a/tests/Module/Catalog/Api/CategoryUniqueTest.php b/tests/Module/Catalog/Api/CategoryUniqueTest.php new file mode 100644 index 0000000..1b2cf98 --- /dev/null +++ b/tests/Module/Catalog/Api/CategoryUniqueTest.php @@ -0,0 +1,144 @@ +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); + } +} diff --git a/tests/Module/Catalog/Api/CategoryValidationTest.php b/tests/Module/Catalog/Api/CategoryValidationTest.php new file mode 100644 index 0000000..aa9baf5 --- /dev/null +++ b/tests/Module/Catalog/Api/CategoryValidationTest.php @@ -0,0 +1,210 @@ +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).', + ); + } +}