/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]; } }