feat(sites) : outillage opt-in site-aware (ticket 4/4)

Livre l'infrastructure permettant aux modules metier de declarer leurs
entites comme "scopees par site" via SiteAwareInterface. Strictement
opt-in : aucune entite metier touchee, aucune migration sur tables
existantes.

Composants :
- SiteAwareInterface (Shared/Domain/Contract) : getSite/setSite
- CurrentSiteProvider + interface (Module/Sites/Application) : resolve
  ?Site selon 3 conditions (module actif, user authentifie, currentSite).
  Interface extraite pour mockabilite en tests (implementation reste final).
- SiteScopedQueryExtension : QueryCollection + QueryItem API Platform,
  ajoute WHERE site = :currentSite si resource SiteAware + provider
  non-null + pas sites.bypass_scope.
- SiteAwareInjectionProcessor : decorator de api_platform.doctrine.orm.
  state.persist_processor (#[AsDecorator]). Injecte currentSite sur
  entites SiteAware sans site ; throw 400 si provider null.
- Permission sites.bypass_scope declaree dans SitesModule::permissions().

Tests :
- FakeSiteAwareEntity dans tests/Fixtures/ + mapping when@test dans
  doctrine.yaml. Table creee a la volee via SchemaTool dans setUp.
  schema:update --force ajoute dans test-db-setup pour que fixtures:load
  ne crashe pas au purger.
- 17 tests dedies au ticket 4 (CurrentSiteProvider unitaire, Injection
  Processor unitaire, Extension integration avec 7 cas couvrant filtrage
  collection + item, bypass, no-op, resource non SiteAware).
- SitesModuleTest : verifie le set de 3 permissions + que le decorator
  est bien enregistre sur le persist processor.

Documentation docs/modules/site-aware.md : guide developpeur 8 sections
(quand/ne pas adopter, comment, migration, mode degrade, anti-patterns,
exemple d'adoption Supplier, cascade delete).

Upgrade @malio/layer-ui 1.4.0 → 1.4.2 (bug 1.4.0 : tailwind.config.ts
oublie dans les files publies npm → classe rounded-malio manquante sur
les DataTables). Simplification tailwind.config.ts Coltura : retrait des
colors/fontFamily/borderRadius dupliques, seule la specifique projet
(primary, secondary, tertiary, m.secondary, m.tertiary) est conservee.

Tests : 201/201 avec et sans SitesModule actif (2 skipped en disabled).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 15:11:07 +02:00
parent 03c761eed4
commit 296befe187
18 changed files with 1263 additions and 27 deletions

View File

@@ -0,0 +1,237 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Sites\Infrastructure\ApiPlatform\Extension;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGenerator;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Sites\Infrastructure\ApiPlatform\Extension\SiteScopedQueryExtension;
use App\Tests\Fixtures\SiteAware\FakeSiteAwareEntity;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaTool;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Bundle\SecurityBundle\Security;
/**
* Tests d'integration de SiteScopedQueryExtension.
*
* Approche : on cree la table `fake_site_aware_entity` a la volee via
* SchemaTool dans le setUp, on y persiste 2 entites sur siteA + 1 sur
* siteB, puis on construit un QueryBuilder via EntityManager et on
* invoque l'extension a la main (pas besoin de monter un endpoint API
* Platform complet — on teste la logique du filtre).
*
* @internal
*/
final class SiteScopedQueryExtensionTest extends KernelTestCase
{
private EntityManagerInterface $em;
private Site $siteA;
private Site $siteB;
protected function setUp(): void
{
self::bootKernel();
$container = self::getContainer();
/** @var EntityManagerInterface $em */
$em = $container->get(EntityManagerInterface::class);
$this->em = $em;
// Creation de la table fake_site_aware_entity uniquement.
// La base de test partage deja les autres tables (site, user, etc.).
$metadata = $this->em->getClassMetadata(FakeSiteAwareEntity::class);
$schema = new SchemaTool($this->em);
// Drop si existe deja (re-run des tests), puis create.
$schema->dropSchema([$metadata]);
$schema->createSchema([$metadata]);
// Fixtures locales : 2 entites sur siteA, 1 sur siteB.
$this->siteA = $this->em->getRepository(Site::class)->findOneBy(['name' => 'Chatellerault']);
$this->siteB = $this->em->getRepository(Site::class)->findOneBy(['name' => 'Saint-Jean']);
self::assertNotNull($this->siteA);
self::assertNotNull($this->siteB);
$e1 = new FakeSiteAwareEntity('A-in-site-A');
$e1->setSite($this->siteA);
$e2 = new FakeSiteAwareEntity('B-in-site-A');
$e2->setSite($this->siteA);
$e3 = new FakeSiteAwareEntity('C-in-site-B');
$e3->setSite($this->siteB);
$this->em->persist($e1);
$this->em->persist($e2);
$this->em->persist($e3);
$this->em->flush();
$this->em->clear();
}
protected function tearDown(): void
{
// Drop de la table fake entre tests pour eviter toute pollution.
if (isset($this->em)) {
$metadata = $this->em->getClassMetadata(FakeSiteAwareEntity::class);
$schema = new SchemaTool($this->em);
$schema->dropSchema([$metadata]);
$this->em->close();
}
parent::tearDown();
}
public function testCollectionFilteredByCurrentSite(): void
{
$extension = $this->makeExtension($this->siteA);
$results = $this->runQuery($extension, FakeSiteAwareEntity::class);
self::assertCount(2, $results, '2 entites sur siteA doivent etre retournees.');
foreach ($results as $entity) {
self::assertSame($this->siteA->getId(), $entity->getSite()->getId());
}
}
public function testCollectionSwitchesToSiteB(): void
{
$extension = $this->makeExtension($this->siteB);
$results = $this->runQuery($extension, FakeSiteAwareEntity::class);
self::assertCount(1, $results);
self::assertSame($this->siteB->getId(), $results[0]->getSite()->getId());
}
public function testNoOpIfNoCurrentSite(): void
{
// Decision assumee (ticket 4 spec Risque 1) : no-op plutot que
// collection vide. L'user sans currentSite voit TOUTES les entites.
$extension = $this->makeExtension(currentSite: null);
$results = $this->runQuery($extension, FakeSiteAwareEntity::class);
self::assertCount(3, $results);
}
public function testNoOpIfBypassScopePermission(): void
{
// User avec sites.bypass_scope voit TOUTES les entites, meme
// avec un currentSite positionne. Comportement admin / audit.
$extension = $this->makeExtension($this->siteA, bypassScope: true);
$results = $this->runQuery($extension, FakeSiteAwareEntity::class);
self::assertCount(3, $results);
}
public function testNoOpIfResourceClassNotSiteAware(): void
{
// Une resource qui n'implemente pas SiteAwareInterface ne doit
// jamais etre filtree (l'extension se contente d'un `return` tot).
$extension = $this->makeExtension($this->siteA);
// On query les users (non SiteAware). Verification robuste : on
// inspecte la partie WHERE du QueryBuilder avant et apres l'appel
// a l'extension. Le before/after doit etre identique (idealement
// null dans les deux cas vu qu'on n'a pas ajoute de WHERE).
$qb = $this->em->createQueryBuilder()->select('u')->from(User::class, 'u');
$nameGen = new QueryNameGenerator();
$whereBefore = $qb->getDQLPart('where');
$extension->applyToCollection($qb, $nameGen, User::class);
$whereAfter = $qb->getDQLPart('where');
self::assertEquals(
$whereBefore,
$whereAfter,
'La clause WHERE du QueryBuilder doit etre intacte pour une resource non SiteAware.',
);
}
public function testItemNotFoundIfWrongSite(): void
{
// GET /api/entity/{id} pour un item du siteB alors que l'user est
// sur siteA -> le filtre ajoute `WHERE site = siteA`, la requete
// retourne null -> API Platform renverra 404.
$em = $this->em;
$entityB = $em->getRepository(FakeSiteAwareEntity::class)
->findOneBy(['name' => 'C-in-site-B'])
;
self::assertNotNull($entityB);
$idB = $entityB->getId();
$em->clear();
$extension = $this->makeExtension($this->siteA);
$qb = $this->em->createQueryBuilder()
->select('e')
->from(FakeSiteAwareEntity::class, 'e')
->andWhere('e.id = :id')
->setParameter('id', $idB)
;
$nameGen = new QueryNameGenerator();
$extension->applyToItem($qb, $nameGen, FakeSiteAwareEntity::class, ['id' => $idB]);
self::assertNull($qb->getQuery()->getOneOrNullResult());
}
public function testItemFoundIfCorrectSite(): void
{
$entityA = $this->em->getRepository(FakeSiteAwareEntity::class)
->findOneBy(['name' => 'A-in-site-A'])
;
self::assertNotNull($entityA);
$idA = $entityA->getId();
$this->em->clear();
$extension = $this->makeExtension($this->siteA);
$qb = $this->em->createQueryBuilder()
->select('e')
->from(FakeSiteAwareEntity::class, 'e')
->andWhere('e.id = :id')
->setParameter('id', $idA)
;
$nameGen = new QueryNameGenerator();
$extension->applyToItem($qb, $nameGen, FakeSiteAwareEntity::class, ['id' => $idA]);
$result = $qb->getQuery()->getOneOrNullResult();
self::assertNotNull($result);
self::assertSame('A-in-site-A', $result->getName());
}
/**
* Construit une extension avec un provider et un security mockes selon
* le scenario testé. Passe par reflection pour forcer le flag
* sitesActive du provider sans toucher au filesystem.
*/
private function makeExtension(?Site $currentSite, bool $bypassScope = false): SiteScopedQueryExtension
{
// createStub : pas d'attentes sur le nombre d'appels, juste fixer
// les valeurs de retour des methodes sollicitees. Evite les notices
// PHPUnit "No expectations configured".
$security = $this->createStub(Security::class);
$security->method('isGranted')->willReturnCallback(
fn (string $perm): bool => 'sites.bypass_scope' === $perm && $bypassScope,
);
$provider = $this->createStub(CurrentSiteProviderInterface::class);
$provider->method('get')->willReturn($currentSite);
return new SiteScopedQueryExtension($provider, $security);
}
private function runQuery(SiteScopedQueryExtension $extension, string $resourceClass): array
{
$qb = $this->em->createQueryBuilder()->select('e')->from($resourceClass, 'e');
$nameGen = new QueryNameGenerator();
$extension->applyToCollection($qb, $nameGen, $resourceClass);
return $qb->getQuery()->getResult();
}
}