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: MALIO-DEV/Coltura#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

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