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,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();
}
}