Compare commits

..

4 Commits

Author SHA1 Message Date
Matthieu 94b3acec05 ci : retire tout le caching (backend de cache runner injoignable, timeout 4m30)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m15s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m9s
Les logs montrent que chaque operation actions/cache attend ~4m30 avant
ETIMEDOUT sur le serveur de cache du runner Gitea (51.91.78.99:39531) :
- cache: npm de setup-node = tout le 'Setup Node 22' (271s)
- cache node_modules et cache .nuxt : timeouts additionnels
- cache Composer cote backend : meme risque

Node 22 est deja dans le tool-cache (install instantane), npm ci a froid
~30s, build ~20s : le caching n'apportait rien ici. A re-activer si le
serveur de cache du runner est repare.
2026-05-27 16:21:27 +02:00
Matthieu f5312686ab ci(frontend) : accelere le job PR (nuxt build + cache node_modules & build Nuxt/Vite)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m35s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 11m44s
- remplace build:dist (nuxt generate + prerender inutile en SPA) par nuxt build
- cache node_modules sur hash du lockfile, npm ci uniquement en cache miss
- regenere les types Nuxt (postinstall) en cache hit
- cache des artefacts .nuxt / Vite avec restore-keys pour eviter le build a froid
2026-05-27 15:46:54 +02:00
Matthieu d4f234ec55 docs(catalog) : document EXCLUDED rationale and add HP-9/HP-10
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m32s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 10m10s
2026-05-27 15:31:45 +02:00
Matthieu d0c3fb7558 feat(shared) : add Timestampable + Blamable Shared pattern (Trait + Interfaces + Subscriber + test) 2026-05-27 15:30:15 +02:00
10 changed files with 540 additions and 11 deletions
+21
View File
@@ -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.
+12 -11
View File
@@ -60,14 +60,9 @@ jobs:
coverage: none coverage: none
tools: composer:v2 tools: composer:v2
- name: Cache Composer # Cache Composer retire : meme cause que cote front — le backend de cache
uses: actions/cache@v4 # du runner Gitea est injoignable (ETIMEDOUT) et fait timeouter le step
with: # ~4 min 30. A re-activer si le serveur de cache du runner est repare.
path: ~/.composer/cache
key: composer-${{ hashFiles('composer.lock') }}
restore-keys: |
composer-
- name: Install PHP dependencies - name: Install PHP dependencies
run: composer install --no-interaction --no-progress --prefer-dist run: composer install --no-interaction --no-progress --prefer-dist
@@ -99,12 +94,15 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 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 - name: Setup Node 22
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '22' node-version: '22'
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install Node dependencies - name: Install Node dependencies
run: npm ci run: npm ci
@@ -115,5 +113,8 @@ jobs:
- name: Unit tests (Vitest) - name: Unit tests (Vitest)
run: npm run test 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) - name: Build production (nuxt build)
run: npm run build:dist run: npm run build
+4
View File
@@ -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
+17
View File
@@ -441,6 +441,21 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
Coût d'écriture : 1h. Coût en CI : ~50ms. Bénéfice : 0 oubli possible. À écrire dans le ticket 0.0. Coût d'écriture : 1h. Coût en CI : ~50ms. Bénéfice : 0 oubli possible. À écrire dans le ticket 0.0.
#### Décision M0 sur la whitelist `EXCLUDED` (ERP-52)
Au moment d'introduire le pattern (ticket 0.0), 4 entités préexistantes vivent déjà sous `src/Module/*/Domain/Entity/` : `User`, `Role`, `Permission` (Core) et `Site` (Sites). Aucune n'implémente le pattern. Le test L3 les détecterait et passerait au rouge.
**Décision** : on **whiteliste explicitement ces 4 entités** dans `EntitiesAreTimestampableBlamableTest::EXCLUDED` avec une justification par entrée, plutôt que de les rétrofiter dans ERP-52 :
| Entité | Justification du `EXCLUDED` |
|---|---|
| `User` | Référentiel d'authentification ; `createdAt` géré manuellement dans le constructeur. Rétrofit non trivial : impose de trancher la récursion Blamable (un `User` créé par un `User`) et casse des tests existants → **HP-9**. |
| `Role` | Référentiel RBAC synchronisé via `app:sync-permissions`, pas de traçabilité user-driven nécessaire. |
| `Permission` | Idem `Role` (synchronisé, pas piloté utilisateur). |
| `Site` | Référentiel admin-managed, rétrofit à intégrer dans un futur module Sites v2 → **HP-10**. |
**Règle dure pour la suite** : toute **nouvelle** entité métier (`Category` au M0, puis `Client`, `Fournisseur`, `Prestataire`, etc.) **doit** implémenter `TimestampableInterface` + `BlamableInterface` via le Trait. La whitelist `EXCLUDED` est réservée aux référentiels statiques justifiés (ex : `CategoryType` au ticket 0.2) — toute nouvelle entrée doit être documentée.
#### Tests Subscriber #### Tests Subscriber
Tests unitaires du Subscriber : créer une entité de test minimale (fixture interne aux tests) qui `use` le Trait + implements les interfaces, vérifier que `prePersist` + `preUpdate` remplissent les 4 colonnes. À écrire dans le ticket 0.0. Tests unitaires du Subscriber : créer une entité de test minimale (fixture interne aux tests) qui `use` le Trait + implements les interfaces, vérifier que `prePersist` + `preUpdate` remplissent les 4 colonnes. À écrire dans le ticket 0.0.
@@ -979,6 +994,8 @@ Les deux mécanismes sont indépendants : on peut désactiver `#[Auditable]` (pa
- **HP-6** : **Filtres avancés / recherche serveur** dans la liste. Pas pertinent à 300 entrées (pagination front). - **HP-6** : **Filtres avancés / recherche serveur** dans la liste. Pas pertinent à 300 entrées (pagination front).
- **HP-7** : **Catégories hiérarchiques** (parent / enfant). Pas demandé. Si besoin futur → migration ajout colonne `parent_id` + spec dédiée. - **HP-7** : **Catégories hiérarchiques** (parent / enfant). Pas demandé. Si besoin futur → migration ajout colonne `parent_id` + spec dédiée.
- **HP-8** : **Création des rôles métier Bureau / Compta / Commerciale / Usine.** Ces rôles font partie du modèle MALIO mais leur seed initial dans `role` + leur attribution aux users est hors du périmètre M0 (probablement un M-RBAC dédié, ou seedés dans `AppFixtures` / `SeedE2ECommand` au fil des modules). - **HP-8** : **Création des rôles métier Bureau / Compta / Commerciale / Usine.** Ces rôles font partie du modèle MALIO mais leur seed initial dans `role` + leur attribution aux users est hors du périmètre M0 (probablement un M-RBAC dédié, ou seedés dans `AppFixtures` / `SeedE2ECommand` au fil des modules).
- **HP-9** : **Rétrofit de `User` vers Timestampable + Blamable.** L'entité `User` est whitelistée dans `EntitiesAreTimestampableBlamableTest::EXCLUDED` au M0 (cf. § 2.8.bis). Son rétrofit nécessite une **décision archi dédiée** : récursion Blamable (un `User` créé/modifié par un `User`, FK auto-référente `created_by` / `updated_by` sur la table `user`), impact sur le `createdAt` déjà géré dans le constructeur, et migration des données existantes. À traiter dans un ticket scopé hors M0.
- **HP-10** : **Rétrofit de `Site` vers Timestampable + Blamable.** Même logique que HP-9 pour le référentiel `Site` (whitelisté `EXCLUDED`). À intégrer dans un futur module Sites v2, avec la migration ajoutant les 4 colonnes + FK `user`.
## 10. Liens & dépendances ## 10. Liens & dépendances
@@ -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;
}
}