feat(sites) : API CRUD + rattachement User<->Site + admin (ticket 2/4)

Exposition de Site via API Platform (5 operations RBAC sites.view/sites.manage),
relation User.sites (M2M user_site EAGER) + User.currentSite (M2O nullable,
ON DELETE SET NULL). Endpoint PATCH /api/me/current-site via ressource
virtuelle + processor (SiteNotAuthorizedException → 403). UserRbacProcessor
etendu avec gardes post-persist : auto-reset si currentSite retire, auto-select
premier site si null + sites non vide.

Page /admin/sites (DataTable + drawer creation/edition + modale suppression).
UserRbacDrawer etendu avec section "Sites autorises". Colonne "Sites" ajoutee
dans la table /admin/users (liste des noms separes par virgule). Sidebar
entree Sites (module: sites, permission: sites.view).

Refactor adresse : split full_address en street + complement (nullable) + getter
computed Site::getFullAddress() multi-lignes. Migration ALTER dediee pour
compat devs ayant deja joue le ticket 1. Fixtures avec vraies adresses
(Chatellerault/Fontenet/Pommevic).

Doctrine : inversedBy synchrone User.sites <-> Site.users pour maintenir la
collection inverse en memoire. User::switchCurrentSite() porte la garde
domaine (throw SiteNotAuthorizedException), aligne sur Role::ensureDeletable.
Helper skipIfSitesModuleDisabled centralise dans AbstractApiTestCase.

Tests : 182/182 (182/182 aussi module desactive, 2 skipped). 29 nouveaux tests
PHPUnit (CRUD API, switch currentSite, cascade DB, /api/me enrichi, extension
/rbac, gardes structurelles fullAddress/currentSite ignores, anti-cycle
Site.users). 11 tests Vitest sur la validation hex couleur.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 10:09:05 +02:00
parent 105574ba2f
commit d137828919
32 changed files with 2271 additions and 117 deletions

View File

@@ -10,9 +10,9 @@ use PHPUnit\Framework\TestCase;
use ReflectionClass;
/**
* Tests unitaires de comportement de l'entite Site : etat initial, setters
* et gestion des timestamps. Les contraintes de validation (regex, unicite)
* sont couvertes par SiteValidationTest.
* 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
*/
@@ -21,26 +21,28 @@ final class SiteTest extends TestCase
public function testConstructorInitialState(): void
{
$site = new Site(
'Chatellerault',
'Chatellerault',
'86100',
'#056CF2',
"1 avenue de l'Europe\n86100 Chatellerault",
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('Chatellerault', $site->getCity());
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::assertStringContainsString('Chatellerault', $site->getFullAddress());
self::assertInstanceOf(DateTimeImmutable::class, $site->getCreatedAt());
self::assertInstanceOf(DateTimeImmutable::class, $site->getUpdatedAt());
}
public function testCreatedAtAndUpdatedAtAreInitiallyEqual(): void
{
$site = new Site('A', 'B', '12345', '#000000', 'Rue X');
$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.
@@ -49,7 +51,7 @@ final class SiteTest extends TestCase
public function testOnPreUpdateAdvancesUpdatedAtOnly(): void
{
$site = new Site('A', 'B', '12345', '#000000', 'Rue X');
$site = new Site('A', 'Rue X', null, '12345', 'B', '#000000');
$originalCreatedAt = $site->getCreatedAt();
// On force updatedAt a une valeur strictement anterieure via reflection
@@ -69,18 +71,63 @@ final class SiteTest extends TestCase
public function testSettersMutateFields(): void
{
$site = new Site('Old', 'OldCity', '12345', '#000000', 'Old Addr');
$site = new Site('Old', 'Old Street', null, '12345', 'OldCity', '#000000');
$site->setName('New');
$site->setCity('NewCity');
$site->setStreet('New Street');
$site->setComplement('Bat A');
$site->setPostalCode('67890');
$site->setCity('NewCity');
$site->setColor('#ABCDEF');
$site->setFullAddress('New Addr');
self::assertSame('New', $site->getName());
self::assertSame('NewCity', $site->getCity());
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());
self::assertSame('New Addr', $site->getFullAddress());
}
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());
}
}