Compare commits

..

6 Commits

Author SHA1 Message Date
gitea-actions 8e31e1759c chore: bump version to v0.1.48
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 18s
2026-05-28 10:25:11 +00:00
matthieu 4824690923 [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>
2026-05-28 09:48:16 +00:00
matthieu fceb1e0e83 [ERP-47] Déclarer le module Catalog et synchroniser RBAC (#19)
Auto Tag Develop / tag (push) Successful in 10s
## Contexte

Ticket Lesstime #47 — M0 position 0.5. Wire le nouveau module **Catalog** dans Starseed et synchronise les 3 sources RBAC (sidebar + personas E2E + seed back). Couvre **RG-1.01** (Admin uniquement) côté infra.

Spec : [`docs/specs/M0-categories/spec-back.md` § 5.1 + § 5.3](https://gitea.malio.fr/MALIO-DEV/Starseed/src/branch/feature/M0-spec-categories/docs/specs/M0-categories/spec-back.md).

> ⚠ **Mode stacked PR** — cible `feature/ERP-46-exposer-category-type-lecture-seule`, **PAS** `develop`. Quand la MR ERP-46 sera mergée sur develop, repointer la cible de cette MR vers develop.

## Modifications (6 fichiers — règle ABSOLUE Starseed n°8 : les 3 sources RBAC bougent ENSEMBLE)

| Fichier | Rôle |
|---|---|
| `src/Module/Catalog/CatalogModule.php` (nouveau) | Déclaration du module : `ID=catalog`, `LABEL=Catalogue`, `REQUIRED=true`, 2 permissions (`view` + `manage`) |
| `config/modules.php` | Wire `CatalogModule::class` |
| `config/sidebar.php` | Item « Gestion des catégories » dans section Administration, gate sur `catalog.categories.view` |
| `frontend/i18n/locales/fr.json` | Clé `sidebar.catalog.categories` = `Gestion des catégories` |
| `frontend/tests/e2e/_fixtures/personas.ts` | `user-full` reçoit les 2 permissions + `'categories'` dans `expectedAdminLinks`. `super-admin` et `ALL_ADMIN_LINKS` étendus avec `'categories'`. `user-readonly` inchangé (Admin-only au M0 — pas de mode read-only spec'é). |
| `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php` | Miroir back : `user-full` reçoit les 2 permissions |

## Décisions

- **`REQUIRED = true`** : la spec § 5.1 + le prompt user disent `true` (Category sera FK NOT NULL côté futurs modules Tiers). Le ticket Lesstime dit `false` par erreur — j'ai suivi la spec.
- **Personas E2E « Admin » = `user-full`** : pas de persona métier « Admin » explicite dans `personas.ts` (personas techniques : `super-admin` bypass, `user-full` = toutes permissions). `user-full` est l'équivalent fonctionnel.
- **`user-readonly` NON touché** : RG-1.01 dit « Admin uniquement », pas de pattern read-only spec'é au M0. À rouvrir dans un futur ticket si besoin.

## Validation

- `make php-cs-fixer-allow-risky` ✓ (0 fichier corrigé)
- `make db-reset` ✓ (sync-permissions : 11 codes en base, dont les 2 nouveaux `catalog.categories.*` vérifiés via `dbal:run-sql`)
- `make test` ✓ (248 tests, 0 régression)
- **RG-1.01 vérifiée manuellement** via curl :
  - Admin → 200 sur `GET /api/categories` et `GET /api/category_types`
  - Bob (zéro permission) → 403 sur `GET /api/categories`, `POST /api/categories`, `GET /api/category_types`
  - Anonyme → 401 sur `GET /api/categories`

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #19
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-28 09:45:33 +00:00
matthieu adda62c1e1 [ERP-46] Exposer le référentiel CategoryType en lecture seule (#18)
Auto Tag Develop / tag (push) Successful in 10s
## Contexte

Ticket Lesstime #46 — position 0.4 (M0 Catalog, quick win).

Expose `CategoryType` en lecture seule pour alimenter le `<MalioSelect>` du formulaire `Category` côté front. Pas d'écriture exposée au M0 (table vide à la livraison).

## Mode stacked PR

⚠ **Cible (base branch) : `feature/ERP-45-implementer-provider-processor-category`** (PAS develop).
Quand MR ERP-45 sera mergée sur develop, repointer la cible de cette MR vers develop.

## Changement

Le gros du travail (`#[ApiResource(operations: [GetCollection, Get])]`, security `is_granted('catalog.categories.view')`, groupes de sérialisation) a été livré dans le ticket ERP-44. Cette MR ajoute uniquement ce qui manquait à la spec § 4.6 :

- `order: ['label' => 'ASC']` sur l'opération `GetCollection` → tri alphabétique stable pour le select front.

## Critères d'acceptation (spec § 4.6)

- [x] `GET /api/category_types` retourne tous les `CategoryType` triés par `label ASC`
- [x] `GET /api/category_types/{id}` retourne le détail
- [x] POST / PATCH / DELETE → 404 (opérations non déclarées)
- [x] Security `is_granted('catalog.categories.view')` sur les 2 opérations
- [x] `make php-cs-fixer-allow-risky` passe (0 fix)

## Vérifications

\`\`\`
$ php bin/console debug:router | grep category_type
_api_/category_types{._format}_get_collection   GET   /api/category_types.{_format}
_api_/category_types/{id}{._format}_get         GET   /api/category_types/{id}.{_format}
\`\`\`

→ Exactement 2 routes générées, aucune POST/PATCH/DELETE.

- \`make php-cs-fixer-allow-risky\` ✓ (0 fix)
- \`make test\` ✓ (248/248)

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #18
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-28 09:45:06 +00:00
matthieu 80fabcae91 [ERP-45] Implémenter Provider et Processor Category (#17)
Auto Tag Develop / tag (push) Successful in 7s
## Mode stacked PR — cible `feature/ERP-44-creer-entites-category`

> ⚠ Cette MR a pour base la branche ERP-44 (en review). Quand ERP-44 sera mergée sur develop, **repointer la cible vers develop**.

## Résumé
- Provider `CategoryProvider` : filtre soft-delete par défaut (RG-1.08), `?includeDeleted=true` (RG-1.09), tri name ASC (RG-1.10), 404 si soft-deleted hors flag (RG-1.11).
- Processor `CategoryProcessor` : trim du `name` (RG-1.03), conversion DELETE → UPDATE (RG-1.12), mapping `UniqueConstraintViolationException` → HTTP 409 avec message exact (RG-1.07).
- Câblage Provider/Processor dans `#[ApiResource]` de `Category`. Provider câblé aussi sur Patch + Delete (au-delà du scope strict du ticket) pour fermer la fuite RG-1.11 sur PATCH.
- `DoctrineCategoryRepository` expose `createListQueryBuilder($includeDeleted)`.

## Décisions notables
- **Filtre soft-delete via QueryBuilder** (choix `a` du ticket) : pas de filtre Doctrine global, lisibilité directe.
- **Pas de `remove_processor` injecté** : la DELETE est convertie en UPDATE via le `persist_processor`. API Platform 4 utilise le processor déclaré sur l'opération sans fallback automatique.
- **Provider sur Patch + Delete aussi** : décision prise pendant le dev pour fermer une fuite RG-1.11 sur PATCH. Coût : 2 lignes dans `Category.php`.

## Tests
- `make php-cs-fixer-allow-risky` ✓
- `make test` ✓ (248 tests, 0 régression — pas de test métier Category, c'est ERP-48)
- Tests manuels curl ✓ — 8 cas RG-1.03 → RG-1.13 validés (détail dans le ticket Lesstime #45)

## Tickets
- Lesstime : #45 (ERP-45) → En review
- Position M0 : 0.3
- Spec : `docs/specs/M0-categories/spec-back.md` § 4.1 + § 4.3 + § 4.4 + § 4.5

## Suite
- ERP-46 (0.4 CategoryType lecture seule) — base : cette branche

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #17
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-28 09:44:43 +00:00
matthieu ff6086bc4d [ERP-44] Créer les entités Category et CategoryType (#15)
Auto Tag Develop / tag (push) Successful in 7s
## Objectif
Couche Domain DDD du module Catalog (ticket M0 · position 0.2). Crée les entités `Category` et `CategoryType`, leurs repositories, et branche le pattern Timestampable + Blamable Shared.

> **Mode stacked PR** : cible `feature/ERP-43-migrer-tables-category`. Quand la MR ERP-43 sera mergée sur develop, Matthieu repointera la cible de cette MR vers develop.

## Contenu
- **`Category`** : `#[ApiResource]` (GetCollection, Get, Post, Patch, Delete), `#[Auditable]`, `TimestampableBlamableTrait` + interfaces, asserts (`NotBlank`/`Length` sur `name`, `NotNull` sur `categoryType`), soft delete via `deletedAt`. Provider/Processor branchés au ticket 0.3 (ERP-45).
- **`CategoryType`** : référentiel statique en lecture seule (GetCollection + Get), embarqué dans `Category` via le groupe `category:read`. Pas de Trait — whitelisté dans `EntitiesAreTimestampableBlamableTest::EXCLUDED` (RG-1.17).
- **Repositories** : interfaces Domain + implémentations Doctrine.
- **`config/packages/doctrine.yaml`** : mapping ORM `Catalog` inconditionnel (miroir de `Sites`) — nécessaire pour que l'ORM reconnaisse les entités. La déclaration du module (`config/modules.php`) reste pour le ticket 0.5 (ERP-47).
- Groupes : `category:read` / `category:write` + `default:read` (expose les 4 colonnes du Trait).

## Notes techniques
- Index nommés déclarés sur les entités pour matcher la migration (cf. Role/Permission/Site).
- L'index unique partiel `uq_category_name_type_active` (`LOWER(name), category_type_id WHERE deleted_at IS NULL`) reste possédé par la seule migration : Doctrine ORM ne sait pas exprimer un index fonctionnel + partiel. Seul diff résiduel de `doctrine:schema:validate`.

## Tests
- `make php-cs-fixer-allow-risky` ✓
- `make test` ✓ (248 tests, 0 échec)
- `make db-reset` ✓
- `debug:router` ✓ (7 routes exposées)
- `doctrine:schema:validate` : mapping correct

---------

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