Compare commits

..

4 Commits

Author SHA1 Message Date
gitea-actions 276f242b10 chore: bump version to v0.1.70
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 18s
2026-06-03 09:47:31 +00:00
matthieu 97301dcd6c refactor(commercial) : découpler l'hydratation des collections de la sélection clients (ERP-100) (#50)
Auto Tag Develop / tag (push) Successful in 7s
## Contexte
Issu de la review ERP-62 (#44). `DoctrineClientRepository::createListQueryBuilder()` portait 3 `leftJoin+addSelect` to-many imbriqués (`categories × addresses × addresses.sites`) **partagés** entre :
- la **liste paginée** (`ClientProvider`) — bornée, OK ;
- l'**export XLSX** et **`?pagination=false`** — `getResult()` sans pagination → hydratation du **produit cartésien sur tout le référentiel** (1 client à 5 cat × 4 adr × 3 sites = 60 lignes SQL, × N clients).

Défaut d'altitude : un « QueryBuilder de liste » (contrat = filtres) imposait une stratégie d'hydratation à tout appelant.

## Changements
- **`createListQueryBuilder()`** redevient **filtres + tri seuls** — conforme au contrat de l'interface.
- Nouvelle méthode **`hydrateListCollections(array $clients)`** : recharge les collections en **2 requêtes `WHERE id IN(...)` séparées** (catégories d'un côté, adresses+sites de l'autre) via l'identity map Doctrine. Casse le triple cartésien en `cat + (addr × site)`.
- **3 appelants** branchés sur cette stratégie unique :
  - liste paginée : `fetchJoinCollection: false` (COUNT simple) + hydratation de la page ;
  - `?pagination=false` : hydratation après `getResult()` ;
  - export XLSX : hydratation après `getResult()`.

## Tests
- `make test` : **465 OK**.
- Nouveau test `ClientExportControllerTest::testExportPopulatesCategoryAndSiteColumns` : garde-fou sur les valeurs Catégories/Sites de l'export (qu'un oubli d'hydratation rendrait silencieusement vides).
- `php-cs-fixer` : 0 correction.

## Notes
- Benchmark « 1000+ clients » non exécuté (pas de jeu de données à cette échelle en dev) ; le cartésien est supprimé structurellement.
- `addr × site` reste un join imbriqué (inévitable pour agréger les sites par adresse), désormais non multiplié par les catégories.

Closes ERP-100.

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #50
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-03 09:44:31 +00:00
gitea-actions daeb8b3003 chore: bump version to v0.1.69
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 19s
2026-06-03 09:14:20 +00:00
matthieu 9c311cb58b fix(tests) : fiabilise la suite PHPUnit contre la derive d'horloge (ERP-98) (#47)
Auto Tag Develop / tag (push) Successful in 11s
## Probleme (ERP-98)

Suite PHPUnit flaky ~1 run sur 2 -> hook pre-commit qui plante, recours au `--no-verify` sur des commits sains.

## Cause racine

Une seule cause commune : l'horloge `CLOCK_REALTIME` du conteneur n'est pas monotone sous WSL2/Docker (saut arriere sous charge), alors que le code et les tests supposaient une horloge stable.

- **401 « Invalid JWT Token »** : lexik validait `iat`/`nbf`/`exp` avec `clock_skew: 0` (`LooseValidAt(.., PT0S)` cote lcobucci). Un recul d'horloge apres `/login_check` rend le token « dans le futur » -> rejet.
- **Horodatages « meme seconde »** (`1780402904 > 1780402904`) : colonnes `TIMESTAMP(0)` + `sleep(1)` reel. L'ecart floor-seconde n'est nul que si l'horloge recule.

## Correctifs

| Fichier | Modif |
|---|---|
| `config/packages/lexik_jwt_authentication.yaml` | `clock_skew: 15` -> tolere la derive (benefice prod aussi) |
| `TimestampableBlamableSubscriber` | injection `ClockInterface` (prod inchange via NativeClock) |
| `CategoryTimestampableBlamableTest` | `ClockSensitiveTrait` + MockClock fige/avance, suppression des `sleep(1)` |
| `TimestampableBlamableSubscriberTest` | MockClock injecte dans les 4 instanciations |

**Subtilite** : `mockTime()` cree un MockClock en UTC ; les colonnes `TIMESTAMP WITHOUT TIME ZONE` round-trippent via le fuseau PHP (Europe/Paris) -> decalage 2h. Le mock est seede dans le fuseau par defaut (comme le NativeClock prod).

## Verifications

- `make test` : **464 tests verts**, 0 echec / 0 erreur
- Test timestamp cible : **5/5 deterministe** (et plus rapide, sleeps reels supprimes)
- `make php-cs-fixer-allow-risky` : 0 fichier a corriger
- Deprecations/notices PHPUnit preexistantes (hors perimetre)

Pas de migration, pas de changement front, RBAC intact.

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #47
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-03 09:14:09 +00:00
10 changed files with 190 additions and 31 deletions
@@ -3,6 +3,14 @@ lexik_jwt_authentication:
public_key: '%env(resolve:JWT_PUBLIC_KEY)%' public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%' pass_phrase: '%env(JWT_PASSPHRASE)%'
token_ttl: '%env(int:JWT_TOKEN_TTL)%' token_ttl: '%env(int:JWT_TOKEN_TTL)%'
# Tolerance d'horloge (en secondes) appliquee a la validation des claims
# temporels iat / nbf / exp (LooseValidAt cote lcobucci). Sans cette marge
# (defaut 0), un recul d'horloge entre la signature (/login_check) et la
# requete suivante rend iat/nbf « dans le futur » -> « Invalid JWT Token »
# (401). Observe en dev sous WSL2/Docker (horloge CLOCK_REALTIME non
# monotone) : flakes intermittents de la suite PHPUnit (ERP-98). Benefice
# aussi en prod si les noeuds derivent legerement entre eux.
clock_skew: 15
remove_token_from_body_when_cookies_used: true remove_token_from_body_when_cookies_used: true
token_extractors: token_extractors:
authorization_header: authorization_header:
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.68' app.version: '0.1.70'
@@ -33,6 +33,12 @@ interface ClientRepositoryInterface
* la liste paginee (ClientProvider) et l'export (ClientExportController) * la liste paginee (ClientProvider) et l'export (ClientExportController)
* partagent strictement la meme logique de selection. * partagent strictement la meme logique de selection.
* *
* Contrat = SELECTION uniquement (filtres + tri). Aucun fetch-join to-many :
* l'hydratation des collections affichees est une decision de l'appelant
* (cf. {@see self::hydrateListCollections()}), pour ne pas imposer le cout
* d'un produit cartesien a un consommateur qui ne filtrerait/compterait que
* (ERP-100).
*
* @param list<string> $categoryCodes * @param list<string> $categoryCodes
* @param list<int> $siteIds * @param list<int> $siteIds
*/ */
@@ -43,4 +49,19 @@ interface ClientRepositoryInterface
array $siteIds = [], array $siteIds = [],
bool $archivedOnly = false, bool $archivedOnly = false,
): QueryBuilder; ): QueryBuilder;
/**
* Hydrate en lot les collections affichees par le repertoire (categories,
* adresses et leurs sites) sur un jeu de clients DEJA charges, via l'identity
* map Doctrine (memes instances). A appeler apres une selection bornee (page
* courante ou jeu d'export) pour eviter le N+1 a la serialisation, sans
* imposer de fetch-join au QueryBuilder de selection (ERP-100).
*
* Charge les categories et les adresses/sites en DEUX requetes distinctes
* (et non un triple fetch-join) pour ne pas multiplier categories x adresses
* x sites en un seul produit cartesien.
*
* @param list<Client> $clients
*/
public function hydrateListCollections(array $clients): void;
} }
@@ -83,8 +83,13 @@ final class ClientProvider implements ProviderInterface
// Echappatoire ?pagination=false : collection complete sans Paginator // Echappatoire ?pagination=false : collection complete sans Paginator
// (cf. convention ERP-72 — utile pour un <select> cote front). // (cf. convention ERP-72 — utile pour un <select> cote front).
if (!$this->pagination->isEnabled($operation, $context)) { if (!$this->pagination->isEnabled($operation, $context)) {
// @var list<Client> $result /** @var list<Client> $clients */
return $qb->getQuery()->getResult(); $clients = $qb->getQuery()->getResult();
// Hydratation batchee des collections affichees (cf. ERP-100) : evite
// le N+1 si la serialisation touche categories/sites, sans cartesien.
$this->repository->hydrateListCollections($clients);
return $clients;
} }
$limit = $this->pagination->getLimit($operation, $context); $limit = $this->pagination->getLimit($operation, $context);
@@ -93,9 +98,13 @@ final class ClientProvider implements ProviderInterface
$qb->setFirstResult($offset)->setMaxResults($limit); $qb->setFirstResult($offset)->setMaxResults($limit);
// fetchJoinCollection: true pour un COUNT correct des que des JOINs // Le QB de selection ne porte plus de fetch-join to-many (ERP-100) : le
// to-many seront ajoutes (sous-collections embarquees en detail). // COUNT est simple, fetchJoinCollection inutile. On materialise la page
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true)); // puis on hydrate ses collections en lot (memes entites managees).
$paginator = new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
$this->repository->hydrateListCollections(iterator_to_array($paginator));
return $paginator;
} }
/** /**
@@ -69,6 +69,11 @@ final class ClientExportController
->getResult() ->getResult()
; ;
// Hydratation batchee des categories + adresses/sites (ERP-100) : le QB de
// selection ne fetch-join plus, on remplit les collections en 2 requetes
// IN bornees plutot que d'hydrater un produit cartesien sur tout le jeu.
$this->repository->hydrateListCollections($clients);
$withSiren = $this->security->isGranted('commercial.clients.accounting.view'); $withSiren = $this->security->isGranted('commercial.clients.accounting.view');
$binary = $this->exporter->export( $binary = $this->exporter->export(
@@ -38,16 +38,12 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
array $siteIds = [], array $siteIds = [],
bool $archivedOnly = false, bool $archivedOnly = false,
): QueryBuilder { ): QueryBuilder {
// SELECTION uniquement (filtres + tri) : pas de fetch-join to-many ici.
// L'hydratation des collections affichees (Catégories / Site(s)) est
// deleguee a hydrateListCollections() une fois le jeu borne, pour ne pas
// imposer un produit cartesien aux chemins non pagines (export,
// ?pagination=false) — ERP-100.
$qb = $this->createQueryBuilder('c') $qb = $this->createQueryBuilder('c')
// Jointures + addSelect pour hydrater en une seule requete les
// collections affichees par le Repertoire (colonnes Catégories /
// Site(s)) : sans cela, la serialisation declenche un N+1 (une
// requete par client, puis par adresse). Le Paginator ORM
// (fetchJoinCollection: true, cf. ClientProvider) gere le COUNT
// malgre ces jointures to-many.
->leftJoin('c.categories', 'cat')->addSelect('cat')
->leftJoin('c.addresses', 'addr')->addSelect('addr')
->leftJoin('addr.sites', 'site')->addSelect('site')
->andWhere('c.deletedAt IS NULL') ->andWhere('c.deletedAt IS NULL')
->orderBy('c.companyName', 'ASC') ->orderBy('c.companyName', 'ASC')
; ;
@@ -66,6 +62,46 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
return $qb; return $qb;
} }
public function hydrateListCollections(array $clients): void
{
if ([] === $clients) {
return;
}
// Ids des clients deja charges (entites managees). On rehydrate leurs
// collections via l'identity map : les requetes ci-dessous renvoient les
// MEMES instances Client, dont les collections sont alors remplies.
$ids = [];
foreach ($clients as $client) {
$id = $client->getId();
if (null !== $id) {
$ids[] = $id;
}
}
if ([] === $ids) {
return;
}
// 1re passe : categories (colonne « Catégories »). Produit c x cat seul.
$this->createQueryBuilder('c')
->leftJoin('c.categories', 'cat')->addSelect('cat')
->where('c.id IN (:ids)')->setParameter('ids', $ids)
->getQuery()
->getResult()
;
// 2e passe : adresses + sites (colonne « Site(s) », sites portes par les
// adresses — RG-1.10). Le join addr -> site reste imbrique mais n'est
// plus multiplie par les categories : le cartesien global est casse.
$this->createQueryBuilder('c')
->leftJoin('c.addresses', 'addr')->addSelect('addr')
->leftJoin('addr.sites', 'site')->addSelect('site')
->where('c.id IN (:ids)')->setParameter('ids', $ids)
->getQuery()
->getResult()
;
}
/** /**
* Recherche fuzzy insensible a la casse sur companyName + lastName + email. * Recherche fuzzy insensible a la casse sur companyName + lastName + email.
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester * Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
@@ -6,12 +6,12 @@ namespace App\Shared\Infrastructure\Doctrine;
use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PrePersistEventArgs; use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs; use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Events; use Doctrine\ORM\Events;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
/** /**
@@ -30,12 +30,19 @@ use Symfony\Component\Security\Core\User\UserInterface;
#[AsDoctrineListener(event: Events::preUpdate)] #[AsDoctrineListener(event: Events::preUpdate)]
final class TimestampableBlamableSubscriber final class TimestampableBlamableSubscriber
{ {
public function __construct(private readonly Security $security) {} // L'horloge est injectee (et non un `new DateTimeImmutable()` direct) pour
// que les tests puissent figer/avancer le temps de facon deterministe via
// ClockSensitiveTrait (cf. ERP-98). En prod, le service `clock` delegue a
// l'horloge systeme reelle.
public function __construct(
private readonly Security $security,
private readonly ClockInterface $clock,
) {}
public function prePersist(PrePersistEventArgs $args): void public function prePersist(PrePersistEventArgs $args): void
{ {
$entity = $args->getObject(); $entity = $args->getObject();
$now = new DateTimeImmutable(); $now = $this->clock->now();
$user = $this->security->getUser(); $user = $this->security->getUser();
if ($entity instanceof TimestampableInterface) { if ($entity instanceof TimestampableInterface) {
@@ -55,7 +62,7 @@ final class TimestampableBlamableSubscriber
$user = $this->security->getUser(); $user = $this->security->getUser();
if ($entity instanceof TimestampableInterface) { if ($entity instanceof TimestampableInterface) {
$entity->setUpdatedAt(new DateTimeImmutable()); $entity->setUpdatedAt($this->clock->now());
} }
if ($entity instanceof BlamableInterface && $user instanceof UserInterface) { if ($entity instanceof BlamableInterface && $user instanceof UserInterface) {
@@ -7,6 +7,8 @@ namespace App\Tests\Module\Catalog\Api;
use App\Module\Catalog\Domain\Entity\Category; use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Core\Domain\Entity\User; use App\Module\Core\Domain\Entity\User;
use DateTimeImmutable; use DateTimeImmutable;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Clock\Test\ClockSensitiveTrait;
/** /**
* Tests RG-1.15 / RG-1.16 : le TimestampableBlamableSubscriber doit remplir * 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 * - DELETE : deletedAt rempli ET updatedAt + updatedBy mis a jour (UPDATE
* Doctrine declenche le subscriber) * 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 * @internal
*/ */
final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase 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 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(); $type = $this->createCategoryType();
/** @var User $admin */ /** @var User $admin */
@@ -33,9 +62,7 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
self::assertNotNull($admin); self::assertNotNull($admin);
$adminId = $admin->getId(); $adminId = $admin->getId();
$before = new DateTimeImmutable(); $before = $clock->now();
// Petit decalage pour absorber les arrondis a la seconde de Postgres.
sleep(1);
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$response = $client->request('POST', '/api/categories', [ $response = $client->request('POST', '/api/categories', [
@@ -103,6 +130,8 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
public function testPatchUpdatesUpdatedFieldsOnly(): void public function testPatchUpdatesUpdatedFieldsOnly(): void
{ {
$clock = $this->freezeClock();
// Etape 1 : creation par admin pour figer createdBy=admin. // Etape 1 : creation par admin pour figer createdBy=admin.
$type = $this->createCategoryType(); $type = $this->createCategoryType();
$adminClient = $this->createAdminClient(); $adminClient = $this->createAdminClient();
@@ -127,9 +156,9 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
$initialUpdatedAt = $initial->getUpdatedAt(); $initialUpdatedAt = $initial->getUpdatedAt();
$initialCreatedById = $initial->getCreatedBy()->getId(); $initialCreatedById = $initial->getCreatedBy()->getId();
// Decalage temporel suffisant pour que la precision PG (seconde) // Avance deterministe de l'horloge mockee : garantit un updatedAt
// capte un updatedAt different. // strictement superieur cote PG (precision seconde) sans sleep reel.
sleep(1); $clock->sleep(1);
// Etape 2 : PATCH par un autre user (manager non-admin) — simule "bob". // Etape 2 : PATCH par un autre user (manager non-admin) — simule "bob".
$manage = $this->createManageClient(); $manage = $this->createManageClient();
@@ -180,6 +209,8 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
public function testSoftDeleteAlsoUpdatesUpdatedFields(): void public function testSoftDeleteAlsoUpdatesUpdatedFields(): void
{ {
$clock = $this->freezeClock();
// RG-1.16 : le soft delete est un UPDATE Doctrine, donc le subscriber // RG-1.16 : le soft delete est un UPDATE Doctrine, donc le subscriber
// doit aussi avancer updatedAt et updatedBy en plus de poser deletedAt. // doit aussi avancer updatedAt et updatedBy en plus de poser deletedAt.
$type = $this->createCategoryType(); $type = $this->createCategoryType();
@@ -202,7 +233,8 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
$initial = $em->getRepository(Category::class)->find($createdId); $initial = $em->getRepository(Category::class)->find($createdId);
$initialUpdatedAt = $initial->getUpdatedAt(); $initialUpdatedAt = $initial->getUpdatedAt();
sleep(1); // Avance deterministe de l'horloge mockee (cf. testPatch).
$clock->sleep(1);
// Soft delete par un manager non-admin. // Soft delete par un manager non-admin.
$manage = $this->createManageClient(); $manage = $this->createManageClient();
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api; namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\ClientAddress;
use App\Module\Sites\Domain\Entity\Site;
use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\IOFactory;
/** /**
@@ -88,6 +90,39 @@ final class ClientExportControllerTest extends AbstractCommercialApiTestCase
self::assertNotContains('SECTEUR CO', $names); self::assertNotContains('SECTEUR CO', $names);
} }
/**
* ERP-100 : depuis le decouplage hydratation/selection, le QueryBuilder de
* liste ne fetch-join plus les collections — l'export les recharge en lot via
* hydrateListCollections(). Ce test garde que les colonnes « Catégories » et
* « Site(s) » restent peuplees (un oubli d'hydratation les rendrait vides
* sans erreur).
*/
public function testExportPopulatesCategoryAndSiteColumns(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Hydrate Co', false, 'DISTRIBUTEUR');
$em = $this->getEm();
$site = $em->getRepository(Site::class)->findOneBy([]);
self::assertNotNull($site, 'Aucun site seede : impossible de tester la colonne Site(s).');
$address = new ClientAddress();
$address->setClient($seed);
$address->setPostalCode('86100');
$address->setCity('Châtellerault');
$address->setStreet('1 rue du Test');
$address->addSite($site);
$em->persist($address);
$em->flush();
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
// Colonne « Catégories » : libelle de la categorie du client (getName()).
self::assertStringContainsString('test_cli_cat_distributeur', $flat);
// Colonne « Site(s) » : site agrege depuis l'adresse (RG-1.10).
self::assertStringContainsString((string) $site->getName(), $flat);
}
public function testSirenColumnPresentWithAccountingView(): void public function testSirenColumnPresentWithAccountingView(): void
{ {
// L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN. // L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN.
@@ -14,6 +14,7 @@ use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs; use Doctrine\ORM\Event\PreUpdateEventArgs;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
/** /**
@@ -30,7 +31,7 @@ final class TimestampableBlamableSubscriberTest extends TestCase
public function testPrePersistWithUser(): void public function testPrePersistWithUser(): void
{ {
$user = $this->createStub(UserInterface::class); $user = $this->createStub(UserInterface::class);
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user)); $subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user), new MockClock());
$entity = new FullAuditableFixture(); $entity = new FullAuditableFixture();
$subscriber->prePersist($this->prePersistArgs($entity)); $subscriber->prePersist($this->prePersistArgs($entity));
@@ -45,7 +46,7 @@ final class TimestampableBlamableSubscriberTest extends TestCase
public function testPrePersistWithoutUser(): void public function testPrePersistWithoutUser(): void
{ {
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning(null)); $subscriber = new TimestampableBlamableSubscriber($this->securityReturning(null), new MockClock());
$entity = new FullAuditableFixture(); $entity = new FullAuditableFixture();
$subscriber->prePersist($this->prePersistArgs($entity)); $subscriber->prePersist($this->prePersistArgs($entity));
@@ -60,7 +61,12 @@ final class TimestampableBlamableSubscriberTest extends TestCase
public function testPreUpdate(): void public function testPreUpdate(): void
{ {
$user = $this->createStub(UserInterface::class); $user = $this->createStub(UserInterface::class);
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user)); // 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, // On simule une entite deja persistee : createdAt fige dans le passe,
// createdBy positionne par une creation anterieure. // createdBy positionne par une creation anterieure.
@@ -80,7 +86,7 @@ final class TimestampableBlamableSubscriberTest extends TestCase
public function testPartialEntityTimestampableOnly(): void public function testPartialEntityTimestampableOnly(): void
{ {
$user = $this->createStub(UserInterface::class); $user = $this->createStub(UserInterface::class);
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user)); $subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user), new MockClock());
$entity = new TimestampableOnlyFixture(); $entity = new TimestampableOnlyFixture();
// Entite Timestampable mais NON Blamable : seules les dates sont posees, // Entite Timestampable mais NON Blamable : seules les dates sont posees,