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:
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Sites\Application\Service;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProvider;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\User\InMemoryUser;
|
||||
|
||||
/**
|
||||
* Tests unitaires du CurrentSiteProvider.
|
||||
*
|
||||
* Le provider lit `config/modules.php` au boot via un `require`. Pour les
|
||||
* tests, on force la valeur du flag `sitesActive` via reflection plutot
|
||||
* que de mock le filesystem : le comportement du constructeur
|
||||
* (file_exists + require) est assez trivial pour etre couvert par un
|
||||
* test d'integration si besoin ; ici on se concentre sur la logique de
|
||||
* `get()`.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CurrentSiteProviderTest extends TestCase
|
||||
{
|
||||
public function testReturnsNullIfSitesModuleInactive(): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->setCurrentSite(new Site('Site', 'Rue', null, '12345', 'Ville', '#000000'));
|
||||
|
||||
$security = $this->createStub(Security::class);
|
||||
$security->method('getUser')->willReturn($user);
|
||||
|
||||
$provider = $this->makeProvider($security, sitesActive: false);
|
||||
|
||||
self::assertNull($provider->get());
|
||||
}
|
||||
|
||||
public function testReturnsNullIfNoUser(): void
|
||||
{
|
||||
$security = $this->createStub(Security::class);
|
||||
$security->method('getUser')->willReturn(null);
|
||||
|
||||
$provider = $this->makeProvider($security, sitesActive: true);
|
||||
|
||||
self::assertNull($provider->get());
|
||||
}
|
||||
|
||||
public function testReturnsNullIfUserIsNotAppUser(): void
|
||||
{
|
||||
// Un InMemoryUser Symfony n'est pas une instance de App\User donc
|
||||
// le provider ne peut pas lire son currentSite -> null defensif.
|
||||
$security = $this->createStub(Security::class);
|
||||
$security->method('getUser')->willReturn(new InMemoryUser('foo', 'bar'));
|
||||
|
||||
$provider = $this->makeProvider($security, sitesActive: true);
|
||||
|
||||
self::assertNull($provider->get());
|
||||
}
|
||||
|
||||
public function testReturnsNullIfUserHasNoCurrentSite(): void
|
||||
{
|
||||
$user = new User();
|
||||
// Pas d'appel a setCurrentSite, donc null par defaut.
|
||||
|
||||
$security = $this->createStub(Security::class);
|
||||
$security->method('getUser')->willReturn($user);
|
||||
|
||||
$provider = $this->makeProvider($security, sitesActive: true);
|
||||
|
||||
self::assertNull($provider->get());
|
||||
}
|
||||
|
||||
public function testReturnsSiteWhenAllConditionsMet(): void
|
||||
{
|
||||
$site = new Site('Chatellerault', 'Rue', null, '86100', 'Chatellerault', '#056CF2');
|
||||
$user = new User();
|
||||
$user->setCurrentSite($site);
|
||||
|
||||
$security = $this->createStub(Security::class);
|
||||
$security->method('getUser')->willReturn($user);
|
||||
|
||||
$provider = $this->makeProvider($security, sitesActive: true);
|
||||
|
||||
self::assertSame($site, $provider->get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory helper : construit un provider avec `$sitesActive` force a
|
||||
* la valeur donnee, bypassant la lecture reelle de config/modules.php.
|
||||
*/
|
||||
private function makeProvider(Security $security, bool $sitesActive): CurrentSiteProvider
|
||||
{
|
||||
// Instance via reflection pour eviter l'appel reel au constructeur
|
||||
// qui require config/modules.php (non deterministe en test unit).
|
||||
$reflection = new ReflectionClass(CurrentSiteProvider::class);
|
||||
$provider = $reflection->newInstanceWithoutConstructor();
|
||||
|
||||
$securityProp = $reflection->getProperty('security');
|
||||
$securityProp->setValue($provider, $security);
|
||||
|
||||
$sitesActiveProp = $reflection->getProperty('sitesActive');
|
||||
$sitesActiveProp->setValue($provider, $sitesActive);
|
||||
|
||||
return $provider;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Sites\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Module\Sites\Infrastructure\ApiPlatform\State\Processor\SiteAwareInjectionProcessor;
|
||||
use App\Shared\Domain\Contract\SiteAwareInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use stdClass;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
/**
|
||||
* Tests unitaires du SiteAwareInjectionProcessor.
|
||||
*
|
||||
* Mocks isoles : le processor decore, donc on verifie (a) les mutations
|
||||
* appliquees sur $data avant delegation, (b) que inner->process est
|
||||
* toujours invoque sauf en cas de throw, (c) le throw 400 explicite si
|
||||
* $data SiteAware sans site + provider null.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SiteAwareInjectionProcessorTest extends TestCase
|
||||
{
|
||||
public function testInjectsCurrentSiteOnSiteAwareEntityWithoutSite(): void
|
||||
{
|
||||
$currentSite = new Site('Chatellerault', 'Rue', null, '86100', 'Chatellerault', '#056CF2');
|
||||
$data = $this->makeSiteAwareStub(null);
|
||||
|
||||
$inner = $this->createMock(ProcessorInterface::class);
|
||||
$inner->expects(self::once())
|
||||
->method('process')
|
||||
->willReturnArgument(0)
|
||||
;
|
||||
|
||||
$processor = $this->makeProcessor($inner, $currentSite);
|
||||
$processor->process($data, $this->makeOperation());
|
||||
|
||||
self::assertSame($currentSite, $data->getSite());
|
||||
}
|
||||
|
||||
public function testDoesNotOverrideExistingSite(): void
|
||||
{
|
||||
$existingSite = new Site('Existing', 'Rue', null, '12345', 'Ville', '#000000');
|
||||
$currentSite = new Site('Chatellerault', 'Rue', null, '86100', 'Chatellerault', '#056CF2');
|
||||
$data = $this->makeSiteAwareStub($existingSite);
|
||||
|
||||
$inner = $this->createMock(ProcessorInterface::class);
|
||||
$inner->expects(self::once())->method('process');
|
||||
|
||||
$processor = $this->makeProcessor($inner, $currentSite);
|
||||
$processor->process($data, $this->makeOperation());
|
||||
|
||||
self::assertSame(
|
||||
$existingSite,
|
||||
$data->getSite(),
|
||||
'Un site deja positionne doit etre preserve, pas ecrase par le currentSite.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testSkipsNonSiteAwareData(): void
|
||||
{
|
||||
$nonSiteAware = new stdClass();
|
||||
|
||||
$inner = $this->createMock(ProcessorInterface::class);
|
||||
$inner->expects(self::once())
|
||||
->method('process')
|
||||
->with($nonSiteAware)
|
||||
;
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
$inner,
|
||||
new Site('Any', 'Rue', null, '12345', 'Ville', '#000000'),
|
||||
);
|
||||
$processor->process($nonSiteAware, $this->makeOperation());
|
||||
}
|
||||
|
||||
public function testThrowsBadRequestIfSiteAwareAndNoCurrentSite(): void
|
||||
{
|
||||
$data = $this->makeSiteAwareStub(null);
|
||||
|
||||
$inner = $this->createMock(ProcessorInterface::class);
|
||||
$inner->expects(self::never())
|
||||
->method('process')
|
||||
;
|
||||
|
||||
$processor = $this->makeProcessor($inner, currentSite: null);
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$this->expectExceptionMessage('aucun site selectionne');
|
||||
|
||||
$processor->process($data, $this->makeOperation());
|
||||
}
|
||||
|
||||
public function testDelegatesToInnerProcessorAlwaysWhenNoThrow(): void
|
||||
{
|
||||
$data = new stdClass();
|
||||
$inner = $this->createMock(ProcessorInterface::class);
|
||||
$inner->expects(self::once())
|
||||
->method('process')
|
||||
->willReturn('delegated-result')
|
||||
;
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
$inner,
|
||||
new Site('Any', 'Rue', null, '12345', 'Ville', '#000000'),
|
||||
);
|
||||
$result = $processor->process($data, $this->makeOperation());
|
||||
|
||||
self::assertSame('delegated-result', $result);
|
||||
}
|
||||
|
||||
private function makeProcessor(
|
||||
ProcessorInterface $inner,
|
||||
?Site $currentSite,
|
||||
): SiteAwareInjectionProcessor {
|
||||
// createStub : on n'a besoin que de fixer la valeur de retour, pas
|
||||
// d'attentes sur le nombre d'appels. Evite la notice PHPUnit
|
||||
// "No expectations were configured for the mock object".
|
||||
$provider = $this->createStub(CurrentSiteProviderInterface::class);
|
||||
$provider->method('get')->willReturn($currentSite);
|
||||
|
||||
return new SiteAwareInjectionProcessor($inner, $provider);
|
||||
}
|
||||
|
||||
private function makeSiteAwareStub(?Site $initialSite): SiteAwareInterface
|
||||
{
|
||||
return new class($initialSite) implements SiteAwareInterface {
|
||||
public function __construct(private ?Site $site) {}
|
||||
|
||||
public function getSite(): ?Site
|
||||
{
|
||||
return $this->site;
|
||||
}
|
||||
|
||||
public function setSite(Site $site): void
|
||||
{
|
||||
$this->site = $site;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function makeOperation(): Operation
|
||||
{
|
||||
return new Post();
|
||||
}
|
||||
}
|
||||
53
tests/Module/Sites/SitesModuleTest.php
Normal file
53
tests/Module/Sites/SitesModuleTest.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Sites;
|
||||
|
||||
use App\Module\Sites\Infrastructure\ApiPlatform\State\Processor\SiteAwareInjectionProcessor;
|
||||
use App\Module\Sites\SitesModule;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* Tests structurels du module Sites : contrat `permissions()` et
|
||||
* invariants d'enregistrement des services.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SitesModuleTest extends KernelTestCase
|
||||
{
|
||||
public function testPermissionsSetContainsExactlyThreeCodes(): void
|
||||
{
|
||||
// Garde-fou : si quelqu'un ajoute une permission sans ajuster les
|
||||
// tests ou la doc, ce test casse explicitement. Si au contraire une
|
||||
// permission disparait (ex: bypass_scope retire par erreur), meme
|
||||
// effet. Le set de 3 permissions est fige par ce test.
|
||||
$codes = array_column(SitesModule::permissions(), 'code');
|
||||
sort($codes);
|
||||
|
||||
self::assertSame(
|
||||
['sites.bypass_scope', 'sites.manage', 'sites.view'],
|
||||
$codes,
|
||||
);
|
||||
}
|
||||
|
||||
public function testSiteAwareInjectionProcessorIsRegisteredAsDecoratorOfPersistProcessor(): void
|
||||
{
|
||||
// Garde d'integration : le ticket 4 compte sur le fait que tous
|
||||
// les processors existants qui deleguent au persist processor
|
||||
// (UserRbacProcessor, RoleProcessor, etc.) passent par notre
|
||||
// decorator SiteAwareInjectionProcessor. Si un refactor Symfony
|
||||
// change la resolution du service decore, ce test cassera en
|
||||
// amont des regressions invisibles dans les tests metier.
|
||||
self::bootKernel();
|
||||
$container = self::getContainer();
|
||||
|
||||
$persistProcessor = $container->get('api_platform.doctrine.orm.state.persist_processor');
|
||||
|
||||
self::assertInstanceOf(
|
||||
SiteAwareInjectionProcessor::class,
|
||||
$persistProcessor,
|
||||
'Le service api_platform.doctrine.orm.state.persist_processor doit etre decore par SiteAwareInjectionProcessor (#[AsDecorator]).',
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user