Merge branch 'develop' into fix/ERP-100-decouple-list-hydration
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Architecture;
|
||||
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
use function is_string;
|
||||
use function sprintf;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
/**
|
||||
* Garde-fou architecture : toute entite `#[Auditable]` doit avoir son libelle
|
||||
* i18n dans le bloc `audit.entity` du `fr.json` du shell.
|
||||
*
|
||||
* Pourquoi : le filtre « Type d'entite » de l'audit-log est dynamique
|
||||
* (`GET /audit-log-entity-types` renvoie les `entity_type` distincts presents
|
||||
* en base). Des qu'un module audite une entite, un nouveau type apparait. Le
|
||||
* rendu front (`formatEntityType`, audit-log.vue) construit la cle
|
||||
* `audit.entity.<module>_<entity>` et, faute de traduction, retombe
|
||||
* SILENCIEUSEMENT sur le type technique brut (ex: `commercial.Client`). Le
|
||||
* manque passe donc inapercu jusqu'a observation dans l'UI.
|
||||
*
|
||||
* Ce test rend le manque BLOQUANT (meme esprit que ColumnsHaveSqlCommentTest) :
|
||||
* il scanne les entites `#[Auditable]` sous `src/Module/<m>/Domain/Entity/`,
|
||||
* derive la cle attendue comme le fait le front, et echoue si elle est absente
|
||||
* du `fr.json`.
|
||||
*
|
||||
* Derivation de la cle (miroir exact de AuditListener::formatEntityType + de
|
||||
* formatEntityType cote front) :
|
||||
* FQCN `App\Module\Commercial\Domain\Entity\ClientAddress`
|
||||
* -> entity_type `commercial.ClientAddress` (module en minuscules, Entity intacte)
|
||||
* -> cle i18n `commercial_clientaddress` (tout en minuscules, `.` -> `_`)
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class AuditableEntitiesHaveI18nLabelTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Chemin du fichier de traductions FR du shell. Source unique des libelles
|
||||
* d'entite audit (decision ERP-99 : emplacement centralise, schema flat).
|
||||
*/
|
||||
private const LOCALE_FILE = __DIR__.'/../../frontend/i18n/locales/fr.json';
|
||||
|
||||
public function testEveryAuditableEntityHasAnI18nLabel(): void
|
||||
{
|
||||
$labels = $this->loadAuditEntityLabels();
|
||||
|
||||
$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 ?');
|
||||
|
||||
$checked = 0;
|
||||
foreach ($finder as $file) {
|
||||
$fqcn = $this->extractFqcn($file->getRealPath());
|
||||
if (null === $fqcn) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reflection = new ReflectionClass($fqcn);
|
||||
// On ne s'interesse qu'aux entites reellement auditees.
|
||||
if ($reflection->isAbstract() || [] === $reflection->getAttributes(Auditable::class)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $this->deriveI18nKey($fqcn);
|
||||
self::assertNotNull(
|
||||
$key,
|
||||
sprintf('Entite %s hors structure modulaire attendue (App\Module\<M>\Domain\Entity\<E>).', $fqcn),
|
||||
);
|
||||
|
||||
self::assertArrayHasKey(
|
||||
$key,
|
||||
$labels,
|
||||
sprintf(
|
||||
'L\'entite auditable %s n\'a pas de libelle i18n. Ajouter "%s" dans le bloc '
|
||||
.'`audit.entity` de frontend/i18n/locales/fr.json (sinon le filtre audit-log '
|
||||
.'affiche le type technique brut). Cf. ERP-99 + .claude/rules/backend.md § Audit.',
|
||||
$fqcn,
|
||||
$key,
|
||||
),
|
||||
);
|
||||
self::assertNotSame('', trim($labels[$key]), sprintf('Le libelle audit "%s" est vide.', $key));
|
||||
|
||||
++$checked;
|
||||
}
|
||||
|
||||
// Garde : au moins une entite auditable doit avoir ete verifiee, sinon
|
||||
// la detection de l'attribut est cassee (faux positif vert).
|
||||
self::assertGreaterThan(0, $checked, 'Aucune entite #[Auditable] detectee : detection d\'attribut cassee ?');
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge le bloc `audit.entity` du fr.json sous forme de map cle -> libelle.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function loadAuditEntityLabels(): array
|
||||
{
|
||||
$raw = file_get_contents(self::LOCALE_FILE);
|
||||
self::assertIsString($raw, sprintf('Fichier de locale introuvable : %s', self::LOCALE_FILE));
|
||||
|
||||
/** @var array<string, mixed> $json */
|
||||
$json = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
$entity = $json['audit']['entity'] ?? null;
|
||||
self::assertIsArray($entity, 'Bloc `audit.entity` absent ou invalide dans fr.json.');
|
||||
|
||||
$labels = [];
|
||||
foreach ($entity as $key => $value) {
|
||||
if (is_string($key) && is_string($value)) {
|
||||
$labels[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive la cle i18n `<module>_<entity>` depuis le FQCN, en miroir de
|
||||
* AuditListener::formatEntityType (module en minuscules) suivi de
|
||||
* l'aplatissement front (tout en minuscules, `.` -> `_`).
|
||||
*
|
||||
* Retourne null si le FQCN ne respecte pas la structure modulaire.
|
||||
*/
|
||||
private function deriveI18nKey(string $fqcn): ?string
|
||||
{
|
||||
if (1 !== preg_match('#^App\\\Module\\\(?<module>[^\\\]+)\\\.+\\\(?<entity>[^\\\]+)$#', $fqcn, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return strtolower($m['module']).'_'.strtolower($m['entity']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ namespace App\Tests\Module\Catalog\Api;
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\Clock\Test\ClockSensitiveTrait;
|
||||
|
||||
/**
|
||||
* Tests RG-1.15 / RG-1.16 : le TimestampableBlamableSubscriber doit remplir
|
||||
@@ -20,12 +22,39 @@ use DateTimeImmutable;
|
||||
* - DELETE : deletedAt rempli ET updatedAt + updatedBy mis a jour (UPDATE
|
||||
* Doctrine declenche le subscriber)
|
||||
*
|
||||
* ERP-98 : ces tests pilotent une horloge mockee (ClockSensitiveTrait) plutot
|
||||
* que de dependre d'un `sleep(1)` reel. Le subscriber lit le service `clock`,
|
||||
* que `self::mockTime()` remplace par un MockClock fige au niveau du process —
|
||||
* ce qui survit aux reboots de kernel entre requetes (POST admin / PATCH bob)
|
||||
* et reste insensible a la derive d'horloge WSL2 a l'origine des flakes.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
use ClockSensitiveTrait;
|
||||
|
||||
/**
|
||||
* Fige l'horloge globale sur l'instant courant DANS LE FUSEAU PHP par
|
||||
* defaut, et la retourne pour la piloter (`sleep()`).
|
||||
*
|
||||
* Subtilite : `self::mockTime()` cree par defaut un MockClock en UTC, or
|
||||
* les colonnes `TIMESTAMP WITHOUT TIME ZONE` round-trippent via le fuseau
|
||||
* PHP (Europe/Paris). Un MockClock UTC decalerait createdAt de l'offset
|
||||
* (2h) au rechargement. On seede donc avec `new DateTimeImmutable()`
|
||||
* (fuseau par defaut), exactement comme le NativeClock en prod.
|
||||
*/
|
||||
private function freezeClock(): ClockInterface
|
||||
{
|
||||
return self::mockTime(new DateTimeImmutable());
|
||||
}
|
||||
|
||||
public function testCreatedByAdminOnPost(): void
|
||||
{
|
||||
// Horloge figee : le subscriber posera createdAt/updatedAt sur cet
|
||||
// instant exact, insensible a tout decalage d'horloge reel.
|
||||
$clock = $this->freezeClock();
|
||||
|
||||
$type = $this->createCategoryType();
|
||||
|
||||
/** @var User $admin */
|
||||
@@ -33,9 +62,7 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
||||
self::assertNotNull($admin);
|
||||
$adminId = $admin->getId();
|
||||
|
||||
$before = new DateTimeImmutable();
|
||||
// Petit decalage pour absorber les arrondis a la seconde de Postgres.
|
||||
sleep(1);
|
||||
$before = $clock->now();
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('POST', '/api/categories', [
|
||||
@@ -103,6 +130,8 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
||||
|
||||
public function testPatchUpdatesUpdatedFieldsOnly(): void
|
||||
{
|
||||
$clock = $this->freezeClock();
|
||||
|
||||
// Etape 1 : creation par admin pour figer createdBy=admin.
|
||||
$type = $this->createCategoryType();
|
||||
$adminClient = $this->createAdminClient();
|
||||
@@ -127,9 +156,9 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
||||
$initialUpdatedAt = $initial->getUpdatedAt();
|
||||
$initialCreatedById = $initial->getCreatedBy()->getId();
|
||||
|
||||
// Decalage temporel suffisant pour que la precision PG (seconde)
|
||||
// capte un updatedAt different.
|
||||
sleep(1);
|
||||
// Avance deterministe de l'horloge mockee : garantit un updatedAt
|
||||
// strictement superieur cote PG (precision seconde) sans sleep reel.
|
||||
$clock->sleep(1);
|
||||
|
||||
// Etape 2 : PATCH par un autre user (manager non-admin) — simule "bob".
|
||||
$manage = $this->createManageClient();
|
||||
@@ -180,6 +209,8 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
||||
|
||||
public function testSoftDeleteAlsoUpdatesUpdatedFields(): void
|
||||
{
|
||||
$clock = $this->freezeClock();
|
||||
|
||||
// RG-1.16 : le soft delete est un UPDATE Doctrine, donc le subscriber
|
||||
// doit aussi avancer updatedAt et updatedBy en plus de poser deletedAt.
|
||||
$type = $this->createCategoryType();
|
||||
@@ -202,7 +233,8 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
||||
$initial = $em->getRepository(Category::class)->find($createdId);
|
||||
$initialUpdatedAt = $initial->getUpdatedAt();
|
||||
|
||||
sleep(1);
|
||||
// Avance deterministe de l'horloge mockee (cf. testPatch).
|
||||
$clock->sleep(1);
|
||||
|
||||
// Soft delete par un manager non-admin.
|
||||
$manage = $this->createManageClient();
|
||||
|
||||
@@ -14,6 +14,7 @@ use Doctrine\ORM\Event\PrePersistEventArgs;
|
||||
use Doctrine\ORM\Event\PreUpdateEventArgs;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Clock\MockClock;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
@@ -30,7 +31,7 @@ final class TimestampableBlamableSubscriberTest extends TestCase
|
||||
public function testPrePersistWithUser(): void
|
||||
{
|
||||
$user = $this->createStub(UserInterface::class);
|
||||
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user));
|
||||
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user), new MockClock());
|
||||
$entity = new FullAuditableFixture();
|
||||
|
||||
$subscriber->prePersist($this->prePersistArgs($entity));
|
||||
@@ -45,7 +46,7 @@ final class TimestampableBlamableSubscriberTest extends TestCase
|
||||
|
||||
public function testPrePersistWithoutUser(): void
|
||||
{
|
||||
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning(null));
|
||||
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning(null), new MockClock());
|
||||
$entity = new FullAuditableFixture();
|
||||
|
||||
$subscriber->prePersist($this->prePersistArgs($entity));
|
||||
@@ -59,8 +60,13 @@ final class TimestampableBlamableSubscriberTest extends TestCase
|
||||
|
||||
public function testPreUpdate(): void
|
||||
{
|
||||
$user = $this->createStub(UserInterface::class);
|
||||
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user));
|
||||
$user = $this->createStub(UserInterface::class);
|
||||
// Horloge figee 1s apres le createdAt simule : updatedAt doit avancer
|
||||
// de facon deterministe, sans dependre de l'heure reelle.
|
||||
$subscriber = new TimestampableBlamableSubscriber(
|
||||
$this->securityReturning($user),
|
||||
new MockClock(new DateTimeImmutable('2020-01-01 10:00:01')),
|
||||
);
|
||||
|
||||
// On simule une entite deja persistee : createdAt fige dans le passe,
|
||||
// createdBy positionne par une creation anterieure.
|
||||
@@ -80,7 +86,7 @@ final class TimestampableBlamableSubscriberTest extends TestCase
|
||||
public function testPartialEntityTimestampableOnly(): void
|
||||
{
|
||||
$user = $this->createStub(UserInterface::class);
|
||||
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user));
|
||||
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user), new MockClock());
|
||||
$entity = new TimestampableOnlyFixture();
|
||||
|
||||
// Entite Timestampable mais NON Blamable : seules les dates sont posees,
|
||||
|
||||
Reference in New Issue
Block a user