From d0c3fb7558adeaa001b7c5c7236ec79deb20dc7a Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 27 May 2026 15:30:15 +0200 Subject: [PATCH] feat(shared) : add Timestampable + Blamable Shared pattern (Trait + Interfaces + Subscriber + test) --- config/packages/doctrine.yaml | 4 + .../Domain/Contract/BlamableInterface.php | 30 ++++ .../Contract/TimestampableInterface.php | 25 +++ .../Trait/TimestampableBlamableTrait.php | 83 +++++++++ .../TimestampableBlamableSubscriber.php | 65 +++++++ .../EntitiesAreTimestampableBlamableTest.php | 124 ++++++++++++++ .../TimestampableBlamableSubscriberTest.php | 159 ++++++++++++++++++ 7 files changed, 490 insertions(+) create mode 100644 src/Shared/Domain/Contract/BlamableInterface.php create mode 100644 src/Shared/Domain/Contract/TimestampableInterface.php create mode 100644 src/Shared/Domain/Trait/TimestampableBlamableTrait.php create mode 100644 src/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriber.php create mode 100644 tests/Architecture/EntitiesAreTimestampableBlamableTest.php create mode 100644 tests/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriberTest.php diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 48eb309..c7ade33 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -33,6 +33,10 @@ doctrine: # `App\Module\Sites\Domain\Entity\Site` dans User.php. resolve_target_entities: 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: Core: type: attribute diff --git a/src/Shared/Domain/Contract/BlamableInterface.php b/src/Shared/Domain/Contract/BlamableInterface.php new file mode 100644 index 0000000..327d47c --- /dev/null +++ b/src/Shared/Domain/Contract/BlamableInterface.php @@ -0,0 +1,30 @@ +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; + } +} diff --git a/src/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriber.php b/src/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriber.php new file mode 100644 index 0000000..99233f6 --- /dev/null +++ b/src/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriber.php @@ -0,0 +1,65 @@ +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); + } + } +} diff --git a/tests/Architecture/EntitiesAreTimestampableBlamableTest.php b/tests/Architecture/EntitiesAreTimestampableBlamableTest.php new file mode 100644 index 0000000..e6fd407 --- /dev/null +++ b/tests/Architecture/EntitiesAreTimestampableBlamableTest.php @@ -0,0 +1,124 @@ +/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]; + } +} diff --git a/tests/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriberTest.php b/tests/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriberTest.php new file mode 100644 index 0000000..b101606 --- /dev/null +++ b/tests/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriberTest.php @@ -0,0 +1,159 @@ +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; + } +}