Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94b3acec05 | |||
| f5312686ab | |||
| d4f234ec55 | |||
| d0c3fb7558 | |||
| 43d80df1e1 | |||
| 5db644d22e | |||
| 33599db5a3 | |||
| 34e75a35fb | |||
| 1696602abb | |||
| cacd8718e5 |
@@ -40,6 +40,27 @@ Format obligatoire : `module.resource[.subresource].action` en snake_case.
|
|||||||
- Audit ManyToMany : trace automatiquement `{fieldName: {added: [ids], removed: [ids]}}` — aucune action supplementaire
|
- Audit ManyToMany : trace automatiquement `{fieldName: {added: [ids], removed: [ids]}}` — aucune action supplementaire
|
||||||
- Spec complete : @doc/audit-log.md
|
- Spec complete : @doc/audit-log.md
|
||||||
|
|
||||||
|
## Timestampable + Blamable (obligatoire pour entites metier)
|
||||||
|
|
||||||
|
Toute **nouvelle** entite metier sous `src/Module/*/Domain/Entity/` doit porter les 4 colonnes `created_at` / `updated_at` / `created_by` / `updated_by`, remplies automatiquement. Trois lignes a ajouter a l'entite :
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
|
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||||
|
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||||
|
|
||||||
|
class MyEntity implements TimestampableInterface, BlamableInterface
|
||||||
|
{
|
||||||
|
use TimestampableBlamableTrait; // porte les 4 props + getters/setters
|
||||||
|
// ... reste metier
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Le `TimestampableBlamableSubscriber` (`Shared/Infrastructure/Doctrine/`) remplit les colonnes au `prePersist` / `preUpdate`. Hors contexte HTTP (CLI, cron, migration), le blame reste `null` (libelle « Systeme » cote front).
|
||||||
|
- La migration de l'entite doit creer les 4 colonnes (`created_at` / `updated_at` NOT NULL, `created_by` / `updated_by` nullable `ON DELETE SET NULL`).
|
||||||
|
- **Garde-fou CI** : `tests/Architecture/EntitiesAreTimestampableBlamableTest` echoue si une entite oublie le pattern. Un referentiel statique justifie (ex: `CategoryType`) doit etre explicitement whiteliste dans la constante `EXCLUDED` avec un commentaire.
|
||||||
|
- Spec complete : @docs/specs/M0-categories/spec-back.md § 2.8 + § 2.8.bis
|
||||||
|
|
||||||
## Serialization
|
## Serialization
|
||||||
|
|
||||||
Pour embarquer une relation dans le JSON (au lieu d'un IRI Hydra), ajouter le groupe du parent sur les proprietes de l'entite cible.
|
Pour embarquer une relation dans le JSON (au lieu d'un IRI Hydra), ajouter le groupe du parent sur les proprietes de l'entite cible.
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
name: Pull Request — Quality gate
|
||||||
|
|
||||||
|
# Lance les tests + lint + build sur chaque PR ciblant develop.
|
||||||
|
# Deux jobs en parallele (backend / frontend) pour reduire le temps de feedback.
|
||||||
|
# E2E volontairement hors scope (cf. regle d'or testing.md).
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
|
||||||
|
# Annule les runs obsoletes quand on repush sur la meme PR.
|
||||||
|
concurrency:
|
||||||
|
group: pr-${{ gitea.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend:
|
||||||
|
name: Backend (PHP CS + PHPUnit)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
# Doivent matcher la DATABASE_URL ci-dessous. Le suffixe `_test`
|
||||||
|
# est applique automatiquement par Doctrine en APP_ENV=test.
|
||||||
|
POSTGRES_USER: app
|
||||||
|
POSTGRES_PASSWORD: '!ChangeMe!'
|
||||||
|
POSTGRES_DB: app
|
||||||
|
# Pas de `ports:` host mapping — le runner partage l'hote avec la
|
||||||
|
# prod (Postgres deja sur 5432) et les jobs Gitea Actions tournent
|
||||||
|
# en container sur un reseau Docker dedie : le service est joignable
|
||||||
|
# via son nom (`postgres`), pas via 127.0.0.1.
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U app"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
|
|
||||||
|
env:
|
||||||
|
APP_ENV: test
|
||||||
|
APP_SECRET: ci-secret-not-used
|
||||||
|
APP_DEBUG: 0
|
||||||
|
DEFAULT_URI: http://localhost/
|
||||||
|
DATABASE_URL: postgresql://app:!ChangeMe!@postgres:5432/app?serverVersion=16&charset=utf8
|
||||||
|
JWT_SECRET_KEY: '%kernel.project_dir%/config/jwt/private.pem'
|
||||||
|
JWT_PUBLIC_KEY: '%kernel.project_dir%/config/jwt/public.pem'
|
||||||
|
JWT_PASSPHRASE: change_me_in_env_local
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP 8.4
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.4'
|
||||||
|
extensions: pdo, pdo_pgsql, intl, opcache, zip, mbstring, sodium
|
||||||
|
coverage: none
|
||||||
|
tools: composer:v2
|
||||||
|
|
||||||
|
# Cache Composer retire : meme cause que cote front — le backend de cache
|
||||||
|
# du runner Gitea est injoignable (ETIMEDOUT) et fait timeouter le step
|
||||||
|
# ~4 min 30. A re-activer si le serveur de cache du runner est repare.
|
||||||
|
- name: Install PHP dependencies
|
||||||
|
run: composer install --no-interaction --no-progress --prefer-dist
|
||||||
|
|
||||||
|
- name: Generate JWT keypair
|
||||||
|
run: php bin/console lexik:jwt:generate-keypair --skip-if-exists --no-interaction
|
||||||
|
|
||||||
|
- name: PHP CS Fixer (dry-run)
|
||||||
|
run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff
|
||||||
|
|
||||||
|
- name: Bootstrap test database
|
||||||
|
run: |
|
||||||
|
php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction
|
||||||
|
php bin/console doctrine:migrations:migrate --env=test --no-interaction
|
||||||
|
php bin/console doctrine:schema:update --env=test --force --no-interaction
|
||||||
|
php bin/console doctrine:fixtures:load --env=test --no-interaction
|
||||||
|
php bin/console app:sync-permissions --env=test --no-interaction
|
||||||
|
|
||||||
|
- name: Run PHPUnit
|
||||||
|
run: php -d memory_limit=512M vendor/bin/phpunit
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
name: Frontend (lint + Vitest + build)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Pas de `cache: npm` : le backend de cache du runner Gitea est injoignable
|
||||||
|
# (ETIMEDOUT) et chaque tentative de restauration attend ~4 min 30 avant de
|
||||||
|
# timeout — c'est ce qui plombait le job. Node 22 est deja dans le
|
||||||
|
# tool-cache du runner (install instantane), et `npm ci` a froid ne prend
|
||||||
|
# que ~30s. A re-activer si le serveur de cache du runner est repare.
|
||||||
|
- name: Setup Node 22
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Install Node dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: ESLint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Unit tests (Vitest)
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
|
# `nuxt build` (et non `build:dist`/`nuxt generate`) : l'app est en SSR off
|
||||||
|
# (SPA), le prerender de generate n'apporte rien a une quality gate — on
|
||||||
|
# veut seulement valider que le bundle compile.
|
||||||
|
- name: Build production (nuxt build)
|
||||||
|
run: npm run build
|
||||||
@@ -33,6 +33,10 @@ doctrine:
|
|||||||
# `App\Module\Sites\Domain\Entity\Site` dans User.php.
|
# `App\Module\Sites\Domain\Entity\Site` dans User.php.
|
||||||
resolve_target_entities:
|
resolve_target_entities:
|
||||||
App\Shared\Domain\Contract\SiteInterface: App\Module\Sites\Domain\Entity\Site
|
App\Shared\Domain\Contract\SiteInterface: App\Module\Sites\Domain\Entity\Site
|
||||||
|
# Cible des ManyToOne created_by / updated_by du TimestampableBlamableTrait.
|
||||||
|
# Permet a Shared de referencer UserInterface dans ses ORM mappings sans
|
||||||
|
# importer la classe concrete du module Core (cf. spec-back M0 § 2.8).
|
||||||
|
Symfony\Component\Security\Core\User\UserInterface: App\Module\Core\Domain\Entity\User
|
||||||
mappings:
|
mappings:
|
||||||
Core:
|
Core:
|
||||||
type: attribute
|
type: attribute
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.37'
|
app.version: '0.1.40'
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ DATABASE_URL="postgresql://malio:password@host.docker.internal:5432/starseed_pro
|
|||||||
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||||
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||||
JWT_PASSPHRASE=<generer avec: openssl rand -hex 32>
|
JWT_PASSPHRASE=<generer avec: openssl rand -hex 32>
|
||||||
JWT_COOKIE_SECURE=1
|
JWT_COOKIE_SECURE=0
|
||||||
JWT_COOKIE_SAMESITE=lax
|
JWT_COOKIE_SAMESITE=lax
|
||||||
JWT_TOKEN_TTL=86400
|
JWT_TOKEN_TTL=86400
|
||||||
JWT_COOKIE_TTL=86400
|
JWT_COOKIE_TTL=86400
|
||||||
@@ -161,7 +161,7 @@ JWT_COOKIE_TTL=86400
|
|||||||
CORS_ALLOW_ORIGIN='^https?://starseed\.malio-dev\.fr$'
|
CORS_ALLOW_ORIGIN='^https?://starseed\.malio-dev\.fr$'
|
||||||
|
|
||||||
# App
|
# App
|
||||||
DEFAULT_URI=https://starseed.malio-dev.fr
|
DEFAULT_URI=http://starseed.malio-dev.fr
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Generer les cles JWT
|
### 6. Generer les cles JWT
|
||||||
|
|||||||
+20
-25
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
Copier-coller integralement dans une session Claude lancee **sur le serveur de prod** apres que :
|
Copier-coller integralement dans une session Claude lancee **sur le serveur de prod** apres que :
|
||||||
- le push develop + build CI ont publie l'image `gitea.malio.fr/malio-dev/starseed:latest`,
|
- le push develop + build CI ont publie l'image `gitea.malio.fr/malio-dev/starseed:latest`,
|
||||||
- le DNS `starseed.malio-dev.fr` resout vers ce serveur,
|
- la resolution reseau local (DNS interne ou `/etc/hosts` des postes clients) pour `starseed.malio-dev.fr` est en place.
|
||||||
- un certificat Let's Encrypt existe (ou est pret a etre genere) pour `starseed.malio-dev.fr`.
|
|
||||||
|
> Setup : HTTP en reseau local, pas de TLS. Pas de Let's Encrypt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ Copier-coller integralement dans une session Claude lancee **sur le serveur de p
|
|||||||
|
|
||||||
Tu es sur le serveur de production d'une app Symfony+Nuxt qui s'appelait **Coltura** et qui doit etre renommee en **Starseed**. Le rename cote code est deja fait et merge. Le repo Gitea s'appelle deja `starseed`. L'image `gitea.malio.fr/malio-dev/starseed:latest` est publiee.
|
Tu es sur le serveur de production d'une app Symfony+Nuxt qui s'appelait **Coltura** et qui doit etre renommee en **Starseed**. Le rename cote code est deja fait et merge. Le repo Gitea s'appelle deja `starseed`. L'image `gitea.malio.fr/malio-dev/starseed:latest` est publiee.
|
||||||
|
|
||||||
Le DNS `starseed.malio-dev.fr` resout vers ce serveur. Le certificat Let's Encrypt pour ce nom de domaine est gere a la main par l'admin (a confirmer avant l'etape nginx).
|
L'app est servie en **HTTP sur reseau local** (pas de TLS, pas de Let's Encrypt). La resolution `starseed.malio-dev.fr` est faite via DNS interne ou `/etc/hosts` cote postes clients — pas de certificat a gerer.
|
||||||
|
|
||||||
Objectif : basculer la prod sur le nouveau nom (registry, container, DB, path FS, vhost) **sans perdre les donnees** et avec downtime minimal (mode maintenance pendant la migration).
|
Objectif : basculer la prod sur le nouveau nom (registry, container, DB, path FS, vhost) **sans perdre les donnees** et avec downtime minimal (mode maintenance pendant la migration).
|
||||||
|
|
||||||
@@ -30,9 +31,6 @@ ls -la /var/www/starseed/ 2>/dev/null | head -5
|
|||||||
|
|
||||||
# 4. Vhost nginx system
|
# 4. Vhost nginx system
|
||||||
sudo ls -la /etc/nginx/sites-enabled/ | grep -E "coltura|starseed"
|
sudo ls -la /etc/nginx/sites-enabled/ | grep -E "coltura|starseed"
|
||||||
|
|
||||||
# 5. Cert Let's Encrypt
|
|
||||||
sudo ls /etc/letsencrypt/live/ | grep -E "coltura|starseed"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Apres confirmation de l'etat, executer dans cet ordre, en demandant validation utilisateur AVANT chaque etape destructive (DB drop, rm -rf, certificat) :**
|
**Apres confirmation de l'etat, executer dans cet ordre, en demandant validation utilisateur AVANT chaque etape destructive (DB drop, rm -rf, certificat) :**
|
||||||
@@ -43,7 +41,7 @@ sudo ls /etc/letsencrypt/live/ | grep -E "coltura|starseed"
|
|||||||
cd /var/www/coltura
|
cd /var/www/coltura
|
||||||
touch maintenance.on
|
touch maintenance.on
|
||||||
# Verifier qu'une requete renvoie 503
|
# Verifier qu'une requete renvoie 503
|
||||||
curl -s -o /dev/null -w "HTTP %{http_code}\n" https://coltura.malio-dev.fr/
|
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://coltura.malio-dev.fr/
|
||||||
```
|
```
|
||||||
|
|
||||||
Doit renvoyer `503`.
|
Doit renvoyer `503`.
|
||||||
@@ -100,17 +98,18 @@ sudo test -f /var/www/starseed/.env && echo ".env OK"
|
|||||||
Editer `/var/www/starseed/.env` :
|
Editer `/var/www/starseed/.env` :
|
||||||
- `DATABASE_URL` : remplacer `/coltura_prod` -> `/starseed_prod` (et user si renomme a etape 3)
|
- `DATABASE_URL` : remplacer `/coltura_prod` -> `/starseed_prod` (et user si renomme a etape 3)
|
||||||
- `CORS_ALLOW_ORIGIN` : remplacer `coltura.malio-dev.fr` -> `starseed.malio-dev.fr`
|
- `CORS_ALLOW_ORIGIN` : remplacer `coltura.malio-dev.fr` -> `starseed.malio-dev.fr`
|
||||||
- `DEFAULT_URI` : `https://starseed.malio-dev.fr`
|
- `DEFAULT_URI` : `http://starseed.malio-dev.fr`
|
||||||
|
- `JWT_COOKIE_SECURE` : doit etre `0` (HTTP, pas de TLS) — verifier qu'il l'est deja
|
||||||
|
|
||||||
Diff attendu :
|
Diff attendu :
|
||||||
|
|
||||||
```diff
|
```diff
|
||||||
- DATABASE_URL="postgresql://malio:xxx@host.docker.internal:5432/coltura_prod?..."
|
- DATABASE_URL="postgresql://malio:xxx@host.docker.internal:5432/coltura_prod?..."
|
||||||
+ DATABASE_URL="postgresql://malio:xxx@host.docker.internal:5432/starseed_prod?..."
|
+ DATABASE_URL="postgresql://malio:xxx@host.docker.internal:5432/starseed_prod?..."
|
||||||
- CORS_ALLOW_ORIGIN='^https?://coltura\.malio-dev\.fr$'
|
- CORS_ALLOW_ORIGIN='^http://coltura\.malio-dev\.fr$'
|
||||||
+ CORS_ALLOW_ORIGIN='^https?://starseed\.malio-dev\.fr$'
|
+ CORS_ALLOW_ORIGIN='^http://starseed\.malio-dev\.fr$'
|
||||||
- DEFAULT_URI=https://coltura.malio-dev.fr
|
- DEFAULT_URI=http://coltura.malio-dev.fr
|
||||||
+ DEFAULT_URI=https://starseed.malio-dev.fr
|
+ DEFAULT_URI=http://starseed.malio-dev.fr
|
||||||
```
|
```
|
||||||
|
|
||||||
### Etape 6 — Stopper et supprimer l'ancien container
|
### Etape 6 — Stopper et supprimer l'ancien container
|
||||||
@@ -144,9 +143,9 @@ sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=pr
|
|||||||
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||||
```
|
```
|
||||||
|
|
||||||
### Etape 9 — Vhost nginx system + certificat
|
### Etape 9 — Vhost nginx system (HTTP only)
|
||||||
|
|
||||||
Copier le nouveau vhost (a jour avec `server_name starseed.malio-dev.fr` et `root /var/www/starseed/public`) :
|
Copier le nouveau vhost (a jour avec `server_name starseed.malio-dev.fr` et `root /var/www/starseed/public`, `listen 80` uniquement) :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo cp /var/www/starseed/infra/prod/nginx-proxy.conf /etc/nginx/sites-available/starseed.conf
|
sudo cp /var/www/starseed/infra/prod/nginx-proxy.conf /etc/nginx/sites-available/starseed.conf
|
||||||
@@ -155,14 +154,10 @@ sudo rm -f /etc/nginx/sites-enabled/coltura.conf
|
|||||||
sudo nginx -t
|
sudo nginx -t
|
||||||
```
|
```
|
||||||
|
|
||||||
**Avant de reload nginx**, generer le certificat Let's Encrypt pour le nouveau domaine (l'utilisateur doit confirmer ; certbot peut casser temporairement le vhost actuel pendant la challenge) :
|
Verifier la resolution reseau local avant reload :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Verifier le DNS d'abord
|
getent hosts starseed.malio-dev.fr || echo "ATTENTION : starseed.malio-dev.fr ne resout pas localement"
|
||||||
dig +short starseed.malio-dev.fr
|
|
||||||
|
|
||||||
# Generer le cert (l'utilisateur valide)
|
|
||||||
sudo certbot --nginx -d starseed.malio-dev.fr --non-interactive --agree-tos -m matthieu@malio.fr
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Puis :
|
Puis :
|
||||||
@@ -176,9 +171,9 @@ sudo systemctl reload nginx
|
|||||||
```bash
|
```bash
|
||||||
rm -f /var/www/starseed/maintenance.on
|
rm -f /var/www/starseed/maintenance.on
|
||||||
|
|
||||||
# Tests externes
|
# Tests externes (HTTP local)
|
||||||
curl -s -o /dev/null -w "HTTP %{http_code}\n" https://starseed.malio-dev.fr/
|
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://starseed.malio-dev.fr/
|
||||||
curl -s https://starseed.malio-dev.fr/api/version
|
curl -s http://starseed.malio-dev.fr/api/version
|
||||||
```
|
```
|
||||||
|
|
||||||
`/api/version` doit renvoyer du JSON avec la version courante.
|
`/api/version` doit renvoyer du JSON avec la version courante.
|
||||||
@@ -229,8 +224,8 @@ rm -f /var/www/coltura/maintenance.on
|
|||||||
## Regles de comportement pour le Claude prod
|
## Regles de comportement pour le Claude prod
|
||||||
|
|
||||||
- **Ne jamais skipper le backup** (etape 2).
|
- **Ne jamais skipper le backup** (etape 2).
|
||||||
- **Demander confirmation utilisateur** avant : `DROP DATABASE`, `rm -rf`, `certbot`, et avant de lever le mode maintenance final.
|
- **Demander confirmation utilisateur** avant : `DROP DATABASE`, `rm -rf`, et avant de lever le mode maintenance final.
|
||||||
- **Une seule operation destructive a la fois**, attendre le retour utilisateur entre chaque.
|
- **Une seule operation destructive a la fois**, attendre le retour utilisateur entre chaque.
|
||||||
- **Logger systematiquement** la sortie des commandes critiques (pg_dump, docker compose up, certbot).
|
- **Logger systematiquement** la sortie des commandes critiques (pg_dump, docker compose up, nginx -t / reload).
|
||||||
- **Si une etape echoue**, NE PAS continuer — declencher le rollback.
|
- **Si une etape echoue**, NE PAS continuer — declencher le rollback.
|
||||||
- **Ne commit rien** sur le repo depuis le serveur prod.
|
- **Ne commit rien** sur le repo depuis le serveur prod.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
|||||||
|
---
|
||||||
|
# === IDENTITÉ ===
|
||||||
|
module: M0
|
||||||
|
nom: "Gestion des catégories"
|
||||||
|
ecran: gestion-categories
|
||||||
|
owner_spec: Matthieu
|
||||||
|
backup_spec: Tristan
|
||||||
|
version: V0
|
||||||
|
date_redaction: 2026-05-22
|
||||||
|
|
||||||
|
# === LIENS ===
|
||||||
|
maquette_figma: null # pas de Figma — UI admin standard
|
||||||
|
regles_metier: [RG-1.01, RG-1.02, RG-1.03, RG-1.04, RG-1.05, RG-1.06, RG-1.07, RG-1.08, RG-1.09, RG-1.10, RG-1.11, RG-1.12, RG-1.13]
|
||||||
|
roles: [Admin, Bureau, Compta, Commerciale, Usine]
|
||||||
|
lien_spec_back: ./spec-back.md
|
||||||
|
|
||||||
|
# === VALIDATION CLIENT #1 ===
|
||||||
|
client_validation_1:
|
||||||
|
statut: validee # V0 client validée le 22/05/2026
|
||||||
|
date: 2026-05-22
|
||||||
|
canal: ecrit
|
||||||
|
valide_par: "Matthieu (CP MALIO) — validation implicite, périmètre projet"
|
||||||
|
resume: "Module 0 — Gestion des catégories. Page admin (datatable + drawer). 2 champs (Nom + Type), 3 actions (Ajouter / Consulter / Modifier). Admin only."
|
||||||
|
trace_archivee: "uploads/c4ebb6b4-M0categories.docx (V0 d'origine .docx) — restitué ci-dessous en Markdown."
|
||||||
|
|
||||||
|
# === LIEN LESSTIME ===
|
||||||
|
lesstime_taskgroup_id: 22
|
||||||
|
lesstime_project_id: 6 # ERP / Starseed
|
||||||
|
statut_global: en_dev # tickets créés en backlog Lesstime le 2026-05-26
|
||||||
|
---
|
||||||
|
|
||||||
|
# Module 0 — Gestion des catégories (V0 front)
|
||||||
|
|
||||||
|
> **Origine** : spec front V0 livrée le 22/05/2026 (`c4ebb6b4-M0categories.docx` + `f665acfb-M0categoriesV0.pdf`). Restitution Markdown fidèle pour intégration au workflow MALIO. Le contenu original n'est pas modifié — toute reformulation et précision (en particulier côté back) vit dans [`spec-back.md`](./spec-back.md).
|
||||||
|
|
||||||
|
## But
|
||||||
|
|
||||||
|
Permettre à un administrateur Starseed de gérer un référentiel de **catégories** depuis l'interface admin du logiciel. Ces catégories seront utilisées plus tard pour classifier les tiers (clients, fournisseurs, prestataires).
|
||||||
|
|
||||||
|
## Accès
|
||||||
|
|
||||||
|
- **Depuis** : menu principal → **Administration** → entrée « Gestion des catégories »
|
||||||
|
- **Rôles autorisés** : **Admin uniquement** (Bureau / Compta / Commerciale / Usine n'ont **aucun** accès, ni lecture ni écriture).
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
L'écran est la page d'entrée du Module **Administration**. Titre de la page : « **Gestion des catégories** ».
|
||||||
|
|
||||||
|
- Affichage principal : un **datatable** listant toutes les catégories existantes.
|
||||||
|
- **Clic sur une ligne** → ouverture d'un **drawer** latéral en mode **consultation / modification** (cf. § Action « Consulter »).
|
||||||
|
- **Bouton « + Ajouter »** (en haut à droite du datatable) → ouverture d'un **drawer** en mode **création** (cf. § Action « Ajouter »).
|
||||||
|
- Pas d'onglet, pas de pagination explicite (volumétrie cible faible).
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
| Action | Déclencheur | Comportement |
|
||||||
|
|---|---|---|
|
||||||
|
| **Ajouter** | Clic sur le bouton « + Ajouter » | Ouvre le drawer en mode création, formulaire vide. Validation → POST → la catégorie apparaît dans le datatable. |
|
||||||
|
| **Consulter** | Clic sur une ligne du datatable | Ouvre le drawer avec les champs pré-remplis en lecture (et passage en édition si l'utilisateur modifie un champ). |
|
||||||
|
| **Modifier** | Modification d'un champ dans le drawer ouvert en consultation | Validation → PATCH → la ligne du datatable se met à jour. |
|
||||||
|
|
||||||
|
> **Note V0** : la **suppression** n'était pas mentionnée dans la V0 client. Côté workflow MALIO, suite à la revue back (cf. `spec-back.md` § Q3), un soft delete est ajouté (corbeille logique). L'UI peut intégrer ce point lors d'une V1 — au M0 le bouton « Supprimer » n'est pas obligatoire, mais doit être facilement ajoutable.
|
||||||
|
|
||||||
|
## Formulaire — Champs
|
||||||
|
|
||||||
|
Le formulaire (drawer) contient **2 champs**, tous deux obligatoires :
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Contenu / valeur par défaut | Règle |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Nom** | Texte libre | **Oui** | vide à la création | Pas de règle métier détaillée en V0. Détails côté back : RG-1.02 / RG-1.03 / RG-1.04 (obligatoire, trim, longueur 2–120). |
|
||||||
|
| **Type de catégorie** | Select | **Oui** | vide à la création | Le contenu du Select n'était pas précisé en V0. Décision back : entité de référence `CategoryType` séparée (RG-1.05 / RG-1.06). Le référentiel sera alimenté plus tard (cf. HP-1 dans `spec-back.md`). |
|
||||||
|
|
||||||
|
> **Note V0** : la V0 ne précisait ni si le `Type de catégorie` est un enum hardcodé ni si c'est une autre entité. Décision tranchée côté back avant découpe en tickets : **entité de référence** (`category_types`), table créée vide au M0.
|
||||||
|
|
||||||
|
## Permissions par rôle
|
||||||
|
|
||||||
|
| Rôle | Vue (`GET`) | Création (`POST`) | Édition (`PATCH`) | Suppression (`DELETE`) |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Admin** | ✅ | ✅ | ✅ | ✅ (soft delete — ajout post-V0) |
|
||||||
|
| Bureau | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
| Compta | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
| Commerciale | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
| Usine | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
|
||||||
|
→ Les rôles non-Admin ne voient **pas** l'entrée de menu et reçoivent **403** sur toute requête vers les endpoints `/api/categories/*` (cf. RG-1.01 dans `spec-back.md`).
|
||||||
|
|
||||||
|
## Composants UI à utiliser (Starseed / `@malio/layer-ui`)
|
||||||
|
|
||||||
|
- **Datatable** : `<MalioDataTable>` (avec colonnes `Nom` + `Type` + actions, tri par défaut sur Nom).
|
||||||
|
- **Drawer** : drawer latéral standard `@malio/layer-ui` (à confirmer côté front avec le composant exact).
|
||||||
|
- **Input texte** : `<MalioInputText>` pour le champ Nom.
|
||||||
|
- **Select** : `<MalioSelect>` pour le champ Type de catégorie, alimenté par `GET /api/category_types`.
|
||||||
|
- **Bouton** : `<MalioButton>` (« + Ajouter », « Enregistrer », « Annuler »).
|
||||||
|
- **Toasts succès / erreur** : standards via `useApi()`.
|
||||||
|
|
||||||
|
## Points laissés ouverts par la V0 (résolus côté back)
|
||||||
|
|
||||||
|
| # | Zone d'ombre V0 | Résolution (cf. `spec-back.md`) |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Suppression non mentionnée | **Soft delete** ajouté (RG-1.12 + RG-1.13). UI peut ajouter le bouton plus tard. |
|
||||||
|
| 2 | Unicité du nom non précisée | **Unicité sur `(name, type)` case-insensitive**, parmi non-soft-deleted (RG-1.07). |
|
||||||
|
| 3 | Nature du `Type de catégorie` (enum vs entité) | **Entité de référence** `CategoryType` (table vide au M0, créée par migration). |
|
||||||
|
| 4 | Volumétrie & pagination | **300 max** → pagination front (`<MalioDataTable>`), pas de pagination serveur. Tri serveur `name ASC` par défaut. |
|
||||||
|
| 5 | Audit / traçabilité | Pattern `#[Auditable]` Starseed standard. Trace dans la table `audit_log` (qui / quoi / quand / diff). **Pas** de colonnes `created_by` / `updated_by` sur l'entité (cohérent avec User / Role dans Starseed). Historique consultable via `/api/audit-log?entityType=Category&entityId={id}`. |
|
||||||
|
| 6 | Référencement par d'autres entités | **Aucune FK entrante au M0.** Les modules Tiers (M-Clients / M-Fournisseurs / M-Prestas) ajouteront leur propre `category_id` plus tard. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Tickets Lesstime générés
|
||||||
|
|
||||||
|
**TaskGroup Lesstime** : `#22 — M0 — Gestion des catégories` (projet `ERP / Starseed`, projectId=6)
|
||||||
|
|
||||||
|
> Détail complet, table des tickets et action manuelle dans Lesstime → voir [`spec-back.md § Tickets Lesstime générés`](./spec-back.md#-tickets-lesstime-générés).
|
||||||
@@ -5,8 +5,9 @@ APP_SECRET=CHANGE_ME_IN_PRODUCTION
|
|||||||
DATABASE_URL="postgresql://starseed:CHANGE_ME@host.docker.internal:5432/starseed_prod?serverVersion=16&charset=utf8"
|
DATABASE_URL="postgresql://starseed:CHANGE_ME@host.docker.internal:5432/starseed_prod?serverVersion=16&charset=utf8"
|
||||||
|
|
||||||
JWT_PASSPHRASE=CHANGE_ME_IN_PRODUCTION
|
JWT_PASSPHRASE=CHANGE_ME_IN_PRODUCTION
|
||||||
JWT_COOKIE_SECURE=1
|
# HTTP en reseau local => cookie non secure
|
||||||
|
JWT_COOKIE_SECURE=0
|
||||||
JWT_TOKEN_TTL=86400
|
JWT_TOKEN_TTL=86400
|
||||||
JWT_COOKIE_TTL=86400
|
JWT_COOKIE_TTL=86400
|
||||||
|
|
||||||
CORS_ALLOW_ORIGIN='^https://starseed\.malio-dev\.fr$'
|
CORS_ALLOW_ORIGIN='^http://starseed\.malio-dev\.fr$'
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ help:
|
|||||||
@printf " \033[36m%-28s\033[0m %s\n" "install-e2e-deps" "One-time : Chromium + libs systeme (sudo)"
|
@printf " \033[36m%-28s\033[0m %s\n" "install-e2e-deps" "One-time : Chromium + libs systeme (sudo)"
|
||||||
@printf "\n \033[1;33mQualite code\033[0m\n"
|
@printf "\n \033[1;33mQualite code\033[0m\n"
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "php-cs-fixer-allow-risky" "Fix code style PHP (utilise par le pre-commit)"
|
@printf " \033[36m%-28s\033[0m %s\n" "php-cs-fixer-allow-risky" "Fix code style PHP (utilise par le pre-commit)"
|
||||||
|
@printf " \033[36m%-28s\033[0m %s\n" "php-cs-fixer-check" "Dry-run du fixer (CI / verif avant push)"
|
||||||
@printf "\n Plus de details : \033[4mREADME.md\033[0m, \033[4mCLAUDE.md\033[0m\n\n"
|
@printf "\n Plus de details : \033[4mREADME.md\033[0m, \033[4mCLAUDE.md\033[0m\n\n"
|
||||||
|
|
||||||
env-init:
|
env-init:
|
||||||
@@ -258,6 +259,11 @@ php-cs-fixer-allow-risky:
|
|||||||
@echo "Fixing files: $(FILES)"
|
@echo "Fixing files: $(FILES)"
|
||||||
$(EXEC_PHP_CS_FIXER) fix --config=.php-cs-fixer.dist.php --allow-risky=yes $(FILES)
|
$(EXEC_PHP_CS_FIXER) fix --config=.php-cs-fixer.dist.php --allow-risky=yes $(FILES)
|
||||||
|
|
||||||
|
# Dry-run du fixer : echec si au moins un fichier n'est pas conforme.
|
||||||
|
# Utilise par la CI (Gitea pull_request) et avant un push manuel.
|
||||||
|
php-cs-fixer-check:
|
||||||
|
$(EXEC_PHP_CS_FIXER) fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff $(FILES)
|
||||||
|
|
||||||
test:
|
test:
|
||||||
$(EXEC_PHP) php -d memory_limit="512M" vendor/bin/phpunit $(FILES)
|
$(EXEC_PHP) php -d memory_limit="512M" vendor/bin/phpunit $(FILES)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Domain\Contract;
|
||||||
|
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat lu par le TimestampableBlamableSubscriber.
|
||||||
|
*
|
||||||
|
* Toute entite qui l'implemente voit ses colonnes `created_by` / `updated_by`
|
||||||
|
* remplies automatiquement avec l'utilisateur authentifie (ou laissees a null
|
||||||
|
* hors contexte HTTP : CLI, cron, migration).
|
||||||
|
*
|
||||||
|
* Le type-hint cible `Symfony\Component\Security\Core\User\UserInterface`
|
||||||
|
* (deja implementee par App\Module\Core\Domain\Entity\User) pour eviter de
|
||||||
|
* coupler Shared a Module/Core. La classe concrete est resolue par Doctrine
|
||||||
|
* via `resolve_target_entities` (cf. config/packages/doctrine.yaml).
|
||||||
|
*/
|
||||||
|
interface BlamableInterface
|
||||||
|
{
|
||||||
|
public function getCreatedBy(): ?UserInterface;
|
||||||
|
|
||||||
|
public function setCreatedBy(?UserInterface $user): void;
|
||||||
|
|
||||||
|
public function getUpdatedBy(): ?UserInterface;
|
||||||
|
|
||||||
|
public function setUpdatedBy(?UserInterface $user): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Domain\Contract;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat lu par le TimestampableBlamableSubscriber.
|
||||||
|
*
|
||||||
|
* Toute entite qui l'implemente voit ses colonnes `created_at` / `updated_at`
|
||||||
|
* remplies automatiquement au prePersist / preUpdate. Le porteur des colonnes
|
||||||
|
* et des accesseurs est le TimestampableBlamableTrait.
|
||||||
|
*/
|
||||||
|
interface TimestampableInterface
|
||||||
|
{
|
||||||
|
public function getCreatedAt(): ?DateTimeImmutable;
|
||||||
|
|
||||||
|
public function setCreatedAt(DateTimeImmutable $createdAt): void;
|
||||||
|
|
||||||
|
public function getUpdatedAt(): ?DateTimeImmutable;
|
||||||
|
|
||||||
|
public function setUpdatedAt(DateTimeImmutable $updatedAt): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Domain\Trait;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trait Doctrine qui porte les 4 colonnes Timestampable + Blamable.
|
||||||
|
*
|
||||||
|
* Usage : `use TimestampableBlamableTrait;` dans l'entite, +
|
||||||
|
* `implements TimestampableInterface, BlamableInterface`. Le
|
||||||
|
* TimestampableBlamableSubscriber remplit les colonnes automatiquement
|
||||||
|
* au prePersist / preUpdate.
|
||||||
|
*
|
||||||
|
* Les Groups Serializer utilisent une convention `default:read` agregee :
|
||||||
|
* pour exposer les 4 colonnes dans une reponse API d'une entite X, ajouter
|
||||||
|
* `default:read` au normalizationContext aux cotes du groupe `x:read`.
|
||||||
|
*/
|
||||||
|
trait TimestampableBlamableTrait
|
||||||
|
{
|
||||||
|
#[ORM\Column(name: 'created_at', type: 'datetime_immutable')]
|
||||||
|
#[Groups(['default:read'])]
|
||||||
|
private ?DateTimeImmutable $createdAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'updated_at', type: 'datetime_immutable')]
|
||||||
|
#[Groups(['default:read'])]
|
||||||
|
private ?DateTimeImmutable $updatedAt = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||||
|
#[ORM\JoinColumn(name: 'created_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||||
|
#[Groups(['default:read'])]
|
||||||
|
private ?UserInterface $createdBy = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||||
|
#[ORM\JoinColumn(name: 'updated_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||||
|
#[Groups(['default:read'])]
|
||||||
|
private ?UserInterface $updatedBy = null;
|
||||||
|
|
||||||
|
public function getCreatedAt(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCreatedAt(DateTimeImmutable $createdAt): void
|
||||||
|
{
|
||||||
|
$this->createdAt = $createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUpdatedAt(DateTimeImmutable $updatedAt): void
|
||||||
|
{
|
||||||
|
$this->updatedAt = $updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedBy(): ?UserInterface
|
||||||
|
{
|
||||||
|
return $this->createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCreatedBy(?UserInterface $user): void
|
||||||
|
{
|
||||||
|
$this->createdBy = $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedBy(): ?UserInterface
|
||||||
|
{
|
||||||
|
return $this->updatedBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUpdatedBy(?UserInterface $user): void
|
||||||
|
{
|
||||||
|
$this->updatedBy = $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Infrastructure\Doctrine;
|
||||||
|
|
||||||
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
|
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||||
|
use Doctrine\ORM\Event\PrePersistEventArgs;
|
||||||
|
use Doctrine\ORM\Event\PreUpdateEventArgs;
|
||||||
|
use Doctrine\ORM\Events;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listener Doctrine global qui remplit automatiquement les colonnes
|
||||||
|
* Timestampable + Blamable.
|
||||||
|
*
|
||||||
|
* Pattern aligne sur AuditListener (cf.
|
||||||
|
* src/Module/Core/Infrastructure/Doctrine/AuditListener.php) : declare via
|
||||||
|
* #[AsDoctrineListener], auto-wire par le DoctrineBundle.
|
||||||
|
*
|
||||||
|
* Regle Blamable : si aucun utilisateur n'est authentifie (CLI, cron,
|
||||||
|
* migration), les FK `created_by` / `updated_by` restent a null. L'affichage
|
||||||
|
* front gere le libelle « Systeme » pour null.
|
||||||
|
*/
|
||||||
|
#[AsDoctrineListener(event: Events::prePersist)]
|
||||||
|
#[AsDoctrineListener(event: Events::preUpdate)]
|
||||||
|
final class TimestampableBlamableSubscriber
|
||||||
|
{
|
||||||
|
public function __construct(private readonly Security $security) {}
|
||||||
|
|
||||||
|
public function prePersist(PrePersistEventArgs $args): void
|
||||||
|
{
|
||||||
|
$entity = $args->getObject();
|
||||||
|
$now = new DateTimeImmutable();
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
|
||||||
|
if ($entity instanceof TimestampableInterface) {
|
||||||
|
$entity->setCreatedAt($now);
|
||||||
|
$entity->setUpdatedAt($now);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($entity instanceof BlamableInterface && $user instanceof UserInterface) {
|
||||||
|
$entity->setCreatedBy($user);
|
||||||
|
$entity->setUpdatedBy($user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function preUpdate(PreUpdateEventArgs $args): void
|
||||||
|
{
|
||||||
|
$entity = $args->getObject();
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
|
||||||
|
if ($entity instanceof TimestampableInterface) {
|
||||||
|
$entity->setUpdatedAt(new DateTimeImmutable());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($entity instanceof BlamableInterface && $user instanceof UserInterface) {
|
||||||
|
$entity->setUpdatedBy($user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Architecture;
|
||||||
|
|
||||||
|
use App\Module\Core\Domain\Entity\Permission;
|
||||||
|
use App\Module\Core\Domain\Entity\Role;
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\Sites\Domain\Entity\Site;
|
||||||
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
|
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||||
|
use Doctrine\ORM\Mapping\Entity;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionClass;
|
||||||
|
use Symfony\Component\Finder\Finder;
|
||||||
|
|
||||||
|
use function in_array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Garde-fou architecture (niveau L3 de la spec § 2.8.bis).
|
||||||
|
*
|
||||||
|
* Scanne toutes les entites Doctrine sous `src/Module/<module>/Domain/Entity/`
|
||||||
|
* et verifie qu'elles implementent TimestampableInterface ET BlamableInterface
|
||||||
|
* (via TimestampableBlamableTrait). Empeche tout oubli du pattern sur une
|
||||||
|
* nouvelle entite metier : la CI passe au rouge.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class EntitiesAreTimestampableBlamableTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Entites explicitement exemptees du pattern.
|
||||||
|
*
|
||||||
|
* Au M0, on whiteliste les 4 entites preexistantes du noyau (creees avant
|
||||||
|
* l'introduction du pattern) : leur retrofit est une decision archi a part
|
||||||
|
* entiere, hors scope ERP-52.
|
||||||
|
*
|
||||||
|
* - User : referentiel d'authentification, createdAt gere manuellement dans
|
||||||
|
* le constructeur. Retrofit hors scope M0 (cf. HP-9) : impose de trancher
|
||||||
|
* la recursion Blamable (un User cree par un User) + casse des tests
|
||||||
|
* existants.
|
||||||
|
* - Role : referentiel RBAC synchronise via `app:sync-permissions`, pas de
|
||||||
|
* tracabilite user-driven necessaire.
|
||||||
|
* - Permission : idem Role (synchronise, pas pilote utilisateur).
|
||||||
|
* - Site : referentiel admin-managed, a integrer dans un futur module Sites
|
||||||
|
* v2 (cf. HP-10).
|
||||||
|
*
|
||||||
|
* Les futurs referentiels statiques (ex: CategoryType au ticket 0.2)
|
||||||
|
* s'ajoutent ici avec une justification.
|
||||||
|
*/
|
||||||
|
private const EXCLUDED = [
|
||||||
|
User::class,
|
||||||
|
Role::class,
|
||||||
|
Permission::class,
|
||||||
|
Site::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function testAllBusinessEntitiesImplementBothInterfaces(): void
|
||||||
|
{
|
||||||
|
// Garde : chaque entree de la whitelist doit pointer sur une classe
|
||||||
|
// reelle. Empeche un FQCN errone de masquer silencieusement un oubli.
|
||||||
|
foreach (self::EXCLUDED as $excluded) {
|
||||||
|
self::assertTrue(class_exists($excluded), sprintf('Classe whitelistee inexistante : %s', $excluded));
|
||||||
|
}
|
||||||
|
|
||||||
|
$finder = new Finder()
|
||||||
|
->files()
|
||||||
|
->in(__DIR__.'/../../src/Module')
|
||||||
|
->path('Domain/Entity')
|
||||||
|
->name('*.php')
|
||||||
|
;
|
||||||
|
|
||||||
|
// Garde : si le scan ne trouve rien, le chemin est casse — le test
|
||||||
|
// deviendrait un faux positif vert. On verifie qu'il a du grain a moudre.
|
||||||
|
self::assertNotEmpty(iterator_to_array($finder), 'Aucune entite scannee : chemin src/Module invalide ?');
|
||||||
|
|
||||||
|
foreach ($finder as $file) {
|
||||||
|
$fqcn = $this->extractFqcn($file->getRealPath());
|
||||||
|
if (null === $fqcn || in_array($fqcn, self::EXCLUDED, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reflection = new ReflectionClass($fqcn);
|
||||||
|
// On ignore les classes abstraites et tout ce qui n'est pas une
|
||||||
|
// entite Doctrine (value objects, embeddables non mappes, etc.).
|
||||||
|
if ($reflection->isAbstract() || [] === $reflection->getAttributes(Entity::class)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertTrue(
|
||||||
|
$reflection->implementsInterface(TimestampableInterface::class)
|
||||||
|
&& $reflection->implementsInterface(BlamableInterface::class),
|
||||||
|
sprintf(
|
||||||
|
'L\'entite %s doit implementer TimestampableInterface ET BlamableInterface '
|
||||||
|
.'(utiliser TimestampableBlamableTrait). Si c\'est un referentiel statique '
|
||||||
|
.'justifie, l\'ajouter dans EntitiesAreTimestampableBlamableTest::EXCLUDED.',
|
||||||
|
$fqcn,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrait le FQCN (namespace + classe) d'un fichier PHP par lecture du
|
||||||
|
* source, sans charger le fichier.
|
||||||
|
*/
|
||||||
|
private function extractFqcn(string $path): ?string
|
||||||
|
{
|
||||||
|
$source = file_get_contents($path);
|
||||||
|
if (false === $source) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
1 !== preg_match('/^namespace\s+([^;]+);/m', $source, $nsMatch)
|
||||||
|
|| 1 !== preg_match('/^(?:final\s+|abstract\s+|readonly\s+)*class\s+(\w+)/m', $source, $classMatch)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim($nsMatch[1]).'\\'.$classMatch[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Shared\Infrastructure\Doctrine;
|
||||||
|
|
||||||
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
|
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||||
|
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||||
|
use App\Shared\Infrastructure\Doctrine\TimestampableBlamableSubscriber;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Doctrine\ORM\Event\PrePersistEventArgs;
|
||||||
|
use Doctrine\ORM\Event\PreUpdateEventArgs;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests unitaires du TimestampableBlamableSubscriber.
|
||||||
|
*
|
||||||
|
* On exerce directement prePersist / preUpdate avec un EntityManager et une
|
||||||
|
* Security stubbes — aucun boot de kernel, aucun acces BDD. Les entites de test
|
||||||
|
* sont des fixtures internes (cf. bas de fichier).
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class TimestampableBlamableSubscriberTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testPrePersistWithUser(): void
|
||||||
|
{
|
||||||
|
$user = $this->createStub(UserInterface::class);
|
||||||
|
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user));
|
||||||
|
$entity = new FullAuditableFixture();
|
||||||
|
|
||||||
|
$subscriber->prePersist($this->prePersistArgs($entity));
|
||||||
|
|
||||||
|
// Les 4 colonnes sont remplies : dates posees, blame = user courant.
|
||||||
|
self::assertInstanceOf(DateTimeImmutable::class, $entity->getCreatedAt());
|
||||||
|
self::assertInstanceOf(DateTimeImmutable::class, $entity->getUpdatedAt());
|
||||||
|
self::assertSame($entity->getCreatedAt(), $entity->getUpdatedAt());
|
||||||
|
self::assertSame($user, $entity->getCreatedBy());
|
||||||
|
self::assertSame($user, $entity->getUpdatedBy());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPrePersistWithoutUser(): void
|
||||||
|
{
|
||||||
|
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning(null));
|
||||||
|
$entity = new FullAuditableFixture();
|
||||||
|
|
||||||
|
$subscriber->prePersist($this->prePersistArgs($entity));
|
||||||
|
|
||||||
|
// Hors contexte HTTP (CLI / cron) : dates remplies, blame laisse a null.
|
||||||
|
self::assertInstanceOf(DateTimeImmutable::class, $entity->getCreatedAt());
|
||||||
|
self::assertInstanceOf(DateTimeImmutable::class, $entity->getUpdatedAt());
|
||||||
|
self::assertNull($entity->getCreatedBy());
|
||||||
|
self::assertNull($entity->getUpdatedBy());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPreUpdate(): void
|
||||||
|
{
|
||||||
|
$user = $this->createStub(UserInterface::class);
|
||||||
|
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user));
|
||||||
|
|
||||||
|
// On simule une entite deja persistee : createdAt fige dans le passe,
|
||||||
|
// createdBy positionne par une creation anterieure.
|
||||||
|
$createdAt = new DateTimeImmutable('2020-01-01 10:00:00');
|
||||||
|
$entity = new FullAuditableFixture();
|
||||||
|
$entity->setCreatedAt($createdAt);
|
||||||
|
$entity->setUpdatedAt($createdAt);
|
||||||
|
|
||||||
|
$subscriber->preUpdate($this->preUpdateArgs($entity));
|
||||||
|
|
||||||
|
// updatedAt avance, createdAt reste fige, updatedBy = user courant.
|
||||||
|
self::assertSame($createdAt, $entity->getCreatedAt());
|
||||||
|
self::assertGreaterThan($createdAt, $entity->getUpdatedAt());
|
||||||
|
self::assertSame($user, $entity->getUpdatedBy());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPartialEntityTimestampableOnly(): void
|
||||||
|
{
|
||||||
|
$user = $this->createStub(UserInterface::class);
|
||||||
|
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user));
|
||||||
|
$entity = new TimestampableOnlyFixture();
|
||||||
|
|
||||||
|
// Entite Timestampable mais NON Blamable : seules les dates sont posees,
|
||||||
|
// aucun appel de blame (et aucune erreur).
|
||||||
|
$subscriber->prePersist($this->prePersistArgs($entity));
|
||||||
|
|
||||||
|
self::assertInstanceOf(DateTimeImmutable::class, $entity->getCreatedAt());
|
||||||
|
self::assertInstanceOf(DateTimeImmutable::class, $entity->getUpdatedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security stubbee renvoyant l'utilisateur fourni (ou null).
|
||||||
|
*/
|
||||||
|
private function securityReturning(?UserInterface $user): Security
|
||||||
|
{
|
||||||
|
$security = $this->createStub(Security::class);
|
||||||
|
$security->method('getUser')->willReturn($user);
|
||||||
|
|
||||||
|
return $security;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function prePersistArgs(object $entity): PrePersistEventArgs
|
||||||
|
{
|
||||||
|
return new PrePersistEventArgs($entity, $this->createStub(EntityManagerInterface::class));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function preUpdateArgs(object $entity): PreUpdateEventArgs
|
||||||
|
{
|
||||||
|
$changeSet = [];
|
||||||
|
|
||||||
|
return new PreUpdateEventArgs($entity, $this->createStub(EntityManagerInterface::class), $changeSet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixture interne : entite metier complete (Timestampable + Blamable) via le
|
||||||
|
* Trait reel teste.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class FullAuditableFixture implements TimestampableInterface, BlamableInterface
|
||||||
|
{
|
||||||
|
use TimestampableBlamableTrait;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixture interne : entite Timestampable seule (sans Blamable), pour verifier
|
||||||
|
* la dissociation des deux contrats par le Subscriber.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class TimestampableOnlyFixture implements TimestampableInterface
|
||||||
|
{
|
||||||
|
private ?DateTimeImmutable $createdAt = null;
|
||||||
|
private ?DateTimeImmutable $updatedAt = null;
|
||||||
|
|
||||||
|
public function getCreatedAt(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCreatedAt(DateTimeImmutable $createdAt): void
|
||||||
|
{
|
||||||
|
$this->createdAt = $createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUpdatedAt(DateTimeImmutable $updatedAt): void
|
||||||
|
{
|
||||||
|
$this->updatedAt = $updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user