Module sites (#8)
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s

| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #8
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #8.
This commit is contained in:
2026-04-20 15:31:58 +00:00
committed by Autin
parent 6b4868b261
commit 6cf5ef4cfc
77 changed files with 7739 additions and 80 deletions

View File

@@ -9,6 +9,7 @@ use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
@@ -123,6 +124,19 @@ abstract class AbstractApiTestCase extends ApiTestCase
$user->setIsAdmin(false);
$user->setPassword($hasher->hashPassword($user, $password));
$user->addRbacRole($role);
// Le helper attache le user jetable a tous les sites existants pour
// neutraliser le filtrage par UserSiteScopedExtension : la plupart
// des tests assume une visibilite globale sur les users cibles. Les
// tests qui valident le comportement "sans sites" doivent creer leur
// user a la main (pas via ce helper).
$siteRepository = $em->getRepository(Site::class);
if (null !== $siteRepository) {
foreach ($siteRepository->findAll() as $site) {
$user->addSite($site);
}
}
$em->persist($user);
$em->flush();
@@ -130,4 +144,34 @@ abstract class AbstractApiTestCase extends ApiTestCase
return ['username' => $username, 'password' => $password];
}
/**
* Skip le test courant si le module Sites est desactive dans
* `config/modules.php` de l'environnement de test.
*
* Mecanisme : on cherche la permission `sites.view` en base. Si le
* module Sites est desactive, `app:sync-permissions` aura marque cette
* permission comme orpheline et l'aura supprimee de la table — donc
* `findOneBy(['code' => 'sites.view'])` renvoie null.
*
* Quand utiliser ce helper : tests qui s'appuient sur
* `createUserWithPermission('sites.*')`. Les tests qui utilisent
* uniquement l'admin (qui bypass via isAdmin) n'en ont pas besoin :
* la classe Site reste mappee Doctrine et exposee via API Platform
* meme module desactive (mapping inconditionnel, decision assumee
* ticket 1).
*/
protected function skipIfSitesModuleDisabled(): void
{
if (!self::$kernel) {
self::bootKernel();
}
$perm = $this->getEm()
->getRepository(Permission::class)
->findOneBy(['code' => 'sites.view'])
;
if (null === $perm) {
self::markTestSkipped('Module Sites desactive : permission sites.view introuvable en base.');
}
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Tests\Module\Core\Api;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
@@ -41,11 +42,18 @@ final class UserRbacApiTest extends AbstractApiTestCase
/** @var UserPasswordHasherInterface $hasher */
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
// User cible standard (non admin).
// User cible standard (non admin). On lui attache tous les sites
// fixtures pour rester visible depuis les callers non-admin munis de
// sites (cf. UserSiteScopedExtension qui filtre `/api/users` par
// intersection de sites). Sans cela, un user `core.users.manage`
// sans site commun avec test_target recevrait un 404 sur le PATCH.
$target = new User();
$target->setUsername('test_target');
$target->setIsAdmin(false);
$target->setPassword($hasher->hashPassword($target, 'secret'));
foreach ($em->getRepository(Site::class)->findAll() as $site) {
$target->addSite($site);
}
$em->persist($target);
// User admin dedie pour le cas d'auto-suicide (pas l'admin fixture).

View File

@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Core\Api;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
/**
* Tests d'extension de l'endpoint PATCH /api/users/{id}/rbac pour assigner
* des sites a un user, avec les deux gardes post-persist :
* - si currentSite n'est plus dans sites → null ;
* - si currentSite null ET sites non vide → auto-select premier site.
*
* @internal
*/
final class UserRbacSitesApiTest extends AbstractApiTestCase
{
public function testAdminCanAssignSitesToUser(): void
{
$em = $this->getEm();
$saintJean = $em->getRepository(Site::class)->findOneBy(['name' => 'Saint-Jean']);
self::assertNotNull($saintJean);
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$aliceId = $alice->getId();
$em->clear();
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => [
'sites' => ['/api/sites/'.$saintJean->getId()],
],
]);
self::assertResponseIsSuccessful();
// Verification cote base.
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(User::class)->find($aliceId);
self::assertNotNull($reloaded);
self::assertCount(1, $reloaded->getSites());
self::assertSame('Saint-Jean', $reloaded->getSites()->first()->getName());
// Restauration pour ne pas polluer les autres tests.
$this->restoreAliceSites();
}
public function testRemovingCurrentSiteResetsCurrentSiteToNullThenAutoSelectsFirst(): void
{
// alice a actuellement {Chatellerault}, currentSite=Chatellerault.
// On lui attribue {Saint-Jean} : Chatellerault disparait → currentSite
// devrait temporairement etre null, PUIS auto-select Saint-Jean (seul
// site restant).
$em = $this->getEm();
$saintJean = $em->getRepository(Site::class)->findOneBy(['name' => 'Saint-Jean']);
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$aliceId = $alice->getId();
$em->clear();
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => [
'sites' => ['/api/sites/'.$saintJean->getId()],
],
]);
self::assertResponseIsSuccessful();
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(User::class)->find($aliceId);
self::assertNotNull($reloaded->getCurrentSite());
self::assertSame('Saint-Jean', $reloaded->getCurrentSite()->getName());
$this->restoreAliceSites();
}
public function testEmptySitesPayloadResetsCurrentSiteToNull(): void
{
$em = $this->getEm();
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$aliceId = $alice->getId();
$em->clear();
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => [
'sites' => [],
],
]);
self::assertResponseIsSuccessful();
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(User::class)->find($aliceId);
self::assertCount(0, $reloaded->getSites());
self::assertNull($reloaded->getCurrentSite());
$this->restoreAliceSites();
}
public function testCurrentSiteFieldInRbacPayloadIsSilentlyIgnored(): void
{
// Garde structurelle : `currentSite` n'est pas dans le groupe
// user:rbac:write. Un client malveillant qui essaierait de set un
// currentSite arbitraire via /rbac doit etre silencieusement
// ignore (le seul flux autorise est PATCH /me/current-site).
$em = $this->getEm();
$pommevic = $em->getRepository(Site::class)->findOneBy(['name' => 'Pommevic']);
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$aliceId = $alice->getId();
$em->clear();
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => [
'currentSite' => '/api/sites/'.$pommevic->getId(),
],
]);
self::assertResponseIsSuccessful();
// alice n'a Pommevic ni dans ses sites ni en currentSite (le champ
// a ete ignore par le denormalizer). Son currentSite reste son
// Chatellerault d'origine.
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(User::class)->find($aliceId);
self::assertNotNull($reloaded);
self::assertNotNull($reloaded->getCurrentSite());
self::assertSame('Chatellerault', $reloaded->getCurrentSite()->getName());
}
public function testRbacPatchWithoutSitesFieldDoesNotChangeCurrentSite(): void
{
// Garde structurelle : si le payload /rbac ne contient pas le champ
// `sites`, ensureCurrentSiteConsistency ne doit pas auto-modifier
// le currentSite (alice avait deja Chatellerault). Un PATCH qui
// change uniquement isAdmin ou roles ne doit pas remuer la
// configuration site de l'user.
$em = $this->getEm();
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$aliceId = $alice->getId();
$em->clear();
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => [
'isAdmin' => false,
],
]);
self::assertResponseIsSuccessful();
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(User::class)->find($aliceId);
self::assertNotNull($reloaded->getCurrentSite());
self::assertSame('Chatellerault', $reloaded->getCurrentSite()->getName());
}
/**
* Remet alice dans l'etat des fixtures : un seul site Chatellerault,
* currentSite Chatellerault. Evite la pollution inter-tests.
*/
private function restoreAliceSites(): void
{
$em = $this->getEm();
$chatellerault = $em->getRepository(Site::class)->findOneBy(['name' => 'Chatellerault']);
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
// Reset complet des sites
foreach ($alice->getSites() as $existing) {
$alice->removeSite($existing);
}
$alice->addSite($chatellerault);
$alice->setCurrentSite($chatellerault);
$em->flush();
}
}

View File

@@ -50,6 +50,14 @@ final class UserRbacProcessorTest extends TestCase
$this->entityManager->method('getUnitOfWork')->willReturn($this->unitOfWork);
// wrapInTransaction doit executer reellement la closure pour que le
// resultat de persistProcessor->process() soit capture dans $result.
// Sans ce stub, la closure n'est jamais invoquee et $result reste null.
$this->entityManager
->method('wrapInTransaction')
->willReturnCallback(static fn (callable $fn) => $fn())
;
$this->processor = new UserRbacProcessor(
$this->persistProcessor,
$this->entityManager,

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Sites\Api;
use App\Module\Sites\Domain\Entity\Site;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
/**
* Tests fonctionnels de l'endpoint PATCH /api/me/current-site (switch).
*
* Fixtures utilisees :
* - alice : rattachee a Chatellerault uniquement (currentSite = Chatellerault).
* - admin : rattache aux 3 sites.
* - bob : rattache a Saint-Jean uniquement.
*
* @internal
*/
final class CurrentSiteSwitchApiTest extends AbstractApiTestCase
{
public function testUserCanSwitchToAuthorizedSite(): void
{
// admin a les 3 sites. On le bascule de Chatellerault vers Pommevic.
$em = $this->getEm();
$pommevic = $em->getRepository(Site::class)->findOneBy(['name' => 'Pommevic']);
self::assertNotNull($pommevic);
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('PATCH', '/api/me/current-site', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['site' => '/api/sites/'.$pommevic->getId()],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertSame('Pommevic', $data['currentSite']['name']);
}
public function testUserCannotSwitchToUnauthorizedSite(): void
{
// alice n'a que Chatellerault. Tenter Pommevic → 400 (anti-enumeration).
//
// Depuis l'ajout de SiteCollectionScopedExtension, les sites hors
// du scope de l'user sont filtres a la source : l'IriConverter ne
// peut pas resoudre `/api/sites/{id}` pour un site non autorise et
// leve 400 "Item not found". Reponse identique a "site inexistant",
// ce qui empeche l'enumeration des ids de sites tiers. Avant la PR
// scope, le processor traduisait SiteNotAuthorizedException → 403.
$em = $this->getEm();
$pommevic = $em->getRepository(Site::class)->findOneBy(['name' => 'Pommevic']);
self::assertNotNull($pommevic);
$client = $this->authenticatedClient('alice', 'alice');
$client->request('PATCH', '/api/me/current-site', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['site' => '/api/sites/'.$pommevic->getId()],
]);
self::assertResponseStatusCodeSame(400);
}
public function testSwitchWithMissingSiteFieldReturns400(): void
{
$client = $this->authenticatedClient('alice', 'alice');
$client->request('PATCH', '/api/me/current-site', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => [],
]);
self::assertResponseStatusCodeSame(400);
}
public function testAnonymousUserCannotSwitch(): void
{
$client = self::createClient();
$client->request('PATCH', '/api/me/current-site', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['site' => '/api/sites/1'],
]);
self::assertResponseStatusCodeSame(401);
}
public function testSwitchWithNonExistentSiteIriReturnsErrorStatus(): void
{
// IRI vers un site qui n'existe pas en base : API Platform leve un
// 400 Bad Request a la denormalisation (l'IriConverter ne peut pas
// resoudre l'IRI). On grave le code de retour reel pour eviter
// qu'une regression silencieuse passe inapercue.
$client = $this->authenticatedClient('alice', 'alice');
$client->request('PATCH', '/api/me/current-site', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['site' => '/api/sites/999999'],
]);
self::assertResponseStatusCodeSame(400);
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Sites\Api;
use App\Module\Core\Domain\Entity\User;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
/**
* Tests d'exposition des sites autorises et du site courant dans /api/me.
*
* Regression-guard du contrat avec le front (ticket 3) : `sites` doit etre
* une liste d'objets Site complets (pas des IRIs), et `currentSite` doit
* etre un objet ou null. Les clients front consomment directement ces
* champs pour alimenter le SiteSelector et le store auth.
*
* @internal
*/
final class MeEndpointSitesTest extends AbstractApiTestCase
{
public function testMeExposesSitesAsObjects(): void
{
$client = $this->authenticatedClient('alice', 'alice');
$response = $client->request('GET', '/api/me');
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertArrayHasKey('sites', $data);
self::assertIsArray($data['sites']);
self::assertCount(1, $data['sites']);
$firstSite = $data['sites'][0];
self::assertIsArray($firstSite, 'Un site doit etre serialise en objet, pas en IRI string.');
self::assertArrayHasKey('id', $firstSite);
self::assertArrayHasKey('name', $firstSite);
self::assertArrayHasKey('street', $firstSite);
self::assertArrayHasKey('city', $firstSite);
self::assertArrayHasKey('color', $firstSite);
// Le getter computed est expose en lecture pour eviter au front
// de redupliquer la logique de concatenation.
self::assertArrayHasKey('fullAddress', $firstSite);
self::assertSame('Chatellerault', $firstSite['name']);
// Garde anti-cycle (cf. Site::$users sans Groups, ticket 2 spec
// section 12 risque 6) : la collection inverse ne doit JAMAIS etre
// serialisee dans /api/me sous peine de boucle infinie
// User → sites → users → sites → ...
self::assertArrayNotHasKey(
'users',
$firstSite,
'Site.users ne doit JAMAIS etre serialise dans /api/me (cycle infini).',
);
}
public function testMeExposesCurrentSiteAsObject(): void
{
$client = $this->authenticatedClient('alice', 'alice');
$response = $client->request('GET', '/api/me');
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertArrayHasKey('currentSite', $data);
self::assertIsArray($data['currentSite'], 'currentSite doit etre un objet, pas une IRI.');
self::assertSame('Chatellerault', $data['currentSite']['name']);
}
public function testAdminHasAllThreeSites(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/me');
$data = $response->toArray();
self::assertCount(3, $data['sites']);
$names = array_column($data['sites'], 'name');
sort($names);
self::assertSame(['Chatellerault', 'Pommevic', 'Saint-Jean'], $names);
}
public function testUserWithoutSitesHasEmptyArrayAndNullCurrent(): void
{
// Creer un user jetable sans rattachement a un site.
$em = $this->getEm();
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
$username = 'orphan_'.$suffix;
$hasher = self::getContainer()->get('security.user_password_hasher');
$user = new User();
$user->setUsername($username);
$user->setIsAdmin(false);
$user->setPassword($hasher->hashPassword($user, 'testpass'));
$em->persist($user);
$em->flush();
try {
$client = $this->authenticatedClient($username, 'testpass');
$response = $client->request('GET', '/api/me');
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertSame([], $data['sites']);
self::assertNull($data['currentSite']);
} finally {
$em = $this->getEm();
$reloaded = $em->getRepository(User::class)->findOneBy(['username' => $username]);
if (null !== $reloaded) {
$em->remove($reloaded);
$em->flush();
}
}
}
}

View File

@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Sites\Api;
use App\Module\Sites\Domain\Entity\Site;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
/**
* Tests fonctionnels CRUD /api/sites avec matrices RBAC.
*
* Strategie : les 3 sites fixtures (Chatellerault, Saint-Jean, Pommevic)
* sont presents a chaque test. On nettoie les sites crees par les tests
* via un prefixe `Test-` en setUp + tearDown.
*
* @internal
*/
final class SiteApiTest extends AbstractApiTestCase
{
private const TEST_NAME_PREFIX = 'Test-';
protected function setUp(): void
{
parent::setUp();
$this->cleanupTestSites();
}
protected function tearDown(): void
{
$this->cleanupTestSites();
parent::tearDown();
}
public function testAdminCanListSites(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/sites');
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertGreaterThanOrEqual(3, $data['totalItems']);
}
public function testUserWithSitesViewCanListSites(): void
{
$this->skipIfSitesModuleDisabled();
$credentials = $this->createUserWithPermission('sites.view');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$client->request('GET', '/api/sites');
self::assertResponseIsSuccessful();
}
public function testUserWithoutPermissionGetsForbidden(): void
{
// alice a la permission via son role "user" ? Non : le role user par
// defaut n'a aucune permission. Elle ne peut donc pas lister.
$client = $this->authenticatedClient('alice', 'alice');
$client->request('GET', '/api/sites');
self::assertResponseStatusCodeSame(403);
}
public function testUnauthenticatedGetCollectionReturns401(): void
{
$client = self::createClient();
$client->request('GET', '/api/sites');
self::assertResponseStatusCodeSame(401);
}
public function testAdminCanCreateSite(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('POST', '/api/sites', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Test-New-Site',
'street' => '1 rue du Test',
'complement' => null,
'postalCode' => '86000',
'city' => 'Poitiers',
'color' => '#AABBCC',
],
]);
self::assertResponseStatusCodeSame(201);
$data = $response->toArray();
self::assertSame('Test-New-Site', $data['name']);
self::assertSame('#AABBCC', $data['color']);
}
public function testAdminCanPatchSite(): void
{
$em = $this->getEm();
$site = new Site('Test-Patch-Site', '1 rue Test', null, '86000', 'Poitiers', '#000000');
$em->persist($site);
$em->flush();
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('PATCH', '/api/sites/'.$site->getId(), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['color' => '#FF0000'],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertSame('#FF0000', $data['color']);
}
public function testAdminCanDeleteSite(): void
{
$em = $this->getEm();
$site = new Site('Test-Delete-Site', '1 rue Test', null, '86000', 'Poitiers', '#000000');
$em->persist($site);
$em->flush();
$siteId = $site->getId();
$client = $this->authenticatedClient('admin', 'admin');
$client->request('DELETE', '/api/sites/'.$siteId);
self::assertResponseStatusCodeSame(204);
$em->clear();
self::assertNull($em->getRepository(Site::class)->find($siteId));
}
public function testUserWithViewButNotManageCannotDelete(): void
{
$em = $this->getEm();
$site = new Site('Test-Protected', '1 rue Test', null, '86000', 'Poitiers', '#000000');
$em->persist($site);
$em->flush();
$this->skipIfSitesModuleDisabled();
$credentials = $this->createUserWithPermission('sites.view');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$client->request('DELETE', '/api/sites/'.$site->getId());
self::assertResponseStatusCodeSame(403);
}
public function testCreateSiteWithDuplicateNameReturns422(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$client->request('POST', '/api/sites', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Chatellerault',
'street' => 'Autre rue',
'postalCode' => '75001',
'city' => 'Autre ville',
'color' => '#FF0000',
],
]);
self::assertResponseStatusCodeSame(422);
}
public function testCreateSiteWithInvalidColorReturns422(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$client->request('POST', '/api/sites', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Test-Invalid-Color',
'street' => '1 rue Test',
'postalCode' => '86000',
'city' => 'Poitiers',
'color' => 'red',
],
]);
self::assertResponseStatusCodeSame(422);
}
public function testCreateSiteIgnoresFullAddressInPayload(): void
{
// Garde structurelle : `fullAddress` est un getter computed cote
// backend (Site::getFullAddress, groupe site:read uniquement). Si un
// client envoie ce champ en POST, API Platform doit l'ignorer
// silencieusement car il n'est pas dans le groupe site:write. On
// grave ce comportement pour qu'un futur dev qui ajouterait un
// setter casse ce test au lieu de casser l'invariant en silence.
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('POST', '/api/sites', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Test-FullAddress-Ignored',
'street' => '1 rue Test',
'postalCode' => '86000',
'city' => 'Poitiers',
'color' => '#000000',
'fullAddress' => 'Adresse arbitraire envoyee par le client',
],
]);
self::assertResponseStatusCodeSame(201);
$data = $response->toArray();
// Le getter computed prevaut sur ce qu'envoie le client : street
// determine la 1re ligne, jamais la valeur "Adresse arbitraire...".
self::assertSame("1 rue Test\n86000 Poitiers", $data['fullAddress']);
}
public function testCreateSiteWithInvalidPostalCodeReturns422(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$client->request('POST', '/api/sites', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Test-Invalid-CP',
'street' => '1 rue Test',
'postalCode' => '123',
'city' => 'Poitiers',
'color' => '#000000',
],
]);
self::assertResponseStatusCodeSame(422);
}
private function cleanupTestSites(): void
{
if (!self::$kernel) {
self::bootKernel();
}
$em = $this->getEm();
$em->createQuery('DELETE FROM '.Site::class.' s WHERE s.name LIKE :prefix')
->setParameter('prefix', self::TEST_NAME_PREFIX.'%')
->execute()
;
$em->clear();
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Sites\Api;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
/**
* Tests de cascade DB a la suppression d'un site.
*
* Verifie les deux comportements attendus :
* - `user_site` a `ON DELETE CASCADE` : les rattachements sont supprimes ;
* - `user.current_site_id` a `ON DELETE SET NULL` : les users pointant sur
* le site supprime voient leur `currentSite` repasser a NULL.
*
* @internal
*/
final class SiteCascadeTest extends AbstractApiTestCase
{
public function testDeletingSitePurgesUserSiteRows(): void
{
// Creer un site jetable et rattacher alice dessus.
$em = $this->getEm();
$site = new Site('Test-Cascade-Purge', '1 rue Test', null, '12345', 'Ville', '#000000');
$em->persist($site);
$em->flush();
$siteId = $site->getId();
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
self::assertNotNull($alice);
$alice->addSite($site);
$em->flush();
$em->clear();
// Verifie presence du rattachement M2M via SQL direct (l'EM est cleared).
$connection = $this->getEm()->getConnection();
$before = (int) $connection->fetchOne(
'SELECT COUNT(*) FROM user_site WHERE site_id = :id',
['id' => $siteId],
);
self::assertSame(1, $before);
// Admin supprime le site.
$client = $this->authenticatedClient('admin', 'admin');
$client->request('DELETE', '/api/sites/'.$siteId);
self::assertResponseStatusCodeSame(204);
// L'entree user_site doit avoir disparu via ON DELETE CASCADE.
$after = (int) $connection->fetchOne(
'SELECT COUNT(*) FROM user_site WHERE site_id = :id',
['id' => $siteId],
);
self::assertSame(0, $after, 'Les rattachements user_site doivent etre purges en cascade.');
}
public function testDeletingSiteSetsCurrentSiteToNullOnReferencingUsers(): void
{
$em = $this->getEm();
$site = new Site('Test-Cascade-Current', '1 rue Test', null, '12345', 'Ville', '#000000');
$em->persist($site);
$em->flush();
$siteId = $site->getId();
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
self::assertNotNull($alice);
$aliceId = $alice->getId();
$alice->addSite($site);
$alice->setCurrentSite($site);
$em->flush();
$em->clear();
// Admin supprime le site.
$client = $this->authenticatedClient('admin', 'admin');
$client->request('DELETE', '/api/sites/'.$siteId);
self::assertResponseStatusCodeSame(204);
// currentSite d'alice doit etre passe a NULL via ON DELETE SET NULL.
$em = $this->getEm();
$em->clear();
$reload = $em->getRepository(User::class)->find($aliceId);
self::assertNotNull($reload);
self::assertNull(
$reload->getCurrentSite(),
'currentSite doit etre NULL apres suppression du site reference.',
);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Sites\Domain\Entity;
use App\Module\Sites\Domain\Entity\Site;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
/**
* Tests unitaires de comportement de l'entite Site : etat initial, setters,
* gestion des timestamps et getter d'adresse complete. Les contraintes de
* validation (regex, unicite) sont couvertes par SiteValidationTest.
*
* @internal
*/
final class SiteTest extends TestCase
{
public function testConstructorInitialState(): void
{
$site = new Site(
name: 'Chatellerault',
street: "1 avenue de l'Europe",
complement: null,
postalCode: '86100',
city: 'Chatellerault',
color: '#056CF2',
);
self::assertNull($site->getId());
self::assertSame('Chatellerault', $site->getName());
self::assertSame("1 avenue de l'Europe", $site->getStreet());
self::assertNull($site->getComplement());
self::assertSame('86100', $site->getPostalCode());
self::assertSame('Chatellerault', $site->getCity());
self::assertSame('#056CF2', $site->getColor());
self::assertInstanceOf(DateTimeImmutable::class, $site->getCreatedAt());
self::assertInstanceOf(DateTimeImmutable::class, $site->getUpdatedAt());
}
public function testCreatedAtAndUpdatedAtAreInitiallyEqual(): void
{
$site = new Site('A', 'Rue X', null, '12345', 'B', '#000000');
// A la creation, les deux timestamps sont seedes avec la meme valeur
// pour garantir updated_at >= created_at au niveau base.
self::assertEquals($site->getCreatedAt(), $site->getUpdatedAt());
}
public function testOnPreUpdateAdvancesUpdatedAtOnly(): void
{
$site = new Site('A', 'Rue X', null, '12345', 'B', '#000000');
$originalCreatedAt = $site->getCreatedAt();
// On force updatedAt a une valeur strictement anterieure via reflection
// pour ne pas dependre d'un `sleep()` (flaky en CI, lent) : l'entite
// n'expose volontairement pas de setter sur updatedAt, c'est le
// callback Doctrine PreUpdate qui s'en charge.
$pastUpdatedAt = new DateTimeImmutable('-1 hour');
$reflection = new ReflectionClass(Site::class);
$updatedAtProperty = $reflection->getProperty('updatedAt');
$updatedAtProperty->setValue($site, $pastUpdatedAt);
$site->onPreUpdate();
self::assertSame($originalCreatedAt, $site->getCreatedAt(), 'created_at doit rester immuable apres un update.');
self::assertGreaterThan($pastUpdatedAt, $site->getUpdatedAt(), 'updated_at doit avancer apres onPreUpdate().');
}
public function testSettersMutateFields(): void
{
$site = new Site('Old', 'Old Street', null, '12345', 'OldCity', '#000000');
$site->setName('New');
$site->setStreet('New Street');
$site->setComplement('Bat A');
$site->setPostalCode('67890');
$site->setCity('NewCity');
$site->setColor('#ABCDEF');
self::assertSame('New', $site->getName());
self::assertSame('New Street', $site->getStreet());
self::assertSame('Bat A', $site->getComplement());
self::assertSame('67890', $site->getPostalCode());
self::assertSame('NewCity', $site->getCity());
self::assertSame('#ABCDEF', $site->getColor());
}
public function testFullAddressGetterWithoutComplement(): void
{
$site = new Site(
name: 'Site1',
street: '1 avenue de l\'Europe',
complement: null,
postalCode: '86100',
city: 'Chatellerault',
color: '#000000',
);
self::assertSame(
"1 avenue de l'Europe\n86100 Chatellerault",
$site->getFullAddress(),
);
}
public function testFullAddressGetterWithComplement(): void
{
$site = new Site(
name: 'Site2',
street: '12 route de Poitiers',
complement: 'Batiment B',
postalCode: '86330',
city: 'Saint-Jean-de-Sauves',
color: '#000000',
);
self::assertSame(
"12 route de Poitiers\nBatiment B\n86330 Saint-Jean-de-Sauves",
$site->getFullAddress(),
);
}
public function testFullAddressGetterIgnoresEmptyComplement(): void
{
// Garde defensive : un complement vide ou whitespace-only ne doit
// pas creer une ligne vide visuellement disgracieuse.
$site = new Site('S', 'Rue', ' ', '12345', 'Ville', '#000000');
self::assertSame("Rue\n12345 Ville", $site->getFullAddress());
}
}

View File

@@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Sites\Domain\Entity;
use App\Module\Sites\Domain\Entity\Site;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* Tests de validation de l'entite Site : contraintes scalaires (regex couleur
* hex, regex code postal FR, NotBlank, Length) ET unicite du nom. Utilise le
* validator applicatif (via KernelTestCase) afin que la contrainte
* UniqueEntity, adossee a Doctrine, puisse reellement interroger la base de
* test.
*
* @internal
*/
final class SiteValidationTest extends KernelTestCase
{
private ValidatorInterface $validator;
private EntityManagerInterface $em;
protected function setUp(): void
{
self::bootKernel();
$container = self::getContainer();
/** @var ValidatorInterface $validator */
$validator = $container->get(ValidatorInterface::class);
$this->validator = $validator;
/** @var EntityManagerInterface $em */
$em = $container->get(EntityManagerInterface::class);
$this->em = $em;
}
protected function tearDown(): void
{
// Liberation explicite des handles pour eviter les fuites inter-tests
// (pattern recommande par Symfony lorsque l'on capture le container).
$this->em->clear();
parent::tearDown();
}
public function testValidSitePassesValidation(): void
{
$site = $this->makeSite();
$violations = $this->validator->validate($site);
self::assertCount(0, $violations, (string) $violations);
}
public function testValidSiteWithComplementPassesValidation(): void
{
$site = $this->makeSite(complement: 'Batiment C');
$violations = $this->validator->validate($site);
self::assertCount(0, $violations, (string) $violations);
}
#[DataProvider('invalidColorProvider')]
public function testColorMustBeHexRrggbb(string $color): void
{
$site = $this->makeSite(color: $color);
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count(), sprintf('La couleur "%s" devrait etre rejetee.', $color));
}
/**
* @return iterable<string, array{string}>
*/
public static function invalidColorProvider(): iterable
{
yield 'nom CSS' => ['red'];
yield 'hex court' => ['#FFF'];
yield 'hex sans diese' => ['FFFFFF'];
yield 'rgb()' => ['rgb(255, 0, 0)'];
yield 'hex trop long' => ['#1234567'];
yield 'caractere non hex' => ['#12345G'];
yield 'vide' => [''];
}
#[DataProvider('validColorProvider')]
public function testValidColorsAreAccepted(string $color): void
{
$site = $this->makeSite(color: $color);
$violations = $this->validator->validate($site);
self::assertCount(0, $violations, sprintf('La couleur "%s" devrait etre acceptee.', $color));
}
/**
* @return iterable<string, array{string}>
*/
public static function validColorProvider(): iterable
{
yield 'majuscules' => ['#ABCDEF'];
yield 'minuscules' => ['#abcdef'];
yield 'mixte' => ['#0a1B2c'];
yield 'noir' => ['#000000'];
yield 'blanc' => ['#FFFFFF'];
}
#[DataProvider('invalidPostalCodeProvider')]
public function testPostalCodeMustMatchFrFormat(string $postalCode): void
{
$site = $this->makeSite(postalCode: $postalCode);
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count(), sprintf('Le CP "%s" devrait etre rejete.', $postalCode));
}
/**
* @return iterable<string, array{string}>
*/
public static function invalidPostalCodeProvider(): iterable
{
yield 'trop court' => ['1234'];
yield 'trop long' => ['123456'];
yield 'alphanumerique' => ['8610A'];
yield 'avec tiret' => ['86-100'];
yield 'vide' => [''];
yield 'avec espace' => ['86 100'];
}
#[DataProvider('validPostalCodeProvider')]
public function testValidPostalCodesAreAccepted(string $postalCode): void
{
$site = $this->makeSite(postalCode: $postalCode);
$violations = $this->validator->validate($site);
self::assertCount(0, $violations, (string) $violations);
}
/**
* @return iterable<string, array{string}>
*/
public static function validPostalCodeProvider(): iterable
{
yield 'metropole' => ['86100'];
yield 'paris' => ['75001'];
yield 'dom' => ['97100'];
yield 'corse' => ['20000'];
}
public function testBlankNameIsRejected(): void
{
$site = $this->makeSite(name: '');
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count());
}
public function testBlankStreetIsRejected(): void
{
$site = $this->makeSite(street: '');
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count());
}
public function testBlankCityIsRejected(): void
{
$site = $this->makeSite(city: '');
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count());
}
public function testNameLongerThan100CharsIsRejected(): void
{
$site = $this->makeSite(name: str_repeat('a', 101));
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count());
}
public function testCityLongerThan100CharsIsRejected(): void
{
$site = $this->makeSite(city: str_repeat('a', 101));
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count());
}
public function testStreetLongerThan255CharsIsRejected(): void
{
$site = $this->makeSite(street: str_repeat('a', 256));
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count());
}
public function testComplementLongerThan255CharsIsRejected(): void
{
$site = $this->makeSite(complement: str_repeat('a', 256));
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count());
}
/**
* Verifie que la contrainte UniqueEntity(name) est effectivement appliquee
* par le validator Symfony (via le validateur Doctrine sous-jacent).
*
* Le test est auto-suffisant : il persiste lui-meme un site porteur d'un
* nom unique, puis tente de valider un second Site avec le meme nom. Le
* site cree est supprime en fin de test pour ne pas laisser de trace
* inter-tests (pattern transactionnel non utilise ici car un seul test
* persiste, un cleanup explicite suffit).
*/
public function testDuplicateNameIsRejected(): void
{
$name = 'Test-Duplicate-'.uniqid('', true);
$original = $this->makeSite(name: $name);
$this->em->persist($original);
$this->em->flush();
try {
$duplicate = $this->makeSite(name: $name, city: 'Autre');
$violations = $this->validator->validate($duplicate);
self::assertGreaterThan(0, $violations->count(), 'Un site homonyme doit lever au moins une violation.');
// Assertion precise : on veut s'assurer que la violation levee
// est bien UniqueEntity sur `name`, pas une autre contrainte
// qui passerait par hasard (matching de message trop laxe).
$found = false;
foreach ($violations as $violation) {
if (UniqueEntity::NOT_UNIQUE_ERROR === $violation->getCode()
&& 'name' === $violation->getPropertyPath()) {
$found = true;
break;
}
}
self::assertTrue($found, 'Violation UniqueEntity(name) attendue (code NOT_UNIQUE_ERROR sur property `name`).');
} finally {
$this->em->remove($original);
$this->em->flush();
}
}
/**
* Helper : construit un Site valide avec un nom unique, sur lequel on
* peut superposer un seul champ invalide pour tester une contrainte.
*/
private function makeSite(
?string $name = null,
string $street = '1 rue Test',
?string $complement = null,
string $postalCode = '12345',
string $city = 'Poitiers',
string $color = '#000000',
): Site {
return new Site(
$name ?? 'Test-'.uniqid('', true),
$street,
$complement,
$postalCode,
$city,
$color,
);
}
}

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

View File

@@ -0,0 +1,161 @@
<?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 App\Shared\Domain\Contract\SiteInterface;
use PHPUnit\Framework\TestCase;
use stdClass;
use Symfony\Bundle\SecurityBundle\Security;
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);
// Stub Security : bypass_scope = true par defaut pour preserver le
// comportement des tests historiques (pas de validation cross-site).
// Les tests dedies a la validation cross-site instancient leur propre
// Security via un helper dedie.
$security = $this->createStub(Security::class);
$security->method('isGranted')->willReturn(true);
return new SiteAwareInjectionProcessor($inner, $provider, $security);
}
private function makeSiteAwareStub(?Site $initialSite): SiteAwareInterface
{
return new class($initialSite) implements SiteAwareInterface {
public function __construct(private ?SiteInterface $site) {}
public function getSite(): ?SiteInterface
{
return $this->site;
}
public function setSite(SiteInterface $site): void
{
$this->site = $site;
}
};
}
private function makeOperation(): Operation
{
return new Post();
}
}

View 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]).',
);
}
}