[ERP-52] Créer le pattern Timestampable + Blamable Shared (#13)
Auto Tag Develop / tag (push) Successful in 9s

## Contexte
Ticket Lesstime : [#52](https://project.malio-dev.fr/projects/6/tasks/463)
Position dans le groupe M0 : 0.0 (prérequis transverse)

## Implémentation
- 2 interfaces (`TimestampableInterface`, `BlamableInterface`) dans `Shared/Domain/Contract/`
- 1 trait (`TimestampableBlamableTrait`) dans `Shared/Domain/Trait/`
- 1 Subscriber Doctrine (`TimestampableBlamableSubscriber`) dans `Shared/Infrastructure/Doctrine/`
- 1 ligne `resolve_target_entities` ajoutée à `config/packages/doctrine.yaml` (`UserInterface` → `User`)
- 1 test architecture (`EntitiesAreTimestampableBlamableTest`) garde-fou L3 de la spec § 2.8.bis
- 1 test unitaire (`TimestampableBlamableSubscriberTest`) 4 cas

## Décision EXCLUDED (cf. réponse review)
Les 4 entités préexistantes (`User`, `Role`, `Permission`, `Site`) sont **whitelistées** dans `EXCLUDED` avec justification par entrée, plutôt que rétrofitées dans ce ticket. Le rétrofit de `User` et `Site` est documenté en **HP-9 / HP-10** (récursion Blamable + migration → décision archi scopée). Doc mise à jour : spec § 2.8.bis, § 9, et `.claude/rules/backend.md`.

## Tests
- PHPUnit : 5 nouveaux tests, 0 échec, 0 risky (248 tests / 874 assertions au total)
- php-cs-fixer : OK

## Reviewer suggéré
- Tristan

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #13
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #13.
This commit is contained in:
2026-05-28 09:37:18 +00:00
committed by admin malio
parent 6c27ac8640
commit 6efe7aa8ea
9 changed files with 528 additions and 0 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.
+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;
}
}