From 8590e3e8509cad969f3fecfb4ff0270679d64e3e Mon Sep 17 00:00:00 2001 From: tristan Date: Fri, 17 Apr 2026 15:45:33 +0200 Subject: [PATCH] feat(sites) : brique fondatrice du module Sites (ticket 1/4) Module Sites optionnel et desactivable via config/modules.php. Entite Site (nom unique, ville, CP FR, couleur hex, adresse), repository + impl Doctrine, migration racine (namespace DoctrineMigrations conforme exception CLAUDE.md), fixtures idempotentes (Chatellerault, Saint-Jean, Pommevic), permissions RBAC sites.view/sites.manage. Tests unitaires + validation via KernelTestCase (UniqueEntity, regex hex et CP, NotBlank, Length). Co-Authored-By: Claude Opus 4.7 (1M context) --- config/modules.php | 2 + config/packages/doctrine.yaml | 10 + config/services.yaml | 3 + migrations/Version20260417120000.php | 67 +++++ src/Module/Sites/Domain/Entity/Site.php | 185 +++++++++++++ .../Repository/SiteRepositoryInterface.php | 23 ++ .../DataFixtures/SitesFixtures.php | 105 +++++++ .../Doctrine/DoctrineSiteRepository.php | 52 ++++ src/Module/Sites/SitesModule.php | 37 +++ tests/Module/Sites/Domain/Entity/SiteTest.php | 86 ++++++ .../Domain/Entity/SiteValidationTest.php | 259 ++++++++++++++++++ 11 files changed, 829 insertions(+) create mode 100644 migrations/Version20260417120000.php create mode 100644 src/Module/Sites/Domain/Entity/Site.php create mode 100644 src/Module/Sites/Domain/Repository/SiteRepositoryInterface.php create mode 100644 src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php create mode 100644 src/Module/Sites/Infrastructure/Doctrine/DoctrineSiteRepository.php create mode 100644 src/Module/Sites/SitesModule.php create mode 100644 tests/Module/Sites/Domain/Entity/SiteTest.php create mode 100644 tests/Module/Sites/Domain/Entity/SiteValidationTest.php diff --git a/config/modules.php b/config/modules.php index c5548ad..449543e 100644 --- a/config/modules.php +++ b/config/modules.php @@ -3,8 +3,10 @@ declare(strict_types=1); use App\Module\Commercial\CommercialModule; use App\Module\Core\CoreModule; +use App\Module\Sites\SitesModule; return [ CoreModule::class, CommercialModule::class, + SitesModule::class, ]; diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index bd9f900..2a0cbb6 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -15,6 +15,16 @@ doctrine: dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity' prefix: 'App\Module\Core\Domain\Entity' alias: Core + # Mapping inconditionnelle du module Sites : la structure DB + # existe meme si SitesModule::class est retire de config/modules.php. + # L'activation fonctionnelle (ex: exposition des permissions, futurs + # endpoints API) passe exclusivement par config/modules.php. + Sites: + type: attribute + is_bundle: false + dir: '%kernel.project_dir%/src/Module/Sites/Domain/Entity' + prefix: 'App\Module\Sites\Domain\Entity' + alias: Sites controller_resolver: auto_mapping: false diff --git a/config/services.yaml b/config/services.yaml index 9457e91..358541c 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -24,3 +24,6 @@ services: App\Module\Core\Domain\Repository\UserRepositoryInterface: alias: App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository + + App\Module\Sites\Domain\Repository\SiteRepositoryInterface: + alias: App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository diff --git a/migrations/Version20260417120000.php b/migrations/Version20260417120000.php new file mode 100644 index 0000000..6efd921 --- /dev/null +++ b/migrations/Version20260417120000.php @@ -0,0 +1,67 @@ +addSql(<<<'SQL' + CREATE TABLE site ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + name VARCHAR(100) NOT NULL, + city VARCHAR(100) NOT NULL, + postal_code VARCHAR(10) NOT NULL, + color VARCHAR(7) NOT NULL, + full_address TEXT NOT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + PRIMARY KEY (id) + ) + SQL); + + // Index unique sur le nom : garantit l'invariant metier "un site porte + // un nom unique" et permet a la contrainte UniqueEntity cote Symfony + // de s'appuyer sur une erreur DB en cas de race condition. + $this->addSql('CREATE UNIQUE INDEX uniq_site_name ON site (name)'); + } + + public function down(Schema $schema): void + { + // Drop direct : aucune FK depuis/vers la table dans ce ticket. + $this->addSql('DROP TABLE site'); + } +} diff --git a/src/Module/Sites/Domain/Entity/Site.php b/src/Module/Sites/Domain/Entity/Site.php new file mode 100644 index 0000000..2ac11b4 --- /dev/null +++ b/src/Module/Sites/Domain/Entity/Site.php @@ -0,0 +1,185 @@ +name = $name; + $this->city = $city; + $this->postalCode = $postalCode; + $this->color = $color; + $this->fullAddress = $fullAddress; + $now = new DateTimeImmutable(); + $this->createdAt = $now; + $this->updatedAt = $now; + } + + /** + * Callback Doctrine : a chaque update en base on rafraichit updatedAt. + * Ne pas toucher a createdAt ici (immutable apres creation). + */ + #[ORM\PreUpdate] + public function onPreUpdate(): void + { + $this->updatedAt = new DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getCity(): string + { + return $this->city; + } + + public function setCity(string $city): static + { + $this->city = $city; + + return $this; + } + + public function getPostalCode(): string + { + return $this->postalCode; + } + + public function setPostalCode(string $postalCode): static + { + $this->postalCode = $postalCode; + + return $this; + } + + public function getColor(): string + { + return $this->color; + } + + public function setColor(string $color): static + { + $this->color = $color; + + return $this; + } + + public function getFullAddress(): string + { + return $this->fullAddress; + } + + public function setFullAddress(string $fullAddress): static + { + $this->fullAddress = $fullAddress; + + return $this; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + public function getUpdatedAt(): DateTimeImmutable + { + return $this->updatedAt; + } +} diff --git a/src/Module/Sites/Domain/Repository/SiteRepositoryInterface.php b/src/Module/Sites/Domain/Repository/SiteRepositoryInterface.php new file mode 100644 index 0000000..bdf6251 --- /dev/null +++ b/src/Module/Sites/Domain/Repository/SiteRepositoryInterface.php @@ -0,0 +1,23 @@ + + */ + public function findAllOrderedByName(): array; + + public function save(Site $site): void; + + public function remove(Site $site): void; +} diff --git a/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php b/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php new file mode 100644 index 0000000..194fd14 --- /dev/null +++ b/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php @@ -0,0 +1,105 @@ +ensureSite( + $manager, + name: 'Chatellerault', + city: 'Chatellerault', + postalCode: '86100', + color: '#056CF2', + fullAddress: "1 avenue de l'Europe\n86100 Chatellerault", + ); + + // Saint-Jean : vert emeraude pour contraster avec le bleu Chatellerault. + $this->ensureSite( + $manager, + name: 'Saint-Jean', + city: 'Saint-Jean-de-Sauves', + postalCode: '86330', + color: '#10B981', + fullAddress: "12 route de Poitiers\n86330 Saint-Jean-de-Sauves", + ); + + // Pommevic : ambre pour une troisieme teinte nettement distincte. + $this->ensureSite( + $manager, + name: 'Pommevic', + city: 'Pommevic', + postalCode: '82400', + color: '#F59E0B', + fullAddress: "5 chemin des Peupliers\n82400 Pommevic", + ); + + $manager->flush(); + } + + /** + * Cree le site s'il n'existe pas encore, sinon re-aligne ville, code + * postal, couleur et adresse sur les valeurs de reference. + * + * Note : le nom sert de cle de lookup (il est unique en base) et n'est + * donc pas resynchronise. Consequence : renommer un site dans la + * fixture cree un nouveau site sans supprimer l'ancien, sauf si le + * purger Doctrine est actif (cas nominal de `doctrine:fixtures:load`). + */ + private function ensureSite( + ObjectManager $manager, + string $name, + string $city, + string $postalCode, + string $color, + string $fullAddress, + ): Site { + $site = $this->siteRepository->findByName($name); + + if (null === $site) { + $site = new Site($name, $city, $postalCode, $color, $fullAddress); + $manager->persist($site); + + return $site; + } + + $site->setCity($city); + $site->setPostalCode($postalCode); + $site->setColor($color); + $site->setFullAddress($fullAddress); + + return $site; + } +} diff --git a/src/Module/Sites/Infrastructure/Doctrine/DoctrineSiteRepository.php b/src/Module/Sites/Infrastructure/Doctrine/DoctrineSiteRepository.php new file mode 100644 index 0000000..13ea032 --- /dev/null +++ b/src/Module/Sites/Infrastructure/Doctrine/DoctrineSiteRepository.php @@ -0,0 +1,52 @@ + + */ +class DoctrineSiteRepository extends ServiceEntityRepository implements SiteRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Site::class); + } + + public function findById(int $id): ?Site + { + return $this->find($id); + } + + public function findByName(string $name): ?Site + { + return $this->findOneBy(['name' => $name]); + } + + /** + * @return list + */ + public function findAllOrderedByName(): array + { + /** @var list $sites */ + return $this->findBy([], ['name' => 'ASC']); + } + + public function save(Site $site): void + { + $this->getEntityManager()->persist($site); + $this->getEntityManager()->flush(); + } + + public function remove(Site $site): void + { + $this->getEntityManager()->remove($site); + $this->getEntityManager()->flush(); + } +} diff --git a/src/Module/Sites/SitesModule.php b/src/Module/Sites/SitesModule.php new file mode 100644 index 0000000..2cdf26c --- /dev/null +++ b/src/Module/Sites/SitesModule.php @@ -0,0 +1,37 @@ + + */ + public static function permissions(): array + { + return [ + ['code' => 'sites.view', 'label' => 'Voir les sites'], + ['code' => 'sites.manage', 'label' => 'Gerer les sites (creer, editer, supprimer)'], + ]; + } +} diff --git a/tests/Module/Sites/Domain/Entity/SiteTest.php b/tests/Module/Sites/Domain/Entity/SiteTest.php new file mode 100644 index 0000000..1895d8b --- /dev/null +++ b/tests/Module/Sites/Domain/Entity/SiteTest.php @@ -0,0 +1,86 @@ +getId()); + self::assertSame('Chatellerault', $site->getName()); + self::assertSame('Chatellerault', $site->getCity()); + self::assertSame('86100', $site->getPostalCode()); + 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'); + + // 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', 'B', '12345', '#000000', 'Rue X'); + $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', 'OldCity', '12345', '#000000', 'Old Addr'); + + $site->setName('New'); + $site->setCity('NewCity'); + $site->setPostalCode('67890'); + $site->setColor('#ABCDEF'); + $site->setFullAddress('New Addr'); + + self::assertSame('New', $site->getName()); + self::assertSame('NewCity', $site->getCity()); + self::assertSame('67890', $site->getPostalCode()); + self::assertSame('#ABCDEF', $site->getColor()); + self::assertSame('New Addr', $site->getFullAddress()); + } +} diff --git a/tests/Module/Sites/Domain/Entity/SiteValidationTest.php b/tests/Module/Sites/Domain/Entity/SiteValidationTest.php new file mode 100644 index 0000000..14a6b7f --- /dev/null +++ b/tests/Module/Sites/Domain/Entity/SiteValidationTest.php @@ -0,0 +1,259 @@ +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 + { + // Reutilise un nom deja present en fixtures (Chatellerault) impliquerait + // une collision UniqueEntity. On prend donc un nom dedie aux tests. + $site = new Site('Test-Valid-'.uniqid('', true), 'Poitiers', '86000', '#056CF2', 'Adresse valide'); + $violations = $this->validator->validate($site); + + self::assertCount(0, $violations, (string) $violations); + } + + #[DataProvider('invalidColorProvider')] + public function testColorMustBeHexRrggbb(string $color): void + { + $site = new Site('Test-'.uniqid('', true), 'Y', '12345', $color, 'Addr'); + + $violations = $this->validator->validate($site); + + self::assertGreaterThan(0, $violations->count(), sprintf('La couleur "%s" devrait etre rejetee.', $color)); + } + + /** + * @return iterable + */ + 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 = new Site('Test-'.uniqid('', true), 'Y', '12345', $color, 'Addr'); + + $violations = $this->validator->validate($site); + + self::assertCount(0, $violations, sprintf('La couleur "%s" devrait etre acceptee.', $color)); + } + + /** + * @return iterable + */ + 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 = new Site('Test-'.uniqid('', true), 'Y', $postalCode, '#000000', 'Addr'); + + $violations = $this->validator->validate($site); + + self::assertGreaterThan(0, $violations->count(), sprintf('Le CP "%s" devrait etre rejete.', $postalCode)); + } + + /** + * @return iterable + */ + 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 = new Site('Test-'.uniqid('', true), 'Y', $postalCode, '#000000', 'Addr'); + + $violations = $this->validator->validate($site); + + self::assertCount(0, $violations, (string) $violations); + } + + /** + * @return iterable + */ + public static function validPostalCodeProvider(): iterable + { + yield 'metropole' => ['86100']; + + yield 'paris' => ['75001']; + + yield 'dom' => ['97100']; + + yield 'corse' => ['20000']; + } + + public function testBlankNameIsRejected(): void + { + $site = new Site('', 'Y', '12345', '#000000', 'Addr'); + + $violations = $this->validator->validate($site); + + self::assertGreaterThan(0, $violations->count()); + } + + public function testBlankCityIsRejected(): void + { + $site = new Site('Test-'.uniqid('', true), '', '12345', '#000000', 'Addr'); + + $violations = $this->validator->validate($site); + + self::assertGreaterThan(0, $violations->count()); + } + + public function testBlankFullAddressIsRejected(): void + { + $site = new Site('Test-'.uniqid('', true), 'Y', '12345', '#000000', ''); + + $violations = $this->validator->validate($site); + + self::assertGreaterThan(0, $violations->count()); + } + + public function testNameLongerThan100CharsIsRejected(): void + { + $site = new Site(str_repeat('a', 101), 'Y', '12345', '#000000', 'Addr'); + + $violations = $this->validator->validate($site); + + self::assertGreaterThan(0, $violations->count()); + } + + public function testCityLongerThan100CharsIsRejected(): void + { + $site = new Site('Test-'.uniqid('', true), str_repeat('a', 101), '12345', '#000000', 'Addr'); + + $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 + { + // Nom unique par execution pour eviter toute collision avec les + // fixtures (Chatellerault, Saint-Jean, Pommevic) ou des tests + // paralleles. + $name = 'Test-Duplicate-'.uniqid('', true); + $original = new Site($name, 'Poitiers', '86000', '#056CF2', 'Adresse originale'); + $this->em->persist($original); + $this->em->flush(); + + try { + $duplicate = new Site($name, 'Autre ville', '75001', '#FF0000', 'Autre adresse'); + $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(); + } + } +}