Compare commits
10 Commits
105574ba2f
...
feat/admin
| Author | SHA1 | Date | |
|---|---|---|---|
| edbe54cb8a | |||
| 1550f46b23 | |||
| cb6d2d72ec | |||
|
|
a15fc83222 | ||
|
|
caae752130 | ||
|
|
8bedab407d | ||
|
|
fd5d3fe36f | ||
| 296befe187 | |||
| 03c761eed4 | |||
| d137828919 |
@@ -1,14 +1,15 @@
|
||||
api_platform:
|
||||
title: Coltura API
|
||||
version: 1.0.0
|
||||
# Scan du module Core pour decouvrir les classes ApiResource et ApiFilter.
|
||||
# Ajouter un chemin par module lors de l'ajout d'entites ApiResource dans d'autres modules.
|
||||
# Sans ces paths, le compile pass d'API Platform ne declare pas les
|
||||
# services de filtres annotes (les filtres etaient silencieusement
|
||||
# ignores sur Permission — cf. ticket #344).
|
||||
# Scan des modules pour decouvrir les classes ApiResource et ApiFilter.
|
||||
# Ajouter un chemin par module lors de l'ajout d'entites ApiResource
|
||||
# dans d'autres modules. Sans ces paths, le compile pass d'API Platform
|
||||
# ne declare pas les services de filtres annotes (les filtres etaient
|
||||
# silencieusement ignores sur Permission — cf. ticket #344).
|
||||
mapping:
|
||||
paths:
|
||||
- '%kernel.project_dir%/src/Module/Core/Domain/Entity'
|
||||
- '%kernel.project_dir%/src/Module/Sites/Domain/Entity'
|
||||
formats:
|
||||
jsonld: ['application/ld+json']
|
||||
json: ['application/json']
|
||||
@@ -18,3 +19,10 @@ api_platform:
|
||||
stateless: true
|
||||
cache_headers:
|
||||
vary: ['Content-Type', 'Authorization', 'Origin']
|
||||
# Active la negociation client de la pagination via ?itemsPerPage=X
|
||||
# (necessaire pour le dropdown perPage des DataTable admin). Borne
|
||||
# haute a 100 pour eviter qu'un client abuse en demandant 10000
|
||||
# items d'un coup — les UIs admin n'ont jamais besoin de plus de 50
|
||||
# en pratique.
|
||||
pagination_client_items_per_page: true
|
||||
pagination_maximum_items_per_page: 100
|
||||
|
||||
@@ -32,6 +32,20 @@ when@test:
|
||||
doctrine:
|
||||
dbal:
|
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||
orm:
|
||||
mappings:
|
||||
# Entite fictive SiteAware utilisee uniquement en tests du
|
||||
# module Sites (ticket 4). Le mapping n'est charge qu'en
|
||||
# environnement test, donc aucun impact sur les schemas
|
||||
# dev/prod. La table est creee a la volee par les tests
|
||||
# d'integration (via `SchemaTool::createSchema`) dans le
|
||||
# setUp de SiteScopedQueryExtensionTest.
|
||||
TestFixtures:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/tests/Fixtures/SiteAware'
|
||||
prefix: 'App\Tests\Fixtures\SiteAware'
|
||||
alias: TestFixtures
|
||||
|
||||
when@prod:
|
||||
doctrine:
|
||||
|
||||
@@ -27,3 +27,6 @@ services:
|
||||
|
||||
App\Module\Sites\Domain\Repository\SiteRepositoryInterface:
|
||||
alias: App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository
|
||||
|
||||
App\Module\Sites\Application\Service\CurrentSiteProviderInterface:
|
||||
alias: App\Module\Sites\Application\Service\CurrentSiteProvider
|
||||
|
||||
@@ -48,6 +48,13 @@ return [
|
||||
'module' => 'core',
|
||||
'permission' => 'core.users.view',
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.core.sites',
|
||||
'to' => '/admin/sites',
|
||||
'icon' => 'mdi:domain',
|
||||
'module' => 'sites',
|
||||
'permission' => 'sites.view',
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.general.logout',
|
||||
'to' => '/logout',
|
||||
|
||||
287
docs/modules/site-aware.md
Normal file
287
docs/modules/site-aware.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# Guide développeur — `SiteAwareInterface` (opt-in)
|
||||
|
||||
Ce guide explique comment adopter le pattern **site-aware** sur une entité
|
||||
d'un module métier pour que ses données soient automatiquement filtrées
|
||||
par le site courant de l'utilisateur connecté, et pour que les créations
|
||||
soient rattachées implicitement au site courant.
|
||||
|
||||
Ce pattern est **opt-in strict** : aucune entité n'est affectée tant qu'un
|
||||
module ne choisit pas explicitement d'implémenter `SiteAwareInterface`.
|
||||
|
||||
Livré par le ticket 4/4 du module Sites (cf. `docs/sites/ticket-04-spec.md`).
|
||||
|
||||
## 1. Quand adopter ?
|
||||
|
||||
Adopte le pattern si :
|
||||
|
||||
- Chaque ligne de l'entité appartient à **un et un seul site**.
|
||||
- Les utilisateurs du site A ne doivent **jamais** voir les lignes du site B.
|
||||
- Créer une ligne sans connaître le site n'a pas de sens métier.
|
||||
|
||||
Exemples typiques : `Supplier`, `Order`, `StockEntry`, `Employee` (si chaque
|
||||
site a sa propre équipe), `Invoice` (si facturation par site).
|
||||
|
||||
## 2. Quand NE PAS adopter ?
|
||||
|
||||
**Entités globales** : partagées par tous les sites, pas de notion de
|
||||
propriétaire. Ne pas adopter.
|
||||
|
||||
- `Role`, `Permission`, `User` (les users sont transverses, rattachés à
|
||||
plusieurs sites via la relation M2M `user_site`).
|
||||
- Catalogues mutualisés : produits, catégories, taxes — sauf si chaque
|
||||
site a son propre catalogue.
|
||||
- Documents / contrats multi-site (ex: contrat-cadre qui couvre plusieurs
|
||||
sites).
|
||||
|
||||
**Entités "par tenant"** : si le scope naturel est plus large que le site
|
||||
(ex: un groupe qui possède plusieurs sites comme entités filiales
|
||||
juridiquement distinctes), utilise plutôt `TenantAwareInterface` (déjà
|
||||
présent dans `src/Shared/Domain/Contract/`).
|
||||
|
||||
**Entités hybrides** : certaines lignes globales, d'autres par site. Le
|
||||
pattern ne supporte pas ce cas — crée deux entités distinctes si
|
||||
nécessaire.
|
||||
|
||||
## 3. Comment adopter ? Check-list
|
||||
|
||||
### 3.1 Entité
|
||||
|
||||
```php
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Shared\Domain\Contract\SiteAwareInterface;
|
||||
|
||||
class Supplier implements SiteAwareInterface
|
||||
{
|
||||
#[ORM\ManyToOne(targetEntity: Site::class)]
|
||||
#[ORM\JoinColumn(name: 'site_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Site $site = null;
|
||||
|
||||
public function getSite(): ?Site
|
||||
{
|
||||
return $this->site;
|
||||
}
|
||||
|
||||
public function setSite(Site $site): void
|
||||
{
|
||||
$this->site = $site;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Points critiques :
|
||||
|
||||
- `nullable: false` au niveau de la `JoinColumn` — la table n'accepte
|
||||
jamais `site_id IS NULL` en régime nominal.
|
||||
- `onDelete: 'CASCADE'` — la suppression d'un site entraîne la suppression
|
||||
de toutes les lignes associées. À remplacer par `RESTRICT` (blocage) si
|
||||
ton métier exige d'empêcher la suppression d'un site contenant des
|
||||
données.
|
||||
- Le getter retourne `?Site` (nullable) pour permettre des états
|
||||
transitoires pré-persist (entité construite avant injection du site).
|
||||
|
||||
### 3.2 Migration
|
||||
|
||||
**Cas 1 — Nouvelle table** : ajoute directement `site_id INT NOT NULL`
|
||||
avec FK et index.
|
||||
|
||||
**Cas 2 — Table existante avec données legacy** : migration en trois étapes
|
||||
distinctes.
|
||||
|
||||
```php
|
||||
// Version1.php
|
||||
$this->addSql('ALTER TABLE supplier ADD site_id INT DEFAULT NULL');
|
||||
$this->addSql('CREATE INDEX IDX_supplier_site ON supplier (site_id)');
|
||||
$this->addSql('ALTER TABLE supplier ADD CONSTRAINT FK_supplier_site FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE CASCADE');
|
||||
|
||||
// Backfill (manuellement ou via script custom selon ton métier)
|
||||
$this->addSql("UPDATE supplier SET site_id = (SELECT id FROM site WHERE name = 'Chatellerault') WHERE site_id IS NULL");
|
||||
|
||||
// Version2.php — après backfill confirmé
|
||||
$this->addSql('ALTER TABLE supplier ALTER COLUMN site_id SET NOT NULL');
|
||||
```
|
||||
|
||||
**Index obligatoire** : le filtre généré par `SiteScopedQueryExtension`
|
||||
est `WHERE x.site = :currentSite`. Sans index sur `site_id`, chaque
|
||||
requête fait un full-scan de la table. Ajoute-le dans la migration.
|
||||
|
||||
### 3.3 Sérialisation API
|
||||
|
||||
Expose la relation `site` dans le groupe de lecture de la ressource pour
|
||||
que le frontend sache à quel site appartient chaque ligne :
|
||||
|
||||
```php
|
||||
#[Groups(['supplier:read'])]
|
||||
private ?Site $site = null;
|
||||
```
|
||||
|
||||
Si tu veux aussi permettre à un admin de créer une ligne sur un autre
|
||||
site que son `currentSite` (ex: admin multi-site), ajoute aussi le groupe
|
||||
d'écriture :
|
||||
|
||||
```php
|
||||
#[Groups(['supplier:read', 'supplier:write'])]
|
||||
```
|
||||
|
||||
Dans ce cas, `SiteAwareInjectionProcessor` respecte la valeur explicite
|
||||
envoyée par le client (voir §4).
|
||||
|
||||
### 3.4 Processor custom
|
||||
|
||||
Si le module a déjà un processor custom sur les opérations POST/PATCH,
|
||||
assure-toi qu'il délègue à `api_platform.doctrine.orm.state.persist_processor`
|
||||
(et non à `$em->persist()` direct) pour que le decorator
|
||||
`SiteAwareInjectionProcessor` s'applique.
|
||||
|
||||
Pattern aligné sur `UserRbacProcessor` :
|
||||
|
||||
```php
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
// Gardes métier custom ici...
|
||||
|
||||
// Délègue au persist processor décoré : SiteAwareInjectionProcessor
|
||||
// interceptera l'appel et injectera le currentSite si besoin.
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Comportement du processor d'injection
|
||||
|
||||
Le decorator `SiteAwareInjectionProcessor` s'applique automatiquement à
|
||||
**toute** persistance API Platform. Son comportement :
|
||||
|
||||
| Cas | Action |
|
||||
|---|---|
|
||||
| `$data` n'implémente pas `SiteAwareInterface` | Délégation directe (no-op). |
|
||||
| `$data` est `SiteAware` avec `$site` déjà positionné (ex: payload POST avec `site` explicite) | Délégation directe, **la valeur explicite est préservée**. |
|
||||
| `$data` est `SiteAware` sans site, `CurrentSiteProvider::get()` retourne un `Site` | Injection `$data->setSite($currentSite)` puis délégation. |
|
||||
| `$data` est `SiteAware` sans site, `CurrentSiteProvider::get()` retourne `null` | **Throw `BadRequestHttpException`** avec message "aucun site sélectionné". |
|
||||
|
||||
Conséquence : un user sans `currentSite` ne peut **pas** créer de ligne
|
||||
sur une entité `SiteAware`. C'est intentionnel : mieux vaut un 400 clair
|
||||
que persister une ligne incohérente.
|
||||
|
||||
## 5. Comportement en mode dégradé
|
||||
|
||||
### 5.1 Module Sites désactivé
|
||||
|
||||
Si `SitesModule::class` est retiré de `config/modules.php`,
|
||||
`CurrentSiteProvider::get()` retourne **toujours `null`** :
|
||||
|
||||
- `SiteScopedQueryExtension` → no-op. Toutes les lignes visibles, comme
|
||||
si le filtre n'existait pas.
|
||||
- `SiteAwareInjectionProcessor` → **throw 400 sur tout POST/PATCH** sans
|
||||
site explicite. L'écriture d'entités `SiteAware` nécessite que le
|
||||
client envoie systématiquement `site` dans le payload.
|
||||
|
||||
**Conséquence** : un module qui adopte le pattern **ne peut pas vivre**
|
||||
sans le module Sites actif pour les opérations d'écriture. À documenter
|
||||
fortement dans le README du module adopté.
|
||||
|
||||
### 5.2 User sans site (sites = [], currentSite = null)
|
||||
|
||||
Même comportement qu'un module désactivé : lecture no-op (tout visible),
|
||||
écriture bloquée par 400. L'UX doit gérer ce cas (ex: écran d'onboarding
|
||||
qui force l'assignation d'un site avant d'accéder aux écrans métier).
|
||||
|
||||
### 5.3 Bypass admin via `sites.bypass_scope`
|
||||
|
||||
Un utilisateur avec la permission `sites.bypass_scope` (ou admin par
|
||||
bypass total via `isAdmin = true`) voit **toutes** les lignes, tous
|
||||
sites confondus. Pratique pour audit, reporting, consolidation groupe.
|
||||
|
||||
Le processor d'injection ne respecte **pas** ce bypass : même un user
|
||||
avec `bypass_scope` verra son `currentSite` injecté à la création s'il
|
||||
n'envoie pas de `site` explicite. Le bypass est un droit de lecture,
|
||||
pas d'écriture multi-site.
|
||||
|
||||
## 6. Anti-patterns et gotchas
|
||||
|
||||
### 6.1 Sous-collections (`/api/clients/{id}/contacts`)
|
||||
|
||||
Si seul `Client` est `SiteAware` (et `Contact` hérite du scope via son
|
||||
parent), le filtre **ne se propage pas automatiquement** aux contacts.
|
||||
Deux options :
|
||||
|
||||
- Rendre `Contact` aussi `SiteAware` (redondance mais simple).
|
||||
- Ajouter un filtre custom qui joint sur `contact.client.site` et compare
|
||||
au `currentSite`.
|
||||
|
||||
Ce ticket ne couvre pas le second cas : à implémenter par le module
|
||||
concerné.
|
||||
|
||||
### 6.2 Repositories custom
|
||||
|
||||
Le filtre API Platform ne s'applique **qu'aux requêtes** générées par
|
||||
API Platform (via `ItemProvider` / `CollectionProvider` Doctrine). Si un
|
||||
repository custom fait une requête DQL manuelle (ex: `findTopRated()`
|
||||
appelé depuis un service), **aucun filtre n'est appliqué**.
|
||||
|
||||
Responsabilité du développeur du module : injecter `CurrentSiteProvider`
|
||||
dans le repository / service et ajouter manuellement la clause WHERE.
|
||||
|
||||
### 6.3 Tests d'intégration
|
||||
|
||||
Les tests qui persistent des entités `SiteAware` doivent :
|
||||
|
||||
- Soit logger un user avec un `currentSite` positionné (cas nominal).
|
||||
- Soit utiliser un user avec `sites.bypass_scope` pour voir toutes les
|
||||
lignes (cas reporting).
|
||||
- Soit positionner le site **explicitement** sur chaque entité persistée
|
||||
via fixture (bypass du processor d'injection qui n'est pas actif hors
|
||||
contexte HTTP).
|
||||
|
||||
### 6.4 Cascade delete d'un site
|
||||
|
||||
La migration type du §3.2 déclare `onDelete: 'CASCADE'` sur la FK
|
||||
`site_id`. **Conséquence** : supprimer un site détruit **toutes** les
|
||||
lignes de **toutes** les tables `SiteAware` rattachées à ce site, en
|
||||
cascade. Pour un `Supplier`, ça signifie perte de l'historique fournisseur
|
||||
du site supprimé.
|
||||
|
||||
Alternatives selon le besoin métier :
|
||||
|
||||
- `onDelete: 'RESTRICT'` : bloque la suppression du site tant qu'il reste
|
||||
des lignes. L'admin doit nettoyer manuellement avant delete.
|
||||
- `onDelete: 'SET NULL'` : transforme les lignes en "globales" après
|
||||
suppression du site — mais incompatible avec `nullable: false`, donc
|
||||
nécessite de relâcher la contrainte. Généralement à éviter.
|
||||
|
||||
## 7. Exemple d'adoption minimale (pseudo-ticket)
|
||||
|
||||
Pour un futur ticket qui adopte `SiteAwareInterface` sur `Supplier` du
|
||||
module Commercial :
|
||||
|
||||
1. Modifier `src/Module/Commercial/Domain/Entity/Supplier.php` : ajouter
|
||||
`implements SiteAwareInterface` + relation `$site`.
|
||||
2. Créer migration `Version<timestamp>.php` : `ALTER TABLE supplier ADD
|
||||
site_id ...`, backfill, `SET NOT NULL`, `CREATE INDEX`.
|
||||
3. Ajouter `#[Groups(['supplier:read'])]` sur `$site`.
|
||||
4. Mettre à jour les fixtures `CommercialFixtures` pour rattacher chaque
|
||||
supplier à un site (`setSite(...)`).
|
||||
5. Ajouter un test d'intégration qui vérifie que la collection
|
||||
`/api/suppliers` retourne bien uniquement les suppliers du site
|
||||
courant pour un user donné.
|
||||
6. Documenter dans le README du module Commercial que les opérations
|
||||
d'écriture sur Supplier nécessitent le module Sites actif + un user
|
||||
avec `currentSite`.
|
||||
|
||||
## 8. Permission `sites.bypass_scope`
|
||||
|
||||
Déclarée par `SitesModule::permissions()`, synchronisée automatiquement
|
||||
en base par `app:sync-permissions`. Une fois synchronisée, elle est
|
||||
assignable :
|
||||
|
||||
- Directement à un user via `/admin/users` (drawer RBAC, section
|
||||
"Permissions directes").
|
||||
- Via un rôle personnalisé (ex: rôle "Auditeur groupe") qui la porte.
|
||||
|
||||
Les admins l'obtiennent automatiquement par bypass total (`isAdmin`), pas
|
||||
besoin d'assignation explicite.
|
||||
@@ -1,5 +1,44 @@
|
||||
# Ticket #03 — 3/4 — Barre de sélection de site (navbar horizontale)
|
||||
|
||||
## 0. Pivots post-implémentation (2026-04-20)
|
||||
|
||||
Écarts assumés entre la spec initiale (écrite avant exploration de la lib) et
|
||||
le code livré après implémentation et test visuel. À lire en premier pour
|
||||
comprendre les divergences lors de la relecture.
|
||||
|
||||
1. **Contraste texte auto supprimé, texte blanc forcé conforme Figma.**
|
||||
La spec (sections 5, 6, 10) prévoyait un calcul de luminance WCAG pour
|
||||
décider entre texte noir et blanc sur chaque tile. Après test visuel, le
|
||||
choix design retenu est d'imposer **texte blanc partout** (default Malio
|
||||
`text-white font-bold uppercase tracking-wide`). Conséquence : charge à
|
||||
l'admin de choisir des couleurs de site suffisamment foncées pour que le
|
||||
blanc reste lisible. Les utilitaires `parseHex`, `getRelativeLuminance`,
|
||||
`getReadableTextColor` ont été supprimés comme code mort. Seul
|
||||
`isValidSiteColor(hex)` reste dans `shared/utils/color.ts` (consommé par
|
||||
`SiteDrawer`).
|
||||
|
||||
2. **Taille texte explicite `text-2xl` (24 px) appliquée via `labelClass`.**
|
||||
Malio applique `font-bold uppercase tracking-wide` sans taille explicite.
|
||||
Le wrapper `SiteSelector.vue` passe `labelClass="text-2xl"` pour garantir
|
||||
les 24 px de la maquette Figma.
|
||||
|
||||
3. **A11y : `ariaGroupLabel` au niveau radiogroup** au lieu de
|
||||
`ariaLabelActive` / `ariaLabelInactive` par tile. La raison : Malio rend
|
||||
déjà un `role="radio"` avec `aria-checked` par tile — le lecteur d'écran
|
||||
annonce "bouton radio coché/non coché" + le nom visible. Ajouter un
|
||||
`aria-label` par tile aurait dupliqué l'info et alourdi sans bénéfice.
|
||||
Le seul ajout nécessaire était un label au groupe, fait via
|
||||
`:aria-label="t('sites.selector.ariaGroupLabel')"` sur `MalioSiteSelector`.
|
||||
|
||||
4. **Auto-détection composables des layers dans `nuxt.config.ts`.**
|
||||
Pas prévu dans la spec. Ajouté car `imports.dirs` explicite override les
|
||||
auto-imports par défaut de Nuxt pour les composables de layer. Sans ça,
|
||||
`useCurrentSite` n'est pas résolu par Nuxt. Scan dynamique aligné sur le
|
||||
pattern `moduleLayers` existant.
|
||||
|
||||
5. **Couleurs fixtures finales :** `#056CF2` (Châtellerault), `#F3CB00`
|
||||
(Saint-Jean), `#74BF04` (Pommevic). Choix client post-maquette.
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Ce ticket livre l'UI de consommation du module Sites pour l'utilisateur final : une barre horizontale en haut de l'application qui liste les sites autorises de l'utilisateur connecte, met en avant le site courant et permet de basculer d'un site a l'autre en un clic.
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
</MalioSidebar>
|
||||
|
||||
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<SiteSelector v-if="showSiteSelector"/>
|
||||
<main
|
||||
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
||||
<div
|
||||
@@ -30,8 +31,21 @@
|
||||
const {t} = useI18n()
|
||||
const ui = useUiStore()
|
||||
const {sections} = useSidebar()
|
||||
const {isModuleActive} = useModules()
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
|
||||
// Le SiteSelector est rendu si :
|
||||
// - le module Sites est actif dans config/modules.php (sinon la feature
|
||||
// n'a pas de sens, cf. ticket 3 spec criteres d'acceptation) ;
|
||||
// - ET l'user connecte a au moins un site autorise (sinon "barre vide"
|
||||
// sans tile cliquable).
|
||||
// Les deux flags sont resolus par le middleware auth.global.ts avant
|
||||
// que le layout ne soit rendu (plan load parallele), donc pas de flash.
|
||||
const showSiteSelector = computed(() =>
|
||||
isModuleActive('sites') && (auth.user?.sites?.length ?? 0) > 0,
|
||||
)
|
||||
|
||||
const translatedSections = computed(() =>
|
||||
sections.value.map(section => ({
|
||||
label: t(section.label),
|
||||
|
||||
@@ -15,9 +15,16 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
}
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
const { loaded, loadSidebar } = useSidebar()
|
||||
if (!loaded.value) {
|
||||
await loadSidebar()
|
||||
}
|
||||
const { loaded: sidebarLoaded, loadSidebar } = useSidebar()
|
||||
const { loaded: modulesLoaded, loadModules } = useModules()
|
||||
|
||||
// Chargement parallele sidebar + modules actifs : les deux sont
|
||||
// consommes par layouts/default.vue (sidebar pour la nav, modules
|
||||
// pour conditionner le SiteSelector). Charger en parallele evite
|
||||
// le flash au premier paint de la barre.
|
||||
await Promise.all([
|
||||
sidebarLoaded.value ? Promise.resolve() : loadSidebar(),
|
||||
modulesLoaded.value ? Promise.resolve() : loadModules(),
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
},
|
||||
"core": {
|
||||
"roles": "Gestion des rôles",
|
||||
"users": "Utilisateurs"
|
||||
"users": "Utilisateurs",
|
||||
"sites": "Sites"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -54,6 +55,15 @@
|
||||
"put": "Erreur lors de la mise a jour",
|
||||
"patch": "Erreur lors de la modification",
|
||||
"delete": "Erreur lors de la suppression"
|
||||
},
|
||||
"sites": {
|
||||
"notAuthorized": "Vous n'êtes pas autorisé à sélectionner ce site."
|
||||
}
|
||||
},
|
||||
"sites": {
|
||||
"selector": {
|
||||
"ariaGroupLabel": "Sélecteur de site actif",
|
||||
"switchSuccess": "Site courant changé"
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
@@ -102,7 +112,8 @@
|
||||
"username": "Nom d'utilisateur",
|
||||
"admin": "Administrateur",
|
||||
"roles": "Roles",
|
||||
"directPermissions": "Permissions directes"
|
||||
"directPermissions": "Permissions directes",
|
||||
"sites": "Sites"
|
||||
},
|
||||
"drawer": {
|
||||
"title": "Permissions de {username}",
|
||||
@@ -110,6 +121,7 @@
|
||||
"adminToggle": "Administrateur (bypass total)",
|
||||
"rolesSection": "Rôles",
|
||||
"directPermissionsSection": "Permissions directes",
|
||||
"sitesSection": "Sites autorisés",
|
||||
"summarySection": "Résumé des permissions effectives",
|
||||
"noEffectivePermissions": "Aucune permission effective",
|
||||
"sourceRole": "via {role}",
|
||||
@@ -119,6 +131,39 @@
|
||||
"toast": {
|
||||
"updated": "Permissions mises à jour avec succès"
|
||||
}
|
||||
},
|
||||
"sites": {
|
||||
"title": "Gestion des sites",
|
||||
"newSite": "Nouveau site",
|
||||
"editSite": "Modifier le site",
|
||||
"createSite": "Créer un site",
|
||||
"noSites": "Aucun site configuré",
|
||||
"table": {
|
||||
"name": "Nom",
|
||||
"city": "Ville",
|
||||
"postalCode": "Code postal",
|
||||
"color": "Couleur",
|
||||
"fullAddress": "Adresse complète"
|
||||
},
|
||||
"form": {
|
||||
"name": "Nom",
|
||||
"street": "Rue",
|
||||
"complement": "Complément d'adresse",
|
||||
"complementPlaceholder": "Bâtiment, escalier, BP... (optionnel)",
|
||||
"postalCode": "Code postal",
|
||||
"city": "Ville",
|
||||
"color": "Couleur (format #RRGGBB)",
|
||||
"colorInvalid": "Format attendu : #RRGGBB (6 caractères hexadécimaux)"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Supprimer le site",
|
||||
"message": "Êtes-vous sûr de vouloir supprimer le site \"{name}\" ? Cette action est irréversible et retirera ce site à tous les utilisateurs rattachés."
|
||||
},
|
||||
"toast": {
|
||||
"created": "Site créé avec succès",
|
||||
"updated": "Site mis à jour avec succès",
|
||||
"deleted": "Site supprimé avec succès"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Sites autorises (ticket 2 module Sites) -->
|
||||
<div>
|
||||
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
|
||||
{{ t('admin.users.drawer.sitesSection') }}
|
||||
</h4>
|
||||
<div v-if="allSites.length === 0" class="text-sm text-neutral-400">
|
||||
{{ t('admin.sites.noSites') }}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<MalioCheckbox
|
||||
v-for="site in allSites"
|
||||
:id="`site-${site.id}`"
|
||||
:key="site.id"
|
||||
:label="site.name"
|
||||
:model-value="selectedSiteIds.has(site.id)"
|
||||
label-class="text-sm text-neutral-600"
|
||||
@update:model-value="(val: boolean) => toggleSite(site.id, val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Resume permissions effectives -->
|
||||
<div>
|
||||
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
|
||||
@@ -92,6 +113,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Permission, Role, UserListItem, EffectivePermission } from '~/shared/types/rbac'
|
||||
import type { Site } from '~/shared/types/sites'
|
||||
|
||||
interface PermissionModule {
|
||||
module: string
|
||||
@@ -115,10 +137,12 @@ const emit = defineEmits<{
|
||||
const saving = ref(false)
|
||||
const allRoles = ref<Role[]>([])
|
||||
const allPermissions = ref<Permission[]>([])
|
||||
const allSites = ref<Site[]>([])
|
||||
|
||||
const form = ref({ isAdmin: false })
|
||||
const selectedRoleIds = ref(new Set<number>())
|
||||
const selectedDirectPermissionIds = ref(new Set<number>())
|
||||
const selectedSiteIds = ref(new Set<number>())
|
||||
|
||||
// Detecter l'auto-edition
|
||||
const isSelfEdit = computed(() => props.user?.id === auth.user?.id)
|
||||
@@ -182,14 +206,17 @@ const effectivePermissions = computed<EffectivePermission[]>(() => {
|
||||
.sort((a, b) => a.code.localeCompare(b.code))
|
||||
})
|
||||
|
||||
// Charger roles et permissions
|
||||
// Charger roles, permissions et sites en parallele pour minimiser le TTFB
|
||||
// a l'ouverture du drawer.
|
||||
async function loadData() {
|
||||
const [rolesData, permsData] = await Promise.all([
|
||||
const [rolesData, permsData, sitesData] = await Promise.all([
|
||||
api.get<{ member: Role[] }>('/roles', {}, { toast: false }),
|
||||
api.get<{ member: Permission[] }>('/permissions', { orphan: false, itemsPerPage: 999 }, { toast: false }),
|
||||
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false }),
|
||||
])
|
||||
allRoles.value = rolesData.member
|
||||
allPermissions.value = permsData.member
|
||||
allSites.value = sitesData.member
|
||||
}
|
||||
|
||||
// Remplir le formulaire quand le user change
|
||||
@@ -198,10 +225,12 @@ watch(() => props.user, (user) => {
|
||||
form.value.isAdmin = user.isAdmin
|
||||
selectedRoleIds.value = new Set(user.roles.map(iriToId))
|
||||
selectedDirectPermissionIds.value = new Set(user.directPermissions.map(iriToId))
|
||||
selectedSiteIds.value = new Set((user.sites ?? []).map(iriToId))
|
||||
} else {
|
||||
form.value.isAdmin = false
|
||||
selectedRoleIds.value = new Set()
|
||||
selectedDirectPermissionIds.value = new Set()
|
||||
selectedSiteIds.value = new Set()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
@@ -235,6 +264,13 @@ function handleToggleAll(module: string, selected: boolean) {
|
||||
selectedDirectPermissionIds.value = ids
|
||||
}
|
||||
|
||||
function toggleSite(id: number, selected: boolean) {
|
||||
const ids = new Set(selectedSiteIds.value)
|
||||
if (selected) ids.add(id)
|
||||
else ids.delete(id)
|
||||
selectedSiteIds.value = ids
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!props.user) return
|
||||
saving.value = true
|
||||
@@ -243,6 +279,7 @@ async function handleSave() {
|
||||
isAdmin: form.value.isAdmin,
|
||||
roles: Array.from(selectedRoleIds.value).map(id => `/api/roles/${id}`),
|
||||
directPermissions: Array.from(selectedDirectPermissionIds.value).map(id => `/api/permissions/${id}`),
|
||||
sites: Array.from(selectedSiteIds.value).map(id => `/api/sites/${id}`),
|
||||
}, {
|
||||
toastSuccessMessage: t('admin.users.toast.updated'),
|
||||
})
|
||||
|
||||
@@ -14,16 +14,68 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Table des roles -->
|
||||
<!-- Table des roles avec filtres + pagination -->
|
||||
<MalioDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
class="mt-6"
|
||||
:columns="columns"
|
||||
:items="roleItems"
|
||||
:total-items="roles.length"
|
||||
:total-items="totalItems"
|
||||
:row-clickable="canManage"
|
||||
:empty-message="t('admin.roles.noRoles')"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<template #header-label>
|
||||
<input
|
||||
v-model="filters.label"
|
||||
type="text"
|
||||
:placeholder="t('admin.roles.table.label')"
|
||||
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
|
||||
>
|
||||
</template>
|
||||
<template #header-code>
|
||||
<input
|
||||
v-model="filters.code"
|
||||
type="text"
|
||||
:placeholder="t('admin.roles.table.code')"
|
||||
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
|
||||
>
|
||||
</template>
|
||||
<template #header-permissions>
|
||||
<select
|
||||
v-model="filters['permissions.code']"
|
||||
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
|
||||
>
|
||||
<option value="">
|
||||
{{ t('admin.roles.table.permissions') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="perm in allPermissions"
|
||||
:key="perm.id"
|
||||
:value="perm.code"
|
||||
>
|
||||
{{ perm.label }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
<template #header-system>
|
||||
<select
|
||||
v-model="filters.isSystem"
|
||||
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
|
||||
>
|
||||
<option value="">
|
||||
{{ t('admin.roles.table.system') }}
|
||||
</option>
|
||||
<option value="true">
|
||||
{{ t('common.yes') }}
|
||||
</option>
|
||||
<option value="false">
|
||||
{{ t('common.no') }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<template #cell-code="{ item }">
|
||||
<span class="font-mono text-xs">{{ item.code }}</span>
|
||||
</template>
|
||||
@@ -59,7 +111,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Role } from '~/shared/types/rbac'
|
||||
import type { Permission, Role } from '~/shared/types/rbac'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
@@ -68,8 +120,42 @@ const canManage = computed(() => can('core.roles.manage'))
|
||||
|
||||
useHead({ title: t('admin.roles.title') })
|
||||
|
||||
const roles = ref<Role[]>([])
|
||||
const loading = ref(false)
|
||||
// Etat DataTable centralise : pagination serveur + filtres debounces.
|
||||
// `isSystem` est une string ('true'/'false'/'') plutot qu'un bool : les
|
||||
// <select> HTML travaillent en string et API Platform BooleanFilter
|
||||
// accepte les strings 'true'/'false' telles quelles.
|
||||
const {
|
||||
items,
|
||||
totalItems,
|
||||
page,
|
||||
perPage,
|
||||
filters,
|
||||
reload,
|
||||
} = useDataTableServerState<Role>('/roles', {
|
||||
label: '',
|
||||
code: '',
|
||||
isSystem: '',
|
||||
'permissions.code': '',
|
||||
})
|
||||
|
||||
// Chargement one-shot des permissions pour alimenter le select filter.
|
||||
// Independant du composable de table : cette liste ne bouge pas pendant
|
||||
// la session admin.
|
||||
const allPermissions = ref<Permission[]>([])
|
||||
|
||||
async function loadPermissions(): Promise<void> {
|
||||
const data = await api.get<{ member: Permission[] }>(
|
||||
'/permissions',
|
||||
{ itemsPerPage: 999, orphan: false },
|
||||
{ toast: false },
|
||||
)
|
||||
// Tri par label pour coherence avec l'affichage du <option> : l'user
|
||||
// lit le label (ex: "Gerer les roles et permissions"), donc l'ordre
|
||||
// alphabetique doit etre base sur ce qu'il voit, pas sur le code.
|
||||
allPermissions.value = (data.member ?? []).sort(
|
||||
(a, b) => a.label.localeCompare(b.label),
|
||||
)
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ key: 'label', label: t('admin.roles.table.label') },
|
||||
@@ -80,45 +166,31 @@ const columns = [
|
||||
|
||||
// Transformer les roles en items compatibles MalioDataTable
|
||||
const roleItems = computed(() =>
|
||||
roles.value.map(role => ({
|
||||
items.value.map(role => ({
|
||||
id: role.id,
|
||||
label: role.label,
|
||||
code: role.code,
|
||||
permissions: role.permissions.length,
|
||||
isSystem: role.isSystem,
|
||||
system: '', // colonne geree par le slot
|
||||
}))
|
||||
})),
|
||||
)
|
||||
|
||||
function getRoleById(id: number): Role | undefined {
|
||||
return roles.value.find(r => r.id === id)
|
||||
return items.value.find(r => r.id === id)
|
||||
}
|
||||
|
||||
function onRowClick(item: Record<string, unknown>) {
|
||||
const role = getRoleById(item.id as number)
|
||||
if (role) openEditDrawer(role)
|
||||
}
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
const selectedRole = ref<Role | null>(null)
|
||||
const deleteModalOpen = ref(false)
|
||||
const roleToDelete = ref<Role | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
// Charger la liste des roles
|
||||
async function loadRoles() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ member: Role[] }>(
|
||||
'/roles',
|
||||
{},
|
||||
{ toast: false },
|
||||
)
|
||||
roles.value = data.member
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
selectedRole.value = null
|
||||
drawerOpen.value = true
|
||||
@@ -145,17 +217,18 @@ async function handleDelete() {
|
||||
deleteModalOpen.value = false
|
||||
roleToDelete.value = null
|
||||
drawerOpen.value = false
|
||||
await loadRoles()
|
||||
reload()
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onRoleSaved() {
|
||||
loadRoles()
|
||||
reload()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadRoles()
|
||||
loadPermissions()
|
||||
reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -7,16 +7,77 @@
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Table des utilisateurs -->
|
||||
<!-- Table des utilisateurs avec filtres + pagination -->
|
||||
<MalioDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
class="mt-6"
|
||||
:columns="columns"
|
||||
:items="userItems"
|
||||
:total-items="users.length"
|
||||
:total-items="totalItems"
|
||||
:row-clickable="canManage"
|
||||
:empty-message="t('admin.users.noUsers')"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<template #header-username>
|
||||
<input
|
||||
v-model="filters.username"
|
||||
type="text"
|
||||
:placeholder="t('admin.users.table.username')"
|
||||
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
|
||||
>
|
||||
</template>
|
||||
<template #header-admin>
|
||||
<select
|
||||
v-model="filters.isAdmin"
|
||||
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
|
||||
>
|
||||
<option value="">
|
||||
{{ t('admin.users.table.admin') }}
|
||||
</option>
|
||||
<option value="true">
|
||||
{{ t('common.yes') }}
|
||||
</option>
|
||||
<option value="false">
|
||||
{{ t('common.no') }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
<template #header-roles>
|
||||
<select
|
||||
v-model="filters['rbacRoles.code']"
|
||||
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
|
||||
>
|
||||
<option value="">
|
||||
{{ t('admin.users.table.roles') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="role in allRoles"
|
||||
:key="role.id"
|
||||
:value="role.code"
|
||||
>
|
||||
{{ role.label }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
<template v-if="sitesModuleActive" #header-sites>
|
||||
<select
|
||||
v-model="filters['sites.name']"
|
||||
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
|
||||
>
|
||||
<option value="">
|
||||
{{ t('admin.users.table.sites') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="site in allSites"
|
||||
:key="site.id"
|
||||
:value="site.name"
|
||||
>
|
||||
{{ site.name }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<template #cell-admin="{ item }">
|
||||
<span
|
||||
v-if="item.admin"
|
||||
@@ -37,54 +98,105 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UserListItem } from '~/shared/types/rbac'
|
||||
import type { Role, UserListItem } from '~/shared/types/rbac'
|
||||
import type { Site } from '~/shared/types/sites'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const { can } = usePermissions()
|
||||
const { isModuleActive } = useModules()
|
||||
|
||||
useHead({ title: t('admin.users.title') })
|
||||
|
||||
const canManage = computed(() => can('core.users.manage'))
|
||||
// Conditionne la colonne Sites + le filtre Sites : si le module Sites
|
||||
// est desactive, inutile de charger /api/sites ni d'afficher ces elements.
|
||||
// L'invariant "module inactif = app fonctionnelle" est preserve.
|
||||
const sitesModuleActive = computed(() => isModuleActive('sites'))
|
||||
|
||||
const users = ref<UserListItem[]>([])
|
||||
const loading = ref(false)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedUser = ref<UserListItem | null>(null)
|
||||
// Etat DataTable centralise. On declare le filtre sites.name meme si le
|
||||
// module Sites est inactif : le composable omet les filtres a valeur
|
||||
// vide donc ca ne produit aucun impact cote API, et ca evite de casser
|
||||
// la forme du state si le module est reactive sans reloader la page.
|
||||
const {
|
||||
items,
|
||||
totalItems,
|
||||
page,
|
||||
perPage,
|
||||
filters,
|
||||
reload,
|
||||
} = useDataTableServerState<UserListItem>('/users', {
|
||||
username: '',
|
||||
isAdmin: '',
|
||||
'rbacRoles.code': '',
|
||||
'sites.name': '',
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ key: 'username', label: t('admin.users.table.username') },
|
||||
{ key: 'admin', label: t('admin.users.table.admin') },
|
||||
{ key: 'roles', label: t('admin.users.table.roles') },
|
||||
{ key: 'directPermissions', label: t('admin.users.table.directPermissions') },
|
||||
]
|
||||
const allRoles = ref<Role[]>([])
|
||||
const allSites = ref<Site[]>([])
|
||||
const sitesById = ref(new Map<number, Site>())
|
||||
|
||||
async function loadFilterOptions(): Promise<void> {
|
||||
const rolesPromise = api.get<{ member: Role[] }>(
|
||||
'/roles',
|
||||
{ itemsPerPage: 999 },
|
||||
{ toast: false },
|
||||
)
|
||||
|
||||
// /api/sites est protege par `sites.view`. On skip si module off pour
|
||||
// eviter un 403 inutile dans la console devtools — la UI ne consomme
|
||||
// pas le resultat dans ce cas.
|
||||
const sitesPromise = sitesModuleActive.value
|
||||
? api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false })
|
||||
: Promise.resolve({ member: [] as Site[] })
|
||||
|
||||
const [rolesData, sitesData] = await Promise.all([rolesPromise, sitesPromise])
|
||||
allRoles.value = rolesData.member ?? []
|
||||
allSites.value = sitesData.member ?? []
|
||||
sitesById.value = new Map(allSites.value.map(s => [s.id, s]))
|
||||
}
|
||||
|
||||
// Colonnes dynamiques : on omet la colonne Sites si le module est off.
|
||||
const columns = computed(() => {
|
||||
const base = [
|
||||
{ key: 'username', label: t('admin.users.table.username') },
|
||||
{ key: 'admin', label: t('admin.users.table.admin') },
|
||||
{ key: 'roles', label: t('admin.users.table.roles') },
|
||||
{ key: 'directPermissions', label: t('admin.users.table.directPermissions') },
|
||||
]
|
||||
if (sitesModuleActive.value) {
|
||||
base.push({ key: 'sites', label: t('admin.users.table.sites') })
|
||||
}
|
||||
return base
|
||||
})
|
||||
|
||||
// Extraire l'id numerique depuis une IRI API Platform type `/api/sites/3`.
|
||||
function iriToId(iri: string): number {
|
||||
return Number(iri.split('/').pop())
|
||||
}
|
||||
|
||||
const userItems = computed(() =>
|
||||
users.value.map(user => ({
|
||||
items.value.map(user => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
admin: user.isAdmin,
|
||||
roles: user.roles.length,
|
||||
directPermissions: user.directPermissions.length,
|
||||
}))
|
||||
// Affichage : liste des noms de sites separes par virgule. Les IRIs
|
||||
// du payload /api/users (groupe user:list) sont resolues via la Map
|
||||
// construite par loadFilterOptions. Vide si module Sites off.
|
||||
sites: (user.sites ?? [])
|
||||
.map(iri => sitesById.value.get(iriToId(iri))?.name)
|
||||
.filter((name): name is string => Boolean(name))
|
||||
.join(', '),
|
||||
})),
|
||||
)
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ member: UserListItem[] }>(
|
||||
'/users',
|
||||
{},
|
||||
{ toast: false },
|
||||
)
|
||||
users.value = data.member
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const drawerOpen = ref(false)
|
||||
const selectedUser = ref<UserListItem | null>(null)
|
||||
|
||||
function getUserById(id: number): UserListItem | undefined {
|
||||
return users.value.find(u => u.id === id)
|
||||
return items.value.find(u => u.id === id)
|
||||
}
|
||||
|
||||
function openDrawer(user: UserListItem) {
|
||||
@@ -98,10 +210,11 @@ function onRowClick(item: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
function onUserSaved() {
|
||||
loadUsers()
|
||||
reload()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
loadFilterOptions()
|
||||
reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -9,10 +9,23 @@ definePageMeta({ layout: 'auth' })
|
||||
|
||||
const auth = useAuthStore()
|
||||
const { resetSidebar } = useSidebar()
|
||||
const { resetModules } = useModules()
|
||||
const { resetCurrentSite } = useCurrentSite()
|
||||
|
||||
onMounted(async () => {
|
||||
await auth.logout()
|
||||
resetSidebar()
|
||||
await navigateTo('/login')
|
||||
try {
|
||||
await auth.logout()
|
||||
} finally {
|
||||
// Les resets sont garantis meme si auth.logout() rejette : eviter
|
||||
// qu'un user suivant (connecte sur le meme onglet) voie l'etat de
|
||||
// l'ancien. Les trois fonctions reset sont synchrones et ne
|
||||
// peuvent pas throw (juste des assignations reactives).
|
||||
// navigateTo est dans le finally pour garantir la redirection
|
||||
// meme si auth.logout() lance une exception (ex: reseau coupé).
|
||||
resetSidebar()
|
||||
resetModules()
|
||||
resetCurrentSite()
|
||||
await navigateTo('/login')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
76
frontend/modules/sites/components/SiteDeleteModal.vue
Normal file
76
frontend/modules/sites/components/SiteDeleteModal.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
@click.self="cancel"
|
||||
>
|
||||
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-semibold text-neutral-900">
|
||||
{{ t('admin.sites.delete.title') }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-neutral-600">
|
||||
{{ t('admin.sites.delete.message', { name: siteName }) }}
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
:label="t('common.cancel')"
|
||||
variant="secondary"
|
||||
@click="cancel"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
:disabled="loading"
|
||||
@click="confirm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
siteName: string
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
confirm: []
|
||||
}>()
|
||||
|
||||
function cancel() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
emit('confirm')
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') cancel()
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', onKeydown))
|
||||
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
185
frontend/modules/sites/components/SiteDrawer.vue
Normal file
185
frontend/modules/sites/components/SiteDrawer.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<MalioDrawer
|
||||
:model-value="modelValue"
|
||||
:title="isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite')"
|
||||
drawer-class="w-full max-w-lg"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
:label="t('admin.sites.form.name')"
|
||||
input-class="w-full"
|
||||
required
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-model="form.street"
|
||||
:label="t('admin.sites.form.street')"
|
||||
input-class="w-full"
|
||||
required
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-model="form.complement"
|
||||
:label="t('admin.sites.form.complement')"
|
||||
:placeholder="t('admin.sites.form.complementPlaceholder')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<!-- Code postal FR : masque "#####" (5 chiffres stricts) +
|
||||
maxLength en double securite. La regex backend validera la
|
||||
forme finale, le masque empeche juste la saisie de
|
||||
caracteres non numeriques. -->
|
||||
<MalioInputText
|
||||
v-model="form.postalCode"
|
||||
:label="t('admin.sites.form.postalCode')"
|
||||
input-class="w-full"
|
||||
mask="#####"
|
||||
max-length="5"
|
||||
required
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-model="form.city"
|
||||
:label="t('admin.sites.form.city')"
|
||||
input-class="w-full"
|
||||
required
|
||||
/>
|
||||
|
||||
<!-- Champ couleur avec preview puce -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">
|
||||
{{ t('admin.sites.form.color') }}
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<MalioInputText
|
||||
v-model="form.color"
|
||||
placeholder="#RRGGBB"
|
||||
input-class="w-full font-mono"
|
||||
required
|
||||
/>
|
||||
<span
|
||||
:style="{ backgroundColor: isValidHex ? form.color : 'transparent' }"
|
||||
class="inline-block size-10 shrink-0 rounded-lg border border-neutral-200"
|
||||
:class="{ 'border-dashed': !isValidHex }"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="form.color && !isValidHex" class="mt-1 text-xs text-red-600">
|
||||
{{ t('admin.sites.form.colorInvalid') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Boutons -->
|
||||
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
|
||||
<MalioButton
|
||||
v-if="isEditMode"
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
<MalioButton
|
||||
v-else
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
:disabled="saving || !isValidHex"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Site } from '~/shared/types/sites'
|
||||
import { isValidSiteColor } from '~/shared/utils/color'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
site: Site | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
saved: []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
street: '',
|
||||
complement: '',
|
||||
postalCode: '',
|
||||
city: '',
|
||||
color: '#000000',
|
||||
})
|
||||
|
||||
const isEditMode = computed(() => props.site !== null)
|
||||
|
||||
// Validation locale du format hex #RRGGBB avant envoi backend.
|
||||
const isValidHex = computed(() => isValidSiteColor(form.value.color))
|
||||
|
||||
// Remplir le formulaire quand le site change
|
||||
watch(() => props.site, (site) => {
|
||||
if (site) {
|
||||
form.value.name = site.name
|
||||
form.value.street = site.street
|
||||
form.value.complement = site.complement ?? ''
|
||||
form.value.postalCode = site.postalCode
|
||||
form.value.city = site.city
|
||||
form.value.color = site.color
|
||||
} else {
|
||||
form.value.name = ''
|
||||
form.value.street = ''
|
||||
form.value.complement = ''
|
||||
form.value.postalCode = ''
|
||||
form.value.city = ''
|
||||
form.value.color = '#056CF2'
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
async function handleSave() {
|
||||
if (!isValidHex.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
// Le champ complement est optionnel cote DB : on envoie null si vide
|
||||
// pour que le backend stocke NULL plutot qu'une chaine vide.
|
||||
const trimmedComplement = form.value.complement.trim()
|
||||
const payload = {
|
||||
name: form.value.name,
|
||||
street: form.value.street,
|
||||
complement: trimmedComplement === '' ? null : trimmedComplement,
|
||||
postalCode: form.value.postalCode,
|
||||
city: form.value.city,
|
||||
color: form.value.color,
|
||||
}
|
||||
|
||||
if (isEditMode.value && props.site) {
|
||||
await api.patch(`/sites/${props.site.id}`, payload, {
|
||||
toastSuccessMessage: t('admin.sites.toast.updated'),
|
||||
})
|
||||
} else {
|
||||
await api.post('/sites', payload, {
|
||||
toastSuccessMessage: t('admin.sites.toast.created'),
|
||||
})
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
92
frontend/modules/sites/components/SiteSelector.vue
Normal file
92
frontend/modules/sites/components/SiteSelector.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<MalioSiteSelector
|
||||
:sites="mappedSites"
|
||||
:model-value="currentSite ? String(currentSite.id) : undefined"
|
||||
:group-class="groupClass"
|
||||
:tile-class="tileClass"
|
||||
:label-class="labelClass"
|
||||
:aria-label="t('sites.selector.ariaGroupLabel')"
|
||||
@change="onChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
const { currentSite, availableSites, syncFromAuth, switchSite } = useCurrentSite()
|
||||
const auth = useAuthStore()
|
||||
|
||||
// Hydratation initiale + watcher : garde le state aligne sur auth.user
|
||||
// meme si un autre composant modifie auth.user.currentSite (ex: switch
|
||||
// depuis un autre onglet via /api/me/current-site, ou refresh du token).
|
||||
// Le rollback de switchSite restaure AUSSI auth.user.currentSite (voir
|
||||
// useCurrentSite::switchSite) pour eviter tout cycle watchEffect -> sync
|
||||
// qui ecraserait l'etat local apres une erreur PATCH.
|
||||
watchEffect(() => {
|
||||
void auth.user?.currentSite
|
||||
void auth.user?.sites
|
||||
syncFromAuth()
|
||||
})
|
||||
|
||||
// Conversion id number -> string : l'API de MalioSiteSelector (v1.4.0)
|
||||
// travaille en string alors que notre type metier Site utilise un int
|
||||
// (ID Doctrine). On reconvertit dans onChange.
|
||||
const mappedSites = computed(() =>
|
||||
availableSites.value.map(site => ({
|
||||
id: String(site.id),
|
||||
name: site.name,
|
||||
color: site.color,
|
||||
})),
|
||||
)
|
||||
|
||||
// Note de rendu : MalioSiteSelector v1.4.0 utilise UNE SEULE `activeColor`
|
||||
// (couleur du site courant) comme fond pour TOUS les tiles. Les inactifs
|
||||
// sont differencies uniquement par `opacity: 0.4`. Le texte est TOUJOURS
|
||||
// blanc (conforme maquette Figma) — charge aux admins de choisir des
|
||||
// couleurs de site suffisamment foncees pour garantir la lisibilite.
|
||||
// On surcharge `labelClass` uniquement pour imposer la taille 24px
|
||||
// (Figma), le reste des attributs tex (blanc, bold, uppercase, tracking)
|
||||
// vient du default Malio via twMerge.
|
||||
|
||||
// Classes Tailwind passees a MalioSiteSelector via twMerge :
|
||||
// - groupClass : hauteur fixe 72px (spec Figma) + scroll horizontal si
|
||||
// debordement de 4+ sites sur petits ecrans.
|
||||
// - tileClass : largeur minimale pour lisibilite + focus ring WCAG.
|
||||
// - labelClass : taille de texte 24px imposee par la maquette Figma.
|
||||
// Tailwind `text-2xl` = 1.5rem = 24px. Merge avec le default Malio
|
||||
// (`text-white font-bold uppercase tracking-wide`).
|
||||
const groupClass = 'h-[72px] overflow-x-auto'
|
||||
const tileClass = 'min-w-[200px] flex items-center justify-center focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2'
|
||||
const labelClass = 'text-2xl'
|
||||
|
||||
async function onChange(site: { id: string; name: string; color: string }): Promise<void> {
|
||||
const target = availableSites.value.find(s => String(s.id) === site.id)
|
||||
if (!target) {
|
||||
// Divergence entre mappedSites et availableSites (peut arriver si
|
||||
// un refresh concurrent a vide la collection). On ignore mais on
|
||||
// trace en dev pour faciliter le debug.
|
||||
if (import.meta.dev) {
|
||||
// Utilise console.error (pas warn) car la convention projet
|
||||
// eslint n'autorise que error (no-console avec allow: ['error']).
|
||||
console.error(`[SiteSelector] Site inconnu emis par MalioSiteSelector : id=${site.id}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// TODO(cross-tab) : si l'utilisateur a change de site dans un autre
|
||||
// onglet, currentSite.value ici peut etre obsolete (state singleton
|
||||
// non synchronise entre onglets). La garde ci-dessous est donc
|
||||
// intentionnellement supprimee pour garantir qu'un clic sur le tile
|
||||
// "actif selon cet onglet" envoie quand meme le PATCH et re-synchronise
|
||||
// l'etat. Amelioration future : ecouter l'evenement `storage` sur la
|
||||
// cle `coltura:site-switch` pour mettre a jour les onglets inactifs
|
||||
// sans clic via auth.fetchUser() / auth.refreshUser().
|
||||
|
||||
try {
|
||||
await switchSite(target)
|
||||
} catch {
|
||||
// L'erreur est deja toastee par useApi ; le composable a rollback
|
||||
// le state local ET le store auth. Rien a faire ici au-dela de
|
||||
// silencer pour eviter une unhandledRejection dans la console.
|
||||
}
|
||||
}
|
||||
</script>
|
||||
189
frontend/modules/sites/components/__tests__/SiteSelector.spec.ts
Normal file
189
frontend/modules/sites/components/__tests__/SiteSelector.spec.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { computed, defineComponent, h, ref, watchEffect } from 'vue'
|
||||
import type { Site } from '~/shared/types/sites'
|
||||
import { useCurrentSite } from '~/modules/sites/composables/useCurrentSite'
|
||||
import SiteSelector from '../SiteSelector.vue'
|
||||
|
||||
const mockPatch = vi.hoisted(() => vi.fn())
|
||||
const mockAuthUser = vi.hoisted(() => ({
|
||||
value: null as { sites: Site[]; currentSite: Site | null } | null,
|
||||
}))
|
||||
|
||||
// Stubs des auto-imports Nuxt. SiteSelector.vue utilise useCurrentSite,
|
||||
// useAuthStore, useI18n, watchEffect, computed sans import explicite
|
||||
// (pattern Nuxt). En Vitest on les expose comme globals.
|
||||
vi.stubGlobal('useCurrentSite', useCurrentSite)
|
||||
vi.stubGlobal('useApi', () => ({ patch: mockPatch }))
|
||||
vi.stubGlobal('useAuthStore', () => ({
|
||||
get user() {
|
||||
return mockAuthUser.value
|
||||
},
|
||||
setCurrentSite(site: Site | null) {
|
||||
if (mockAuthUser.value) {
|
||||
mockAuthUser.value.currentSite = site
|
||||
}
|
||||
},
|
||||
}))
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('watchEffect', watchEffect)
|
||||
vi.stubGlobal('computed', computed)
|
||||
vi.stubGlobal('ref', ref)
|
||||
// useSidebar et refreshNuxtData sont consommes par useCurrentSite apres
|
||||
// un switch reussi — stubs minimaux pour eviter ReferenceError au mount.
|
||||
vi.stubGlobal('useSidebar', () => ({ loadSidebar: vi.fn() }))
|
||||
vi.stubGlobal('refreshNuxtData', vi.fn())
|
||||
|
||||
// Stub de MalioSiteSelector : on se contente de tracker les props recues
|
||||
// et de re-emettre `change` quand on le simule via `trigger`. Evite de
|
||||
// monter la vraie lib Malio (qui aurait besoin de tout Tailwind + twMerge).
|
||||
const MalioSiteSelectorStub = defineComponent({
|
||||
name: 'MalioSiteSelector',
|
||||
props: {
|
||||
sites: { type: Array, required: true },
|
||||
modelValue: { type: String, default: undefined },
|
||||
groupClass: { type: String, default: '' },
|
||||
tileClass: { type: String, default: '' },
|
||||
labelClass: { type: String, default: '' },
|
||||
},
|
||||
emits: ['update:modelValue', 'change'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('div', {
|
||||
'data-testid': 'malio-site-selector',
|
||||
'data-sites-count': String((props.sites as unknown[]).length),
|
||||
'data-active-id': String(props.modelValue ?? ''),
|
||||
'data-label-class': props.labelClass,
|
||||
}, [
|
||||
...(props.sites as Array<{ id: string; name: string; color: string }>).map(site =>
|
||||
h('button', {
|
||||
'data-testid': `tile-${site.id}`,
|
||||
// Emet les deux events comme le vrai MalioSiteSelector
|
||||
// (update:modelValue + change). Le wrapper n'ecoute que
|
||||
// change aujourd'hui, mais tracker les deux grave la
|
||||
// signature et prepare un eventuel v-model futur.
|
||||
onClick: () => {
|
||||
emit('update:modelValue', site.id)
|
||||
emit('change', site)
|
||||
},
|
||||
}, site.name),
|
||||
),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const SITE_A: Site = {
|
||||
id: 1,
|
||||
name: 'Chatellerault',
|
||||
street: '14 All.',
|
||||
complement: null,
|
||||
postalCode: '86100',
|
||||
city: 'Châtellerault',
|
||||
color: '#056CF2',
|
||||
fullAddress: '14 All.\n86100 Châtellerault',
|
||||
}
|
||||
const SITE_B: Site = {
|
||||
id: 2,
|
||||
name: 'Saint-Jean',
|
||||
street: 'Z i',
|
||||
complement: null,
|
||||
postalCode: '17400',
|
||||
city: 'Fontenet',
|
||||
color: '#F3CB00',
|
||||
fullAddress: 'Z i\n17400 Fontenet',
|
||||
}
|
||||
|
||||
function mountSelector() {
|
||||
return mount(SiteSelector, {
|
||||
global: {
|
||||
stubs: { MalioSiteSelector: MalioSiteSelectorStub },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('SiteSelector', () => {
|
||||
beforeEach(() => {
|
||||
mockPatch.mockReset()
|
||||
mockAuthUser.value = {
|
||||
sites: [SITE_A, SITE_B],
|
||||
currentSite: SITE_A,
|
||||
}
|
||||
})
|
||||
|
||||
it('rend un tile par site autorise', () => {
|
||||
const wrapper = mountSelector()
|
||||
const stub = wrapper.find('[data-testid="malio-site-selector"]')
|
||||
|
||||
expect(stub.attributes('data-sites-count')).toBe('2')
|
||||
})
|
||||
|
||||
it('marque le site courant via modelValue (string)', () => {
|
||||
const wrapper = mountSelector()
|
||||
const stub = wrapper.find('[data-testid="malio-site-selector"]')
|
||||
|
||||
// Chatellerault id=1 => '1'
|
||||
expect(stub.attributes('data-active-id')).toBe('1')
|
||||
})
|
||||
|
||||
it('passe labelClass="text-2xl" pour forcer 24px conforme Figma', () => {
|
||||
// Decision design : texte blanc par defaut Malio mais taille 24px
|
||||
// imposee par la maquette. Le reste des attributs text (white, bold,
|
||||
// uppercase, tracking-wide) provient du default Malio via twMerge.
|
||||
const wrapper = mountSelector()
|
||||
const stub = wrapper.find('[data-testid="malio-site-selector"]')
|
||||
|
||||
expect(stub.attributes('data-label-class')).toBe('text-2xl')
|
||||
})
|
||||
|
||||
it('clic sur un tile inactif declenche switchSite via PATCH /me/current-site', async () => {
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const wrapper = mountSelector()
|
||||
|
||||
await wrapper.find('[data-testid="tile-2"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/me/current-site',
|
||||
{ site: '/api/sites/2' },
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('clic sur le tile deja actif declenche un PATCH (resync cross-tab)', async () => {
|
||||
// Le court-circuit "si deja actif, ne rien faire" a ete supprime
|
||||
// pour couvrir le cas ou un autre onglet a modifie le site courant
|
||||
// cote serveur : un clic sur la tile localement "active" (etat
|
||||
// potentiellement stale) force une resync via PATCH. Le prix est un
|
||||
// PATCH superflu quand l'etat local est effectivement a jour.
|
||||
const wrapper = mountSelector()
|
||||
|
||||
await wrapper.find('[data-testid="tile-1"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/me/current-site',
|
||||
{ site: '/api/sites/1' },
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('rollback visuel : sur erreur PATCH, data-active-id revient au site initial', async () => {
|
||||
// Scenario : admin clique sur Saint-Jean alors que Chatellerault est
|
||||
// actif, mais le serveur rejette (ex : 500). Apres rollback dans
|
||||
// useCurrentSite, le composant doit re-afficher Chatellerault actif.
|
||||
mockPatch.mockRejectedValueOnce(new Error('server down'))
|
||||
const wrapper = mountSelector()
|
||||
|
||||
// Avant : Chatellerault (id=1) actif.
|
||||
expect(wrapper.find('[data-testid="malio-site-selector"]').attributes('data-active-id'))
|
||||
.toBe('1')
|
||||
|
||||
await wrapper.find('[data-testid="tile-2"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// Apres rollback : Chatellerault (id=1) de nouveau actif.
|
||||
expect(wrapper.find('[data-testid="malio-site-selector"]').attributes('data-active-id'))
|
||||
.toBe('1')
|
||||
// Le store auth ne doit PAS avoir ete laisse avec SITE_B.
|
||||
expect(mockAuthUser.value?.currentSite).toEqual(SITE_A)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { Site } from '~/shared/types/sites'
|
||||
import { useCurrentSite } from '../useCurrentSite'
|
||||
|
||||
const mockPatch = vi.hoisted(() => vi.fn())
|
||||
const mockAuthUser = vi.hoisted(() => ({
|
||||
value: null as { sites: Site[]; currentSite: Site | null } | null,
|
||||
}))
|
||||
|
||||
// Stub des auto-imports Nuxt consommes par le composable.
|
||||
vi.stubGlobal('useApi', () => ({ patch: mockPatch }))
|
||||
vi.stubGlobal('useAuthStore', () => ({
|
||||
get user() {
|
||||
return mockAuthUser.value
|
||||
},
|
||||
// Mime l'action Pinia ajoutee au ticket 3 review (S6) : mute
|
||||
// user.currentSite si user present, no-op sinon.
|
||||
setCurrentSite(site: Site | null) {
|
||||
if (mockAuthUser.value) {
|
||||
mockAuthUser.value.currentSite = site
|
||||
}
|
||||
},
|
||||
}))
|
||||
vi.stubGlobal('useI18n', () => ({
|
||||
t: (key: string) => key,
|
||||
}))
|
||||
// useSidebar est consomme par useCurrentSite pour rafraichir la sidebar
|
||||
// apres un switch reussi. Stub minimal retournant un loadSidebar no-op.
|
||||
vi.stubGlobal('useSidebar', () => ({
|
||||
loadSidebar: vi.fn(),
|
||||
}))
|
||||
// refreshNuxtData est appele apres un switch pour invalider les donnees
|
||||
// de page precedemment fetchees. Stub no-op pour les tests unitaires.
|
||||
vi.stubGlobal('refreshNuxtData', vi.fn())
|
||||
|
||||
const SITE_A: Site = {
|
||||
id: 1,
|
||||
name: 'Chatellerault',
|
||||
street: '14 All. d\'Argenson',
|
||||
complement: null,
|
||||
postalCode: '86100',
|
||||
city: 'Châtellerault',
|
||||
color: '#056CF2',
|
||||
fullAddress: '14 All. d\'Argenson\n86100 Châtellerault',
|
||||
}
|
||||
const SITE_B: Site = {
|
||||
id: 2,
|
||||
name: 'Saint-Jean',
|
||||
street: 'Z i',
|
||||
complement: null,
|
||||
postalCode: '17400',
|
||||
city: 'Fontenet',
|
||||
color: '#F3CB00',
|
||||
fullAddress: 'Z i\n17400 Fontenet',
|
||||
}
|
||||
|
||||
describe('useCurrentSite', () => {
|
||||
beforeEach(() => {
|
||||
mockPatch.mockReset()
|
||||
mockAuthUser.value = {
|
||||
sites: [SITE_A, SITE_B],
|
||||
currentSite: SITE_A,
|
||||
}
|
||||
const { resetCurrentSite } = useCurrentSite()
|
||||
resetCurrentSite()
|
||||
})
|
||||
|
||||
it('syncFromAuth hydrate le state depuis le store auth', () => {
|
||||
const { syncFromAuth, currentSite, availableSites } = useCurrentSite()
|
||||
|
||||
syncFromAuth()
|
||||
|
||||
expect(currentSite.value).toEqual(SITE_A)
|
||||
expect(availableSites.value).toEqual([SITE_A, SITE_B])
|
||||
})
|
||||
|
||||
it('syncFromAuth gere le cas user null (deconnecte)', () => {
|
||||
mockAuthUser.value = null
|
||||
const { syncFromAuth, currentSite, availableSites } = useCurrentSite()
|
||||
|
||||
syncFromAuth()
|
||||
|
||||
expect(currentSite.value).toBeNull()
|
||||
expect(availableSites.value).toEqual([])
|
||||
})
|
||||
|
||||
it('switchSite met a jour currentSite localement AVANT la requete (optimistic)', async () => {
|
||||
mockPatch.mockImplementation(async () => {
|
||||
// Au moment du resolve, currentSite est deja basculé.
|
||||
const state = useCurrentSite()
|
||||
expect(state.currentSite.value).toEqual(SITE_B)
|
||||
return {}
|
||||
})
|
||||
|
||||
const { syncFromAuth, switchSite, currentSite } = useCurrentSite()
|
||||
syncFromAuth()
|
||||
await switchSite(SITE_B)
|
||||
|
||||
expect(currentSite.value).toEqual(SITE_B)
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/me/current-site',
|
||||
{ site: '/api/sites/2' },
|
||||
expect.objectContaining({ toastSuccessMessage: expect.any(String) }),
|
||||
)
|
||||
})
|
||||
|
||||
it('switchSite propage le nouveau currentSite au store auth en cas de succes', async () => {
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const { syncFromAuth, switchSite } = useCurrentSite()
|
||||
syncFromAuth()
|
||||
|
||||
await switchSite(SITE_B)
|
||||
|
||||
expect(mockAuthUser.value?.currentSite).toEqual(SITE_B)
|
||||
})
|
||||
|
||||
it('switchSite rollback le currentSite local si la requete echoue', async () => {
|
||||
mockPatch.mockRejectedValueOnce(new Error('network'))
|
||||
const { syncFromAuth, switchSite, currentSite } = useCurrentSite()
|
||||
syncFromAuth()
|
||||
|
||||
await expect(switchSite(SITE_B)).rejects.toThrow('network')
|
||||
|
||||
expect(currentSite.value).toEqual(SITE_A)
|
||||
})
|
||||
|
||||
it('switchSite ne propage pas au store auth en cas d\'echec', async () => {
|
||||
mockPatch.mockRejectedValueOnce(new Error('network'))
|
||||
const { syncFromAuth, switchSite } = useCurrentSite()
|
||||
syncFromAuth()
|
||||
|
||||
await expect(switchSite(SITE_B)).rejects.toThrow()
|
||||
|
||||
expect(mockAuthUser.value?.currentSite).toEqual(SITE_A)
|
||||
})
|
||||
|
||||
it('switching est vrai pendant la requete et faux apres', async () => {
|
||||
let resolveRequest: (value: unknown) => void = () => {}
|
||||
mockPatch.mockImplementation(
|
||||
() => new Promise((resolve) => { resolveRequest = resolve }),
|
||||
)
|
||||
|
||||
const { syncFromAuth, switchSite, switching } = useCurrentSite()
|
||||
syncFromAuth()
|
||||
|
||||
const pending = switchSite(SITE_B)
|
||||
expect(switching.value).toBe(true)
|
||||
|
||||
resolveRequest({})
|
||||
await pending
|
||||
|
||||
expect(switching.value).toBe(false)
|
||||
})
|
||||
|
||||
it('double switchSite concurrent : le second appel est un no-op silencieux', async () => {
|
||||
let resolveRequest: (value: unknown) => void = () => {}
|
||||
mockPatch.mockImplementation(
|
||||
() => new Promise((resolve) => { resolveRequest = resolve }),
|
||||
)
|
||||
|
||||
const { syncFromAuth, switchSite } = useCurrentSite()
|
||||
syncFromAuth()
|
||||
|
||||
const first = switchSite(SITE_B)
|
||||
await switchSite(SITE_A) // doit etre no-op (switching=true)
|
||||
|
||||
// Le second appel ne declenche pas de PATCH additionnel.
|
||||
expect(mockPatch).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveRequest({})
|
||||
await first
|
||||
})
|
||||
|
||||
it('resetCurrentSite vide tout l\'etat singleton', () => {
|
||||
const { syncFromAuth, resetCurrentSite, currentSite, availableSites, switching } = useCurrentSite()
|
||||
syncFromAuth()
|
||||
expect(currentSite.value).not.toBeNull()
|
||||
|
||||
resetCurrentSite()
|
||||
|
||||
expect(currentSite.value).toBeNull()
|
||||
expect(availableSites.value).toEqual([])
|
||||
expect(switching.value).toBe(false)
|
||||
})
|
||||
|
||||
it('capture useI18n/useApi/useAuthStore UNE FOIS au setup (garde anti-regression bug runtime)', async () => {
|
||||
// Historique : une premiere version du composable appelait useI18n()
|
||||
// dans `switchSite` plutot qu'au top du setup. Consequence en runtime :
|
||||
// l'appel depuis un event handler (click) hors contexte setup levait
|
||||
// "Must be called at the top of a setup function". Ce test grave le
|
||||
// contrat : useCurrentSite() DOIT capturer les 3 services a
|
||||
// l'initialisation, pas paresseusement.
|
||||
//
|
||||
// Verification : on remplace useI18n par un mock qui throw au 2e appel.
|
||||
// Si switchSite invoque useI18n() lui-meme, ce test cassera.
|
||||
let i18nCallCount = 0
|
||||
vi.stubGlobal('useI18n', () => {
|
||||
i18nCallCount++
|
||||
if (i18nCallCount > 1) {
|
||||
throw new Error('useI18n called more than once — regression bug runtime')
|
||||
}
|
||||
return { t: (key: string) => key }
|
||||
})
|
||||
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const { syncFromAuth, switchSite } = useCurrentSite()
|
||||
syncFromAuth()
|
||||
|
||||
// Si switchSite appelait useI18n() en interne, ce call incrementerait
|
||||
// i18nCallCount a 2 et throw. La garde du test passe uniquement si
|
||||
// la capture a bien eu lieu au setup (i18nCallCount reste a 1).
|
||||
await switchSite(SITE_B)
|
||||
|
||||
expect(i18nCallCount).toBe(1)
|
||||
|
||||
// Restaure le stub par defaut pour les tests suivants.
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
})
|
||||
})
|
||||
130
frontend/modules/sites/composables/useCurrentSite.ts
Normal file
130
frontend/modules/sites/composables/useCurrentSite.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Composable de gestion du site courant (ticket 3 module Sites).
|
||||
*
|
||||
* Pattern aligne sur `useSidebar` : state singleton au niveau module,
|
||||
* hydrate depuis `useAuthStore().user`, mute de maniere optimistic avec
|
||||
* rollback si la requete PATCH `/api/me/current-site` echoue.
|
||||
*
|
||||
* Garantie d'unicite : le flag `switching` bloque les double-clicks
|
||||
* concurrents. Le reset explicite est appele au logout
|
||||
* (voir `modules/core/pages/logout.vue`).
|
||||
*
|
||||
* Auto-select : aucun. Le backend (`UserRbacProcessor::ensureCurrentSiteConsistency`)
|
||||
* garantit deja l'invariant "user avec sites non vide => currentSite non null"
|
||||
* apres tout PATCH /rbac. Le front consomme l'etat renvoye tel quel.
|
||||
*
|
||||
* Contrainte d'appel : `useCurrentSite()` doit etre invoque au top du
|
||||
* `setup()` d'un composant (ou d'un autre composable appele au setup).
|
||||
* Les dependances `useI18n`, `useApi` et `useAuthStore` sont resolues
|
||||
* a l'initialisation et reutilisees par `switchSite` — ceci evite le
|
||||
* "Must be called at the top of a setup function" qui se produirait
|
||||
* si on les appelait paresseusement depuis une fonction async declenchee
|
||||
* par un handler d'event (hors contexte setup).
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import type { Site } from '~/shared/types/sites'
|
||||
import { onAuthSessionCleared } from '~/shared/stores/auth'
|
||||
|
||||
const currentSite = ref<Site | null>(null)
|
||||
const availableSites = ref<Site[]>([])
|
||||
const switching = ref(false)
|
||||
|
||||
// Enregistrement unique au niveau module (singleton) : quand clearSession()
|
||||
// est appelee par l'intercepteur 401 de useApi, le state local est purgé
|
||||
// de la meme facon qu'au logout explicite (logout.vue).
|
||||
onAuthSessionCleared(() => {
|
||||
currentSite.value = null
|
||||
availableSites.value = []
|
||||
switching.value = false
|
||||
})
|
||||
|
||||
export function useCurrentSite() {
|
||||
// Resolution au setup : les 3 services doivent etre invoques dans un
|
||||
// contexte composant. Leur capture ici permet a switchSite() de
|
||||
// s'executer plus tard (handler de click, async) sans crash.
|
||||
const auth = useAuthStore()
|
||||
const api = useApi()
|
||||
const { t } = useI18n()
|
||||
const { loadSidebar } = useSidebar()
|
||||
|
||||
/**
|
||||
* Synchronise le state singleton depuis le store auth. A appeler au
|
||||
* mount du SiteSelector (ou via un watcher sur `auth.user`).
|
||||
*/
|
||||
function syncFromAuth(): void {
|
||||
availableSites.value = auth.user?.sites ?? []
|
||||
currentSite.value = auth.user?.currentSite ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Bascule le site courant. Optimistic UI : la mutation locale precede
|
||||
* la requete HTTP. En cas d'echec (`api.patch` throw), l'etat local est
|
||||
* restaure — le store auth n'a PAS ete muté a ce stade (la propagation
|
||||
* `auth.setCurrentSite` se fait uniquement apres un succes HTTP), donc
|
||||
* aucun rollback cote auth n'est necessaire.
|
||||
*
|
||||
* Garde anti-double-submit : si un switch est deja en vol, le second
|
||||
* appel est un no-op silencieux.
|
||||
*/
|
||||
async function switchSite(site: Site): Promise<void> {
|
||||
if (switching.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const previousLocal = currentSite.value
|
||||
currentSite.value = site
|
||||
switching.value = true
|
||||
|
||||
try {
|
||||
await api.patch(
|
||||
'/me/current-site',
|
||||
{ site: `/api/sites/${site.id}` },
|
||||
{ toastSuccessMessage: t('sites.selector.switchSuccess') },
|
||||
)
|
||||
// Propage au store auth via l'action dediee — plus tracable que
|
||||
// la mutation directe et garantit la notification des watchers.
|
||||
// N'est appele qu'apres un succes HTTP donc pas de rollback a
|
||||
// prevoir sur cette ligne.
|
||||
auth.setCurrentSite(site)
|
||||
|
||||
// Apres un switch reussi : recharger la sidebar (les filtres de
|
||||
// modules peuvent dependre du site courant via SiteScopedQueryExtension)
|
||||
// et invalider toutes les donnees de page pour eviter que l'utilisateur
|
||||
// voie les donnees de l'ancien site sous un toast "Site change".
|
||||
try {
|
||||
await loadSidebar()
|
||||
} catch {
|
||||
// No-op : la sidebar non rafraichie n'est pas bloquante.
|
||||
}
|
||||
try {
|
||||
await refreshNuxtData()
|
||||
} catch {
|
||||
// No-op : certaines pages n'ont pas de useAsyncData a invalider.
|
||||
}
|
||||
} catch (error) {
|
||||
currentSite.value = previousLocal
|
||||
throw error
|
||||
} finally {
|
||||
switching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vide l'etat singleton. Appele au logout pour eviter qu'un user
|
||||
* suivant (connecte sur le meme onglet) voie les sites de l'ancien.
|
||||
*/
|
||||
function resetCurrentSite(): void {
|
||||
currentSite.value = null
|
||||
availableSites.value = []
|
||||
switching.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
currentSite,
|
||||
availableSites,
|
||||
switching,
|
||||
switchSite,
|
||||
syncFromAuth,
|
||||
resetCurrentSite,
|
||||
}
|
||||
}
|
||||
1
frontend/modules/sites/nuxt.config.ts
Normal file
1
frontend/modules/sites/nuxt.config.ts
Normal file
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
195
frontend/modules/sites/pages/admin/sites.vue
Normal file
195
frontend/modules/sites/pages/admin/sites.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||
{{ t('admin.sites.title') }}
|
||||
</h1>
|
||||
<MalioButton
|
||||
v-if="can('sites.manage')"
|
||||
:label="t('admin.sites.newSite')"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
@click="openCreateDrawer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Table des sites avec filtres + pagination -->
|
||||
<MalioDataTable
|
||||
v-model:page="page"
|
||||
v-model:per-page="perPage"
|
||||
class="mt-6"
|
||||
:columns="columns"
|
||||
:items="siteItems"
|
||||
:total-items="totalItems"
|
||||
:row-clickable="canManage"
|
||||
:empty-message="t('admin.sites.noSites')"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<template #header-name>
|
||||
<input
|
||||
v-model="filters.name"
|
||||
type="text"
|
||||
:placeholder="t('admin.sites.table.name')"
|
||||
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
|
||||
>
|
||||
</template>
|
||||
<template #header-city>
|
||||
<input
|
||||
v-model="filters.city"
|
||||
type="text"
|
||||
:placeholder="t('admin.sites.table.city')"
|
||||
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
|
||||
>
|
||||
</template>
|
||||
<template #header-postalCode>
|
||||
<input
|
||||
v-model="filters.postalCode"
|
||||
type="text"
|
||||
:placeholder="t('admin.sites.table.postalCode')"
|
||||
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
|
||||
>
|
||||
</template>
|
||||
|
||||
<template #cell-color="{ item }">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span
|
||||
:style="{ backgroundColor: item.color }"
|
||||
class="inline-block size-5 rounded-full border border-neutral-200"
|
||||
/>
|
||||
<span class="font-mono text-xs">{{ item.color }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-fullAddress="{ item }">
|
||||
<span class="line-clamp-2 text-xs text-neutral-600">
|
||||
{{ item.fullAddress }}
|
||||
</span>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
|
||||
<!-- Drawer creation/edition -->
|
||||
<SiteDrawer
|
||||
v-model="drawerOpen"
|
||||
:site="selectedSite"
|
||||
@saved="onSiteSaved"
|
||||
@delete="onDeleteRequest"
|
||||
/>
|
||||
|
||||
<!-- Modale de suppression -->
|
||||
<SiteDeleteModal
|
||||
v-model="deleteModalOpen"
|
||||
:site-name="siteToDelete?.name ?? ''"
|
||||
:loading="deleting"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Site } from '~/shared/types/sites'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const auth = useAuthStore()
|
||||
const { can } = usePermissions()
|
||||
const canManage = computed(() => can('sites.manage'))
|
||||
|
||||
useHead({ title: t('admin.sites.title') })
|
||||
|
||||
// Etat DataTable centralise : pagination serveur + filtres debounces.
|
||||
// Les filtres name/city/postalCode sont des partiels SearchFilter cote API.
|
||||
const {
|
||||
items,
|
||||
totalItems,
|
||||
page,
|
||||
perPage,
|
||||
filters,
|
||||
reload,
|
||||
} = useDataTableServerState<Site>('/sites', {
|
||||
name: '',
|
||||
city: '',
|
||||
postalCode: '',
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: t('admin.sites.table.name') },
|
||||
{ key: 'city', label: t('admin.sites.table.city') },
|
||||
{ key: 'postalCode', label: t('admin.sites.table.postalCode') },
|
||||
{ key: 'color', label: t('admin.sites.table.color') },
|
||||
{ key: 'fullAddress', label: t('admin.sites.table.fullAddress') },
|
||||
]
|
||||
|
||||
// Transformer les sites en items compatibles MalioDataTable.
|
||||
// `fullAddress` provient du getter computed cote backend (Site::getFullAddress)
|
||||
// au format multi-lignes — on l'aplatit en virgules pour l'affichage table.
|
||||
const siteItems = computed(() =>
|
||||
items.value.map(site => ({
|
||||
id: site.id,
|
||||
name: site.name,
|
||||
city: site.city,
|
||||
postalCode: site.postalCode,
|
||||
color: site.color,
|
||||
fullAddress: site.fullAddress.split('\n').join(', '),
|
||||
})),
|
||||
)
|
||||
|
||||
function getSiteById(id: number): Site | undefined {
|
||||
return items.value.find(s => s.id === id)
|
||||
}
|
||||
|
||||
function onRowClick(item: Record<string, unknown>) {
|
||||
const site = getSiteById(item.id as number)
|
||||
if (site) openEditDrawer(site)
|
||||
}
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
const selectedSite = ref<Site | null>(null)
|
||||
const deleteModalOpen = ref(false)
|
||||
const siteToDelete = ref<Site | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
function openCreateDrawer() {
|
||||
selectedSite.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEditDrawer(site: Site) {
|
||||
selectedSite.value = site
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function onDeleteRequest() {
|
||||
if (!selectedSite.value) return
|
||||
siteToDelete.value = selectedSite.value
|
||||
deleteModalOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!siteToDelete.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await api.delete(`/sites/${siteToDelete.value.id}`, {}, {
|
||||
toastSuccessMessage: t('admin.sites.toast.deleted'),
|
||||
})
|
||||
deleteModalOpen.value = false
|
||||
siteToDelete.value = null
|
||||
drawerOpen.value = false
|
||||
reload()
|
||||
// Rafraichit auth.user apres suppression d'un site : le backend
|
||||
// applique ON DELETE SET NULL sur user.current_site_id, donc
|
||||
// auth.user.currentSite peut etre devenu null sans que le front
|
||||
// le sache. refreshUser() resynchronise depuis GET /api/me.
|
||||
await auth.refreshUser()
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSiteSaved() {
|
||||
reload()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
reload()
|
||||
})
|
||||
</script>
|
||||
@@ -3,11 +3,21 @@ import { resolve } from 'node:path'
|
||||
|
||||
// Auto-detect module layers: every directory under frontend/modules/ becomes a Nuxt layer.
|
||||
const modulesDir = resolve(__dirname, 'modules')
|
||||
const moduleLayers = existsSync(modulesDir)
|
||||
const moduleDirs = existsSync(modulesDir)
|
||||
? readdirSync(modulesDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => `./modules/${d.name}`)
|
||||
.map(d => d.name)
|
||||
: []
|
||||
const moduleLayers = moduleDirs.map(name => `./modules/${name}`)
|
||||
|
||||
// Auto-detect composables dirs pour chaque layer module. Necessaire car le
|
||||
// `imports.dirs` explicite ci-dessous override le comportement par defaut
|
||||
// de Nuxt (qui scannerait composables/ de chaque layer automatiquement).
|
||||
// Sans ca, useCurrentSite / autres composables des modules ne seraient pas
|
||||
// resolus a l'execution — cf. ticket 3 bug detecte apres review.
|
||||
const moduleComposableDirs = moduleDirs
|
||||
.map(name => `./modules/${name}/composables`)
|
||||
.filter(path => existsSync(resolve(__dirname, path)))
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
@@ -51,6 +61,7 @@ export default defineNuxtConfig({
|
||||
'shared/composables',
|
||||
'shared/utils',
|
||||
'shared/stores',
|
||||
...moduleComposableDirs,
|
||||
],
|
||||
},
|
||||
vite: {
|
||||
|
||||
72
frontend/package-lock.json
generated
72
frontend/package-lock.json
generated
@@ -7,7 +7,7 @@
|
||||
"name": "coltura-frontend",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.3.0",
|
||||
"@malio/layer-ui": "^1.4.2",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -22,6 +22,7 @@
|
||||
"@nuxt/eslint-config": "^1.9.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.44.1",
|
||||
"@typescript-eslint/parser": "^8.44.1",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-vue": "^10.5.0",
|
||||
@@ -83,6 +84,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -580,27 +582,6 @@
|
||||
"integrity": "sha512-/B8YJGPzaYq1NbsQmwgP8EZqg40NpTw4ZB3suuI0TplbxKHeK94jeaawLmVhCv+YwUnOpiWEz9U6SeThku/8JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
@@ -1839,9 +1820,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@malio/layer-ui": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.3.0/layer-ui-1.3.0.tgz",
|
||||
"integrity": "sha512-Gs4pnlWTWrhoF3QQKxYBu4IxN65O9B4bls7s+ONm05qvI2Y2x7N4VNFGjWvT+rNQ4BzHFCxSCzN4V3o6p0Q7uw==",
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.4.2/layer-ui-1.4.2.tgz",
|
||||
"integrity": "sha512-H8f5FJXHFH9ZI1Jx4u9XE7w6VlR/d9Zr2encfQyMax1I0UZ3SiGBUjictcL33r0OhgsrgSmPq0J9aF6aab85Nw==",
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -2186,6 +2167,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.4.2.tgz",
|
||||
"integrity": "sha512-5+IPRNX2CjkBhuWUwz0hBuLqiaJPRoKzQ+SvcdrQDbAyE+VDeFt74VpSFr5/R0ujrK4b+XnSHUJWdS72w6hsog==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"c12": "^3.3.3",
|
||||
"consola": "^3.4.2",
|
||||
@@ -2288,6 +2270,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.4.2.tgz",
|
||||
"integrity": "sha512-/q6C7Qhiricgi+PKR7ovBnJlKTL0memCbA1CzRT+itCW/oeYzUfeMdQ35mGntlBoyRPNrMXbzuSUhfDbSCU57w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/shared": "^3.5.30",
|
||||
"defu": "^6.1.4",
|
||||
@@ -3957,9 +3940,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
|
||||
"integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/plugin-alias": {
|
||||
@@ -4628,6 +4611,7 @@
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
}
|
||||
@@ -4690,6 +4674,7 @@
|
||||
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.58.2",
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
@@ -5206,12 +5191,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "6.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz",
|
||||
"integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==",
|
||||
"version": "6.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz",
|
||||
"integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rolldown/pluginutils": "1.0.0-rc.2"
|
||||
"@rolldown/pluginutils": "1.0.0-rc.13"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
@@ -5469,6 +5454,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz",
|
||||
"integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.2",
|
||||
"@vue/compiler-core": "3.5.32",
|
||||
@@ -5712,6 +5698,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -6099,6 +6086,7 @@
|
||||
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
|
||||
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*"
|
||||
},
|
||||
@@ -6296,6 +6284,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@@ -6410,6 +6399,7 @@
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -6604,7 +6594,8 @@
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
|
||||
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/clean-regexp": {
|
||||
"version": "1.0.0",
|
||||
@@ -7657,6 +7648,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
|
||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8815,6 +8807,7 @@
|
||||
"integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": ">=20.0.0",
|
||||
"@types/whatwg-mimetype": "^3.0.2",
|
||||
@@ -11205,6 +11198,7 @@
|
||||
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.4.2.tgz",
|
||||
"integrity": "sha512-iWVFpr/YEqVU/CenqIHMnIkvb2HE/9f+q8oxZ+pj2et+60NljGRClCgnmbvGPdmNFE0F1bEhoBCYfqbDOCim3Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dxup/nuxt": "^0.4.0",
|
||||
"@nuxt/cli": "^3.34.0",
|
||||
@@ -12263,6 +12257,7 @@
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"deep-is": "^0.1.3",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
@@ -12314,6 +12309,7 @@
|
||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.112.0.tgz",
|
||||
"integrity": "sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "^0.112.0"
|
||||
},
|
||||
@@ -12580,6 +12576,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^7.7.7"
|
||||
},
|
||||
@@ -12658,6 +12655,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -13201,6 +13199,7 @@
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
||||
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@@ -13820,6 +13819,7 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -14717,6 +14717,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -15372,6 +15373,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"napi-postinstall": "^0.3.0"
|
||||
},
|
||||
@@ -15638,6 +15640,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -16556,6 +16559,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz",
|
||||
"integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.32",
|
||||
"@vue/compiler-sfc": "3.5.32",
|
||||
@@ -16600,6 +16604,7 @@
|
||||
"integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"eslint-scope": "^8.2.0 || ^9.0.0",
|
||||
@@ -16636,6 +16641,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.1.tgz",
|
||||
"integrity": "sha512-azq8fhVnCwJAw0iXW7i44h9P+Bj+snNuevBAaJ9bxn0I3YVsRU3deVFPNnTfZ2uxVJefGp83JUmL68ddCPw5Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "11.3.1",
|
||||
"@intlify/devtools-types": "11.3.1",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.3.0",
|
||||
"@malio/layer-ui": "^1.4.2",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -30,6 +30,7 @@
|
||||
"@nuxt/eslint-config": "^1.9.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.44.1",
|
||||
"@typescript-eslint/parser": "^8.44.1",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-vue": "^10.5.0",
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { useDataTableServerState } from '../useDataTableServerState'
|
||||
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
|
||||
function ldResponse<T>(member: T[], totalItems?: number): { member: T[], totalItems: number } {
|
||||
return { member, totalItems: totalItems ?? member.length }
|
||||
}
|
||||
|
||||
describe('useDataTableServerState', () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('fetch initial au premier reload() avec page=1 et perPage par defaut', async () => {
|
||||
mockApiGet.mockResolvedValueOnce(ldResponse([{ id: 1 }, { id: 2 }], 42))
|
||||
|
||||
const { items, totalItems, reload } = useDataTableServerState('/sites', { name: '' })
|
||||
reload()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledWith(
|
||||
'/sites',
|
||||
{ page: 1, itemsPerPage: 10 },
|
||||
{ toast: false },
|
||||
)
|
||||
expect(items.value).toHaveLength(2)
|
||||
expect(totalItems.value).toBe(42)
|
||||
})
|
||||
|
||||
it('omet les filtres a valeur vide dans les query params', async () => {
|
||||
mockApiGet.mockResolvedValueOnce(ldResponse([]))
|
||||
|
||||
const { reload } = useDataTableServerState('/users', {
|
||||
username: '',
|
||||
isAdmin: null,
|
||||
})
|
||||
reload()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledWith(
|
||||
'/users',
|
||||
{ page: 1, itemsPerPage: 10 },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('inclut les filtres renseignes dans les query params', async () => {
|
||||
// mockResolvedValue (sans Once) : chaque fetch retourne une
|
||||
// reponse valide, y compris ceux declenches par le debounce des
|
||||
// mutations de filters qui precedent reload().
|
||||
mockApiGet.mockResolvedValue(ldResponse([]))
|
||||
|
||||
const { filters, reload } = useDataTableServerState('/users', {
|
||||
username: '',
|
||||
isAdmin: null,
|
||||
})
|
||||
filters.value.username = 'alice'
|
||||
filters.value.isAdmin = true
|
||||
reload()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
// Le reload() ecrase les scheduleReload en cours (clearTimeout),
|
||||
// donc on verifie juste que la derniere requete emise porte bien
|
||||
// les filtres + les parametres de pagination.
|
||||
expect(mockApiGet).toHaveBeenLastCalledWith(
|
||||
'/users',
|
||||
{ page: 1, itemsPerPage: 10, username: 'alice', isAdmin: true },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('change page declenche un fetch immediat (pas de debounce)', async () => {
|
||||
mockApiGet.mockResolvedValue(ldResponse([]))
|
||||
|
||||
const { page, reload } = useDataTableServerState('/sites', {})
|
||||
reload()
|
||||
await vi.runAllTimersAsync()
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
||||
|
||||
page.value = 3
|
||||
await nextTick()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(2)
|
||||
expect(mockApiGet).toHaveBeenLastCalledWith(
|
||||
'/sites',
|
||||
{ page: 3, itemsPerPage: 10 },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('change filter debounce 300ms avant fetch', async () => {
|
||||
mockApiGet.mockResolvedValue(ldResponse([]))
|
||||
|
||||
const { filters, reload } = useDataTableServerState('/sites', { name: '' })
|
||||
reload()
|
||||
await vi.runAllTimersAsync()
|
||||
mockApiGet.mockClear()
|
||||
|
||||
filters.value.name = 'a'
|
||||
await nextTick()
|
||||
// Pas encore de requete : debounce en cours.
|
||||
expect(mockApiGet).not.toHaveBeenCalled()
|
||||
|
||||
filters.value.name = 'al'
|
||||
await nextTick()
|
||||
filters.value.name = 'ali'
|
||||
await nextTick()
|
||||
|
||||
// Avance le timer de 200ms : toujours pas fetch.
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(mockApiGet).not.toHaveBeenCalled()
|
||||
|
||||
// Avance encore 100ms : debounce expire, fetch lance.
|
||||
vi.advanceTimersByTime(100)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
||||
expect(mockApiGet).toHaveBeenCalledWith(
|
||||
'/sites',
|
||||
{ page: 1, itemsPerPage: 10, name: 'ali' },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('changer un filtre reset page a 1', async () => {
|
||||
mockApiGet.mockResolvedValue(ldResponse([]))
|
||||
|
||||
const { page, filters, reload } = useDataTableServerState('/sites', { name: '' })
|
||||
reload()
|
||||
await vi.runAllTimersAsync()
|
||||
page.value = 5
|
||||
await vi.runAllTimersAsync()
|
||||
mockApiGet.mockClear()
|
||||
|
||||
filters.value.name = 'x'
|
||||
await nextTick()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
// Page doit etre revenue a 1 avant le fetch.
|
||||
expect(page.value).toBe(1)
|
||||
expect(mockApiGet).toHaveBeenLastCalledWith(
|
||||
'/sites',
|
||||
expect.objectContaining({ page: 1, name: 'x' }),
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('change perPage declenche un fetch immediat', async () => {
|
||||
mockApiGet.mockResolvedValue(ldResponse([]))
|
||||
|
||||
const { perPage, reload } = useDataTableServerState('/sites', {})
|
||||
reload()
|
||||
await vi.runAllTimersAsync()
|
||||
mockApiGet.mockClear()
|
||||
|
||||
perPage.value = 25
|
||||
await nextTick()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(mockApiGet).toHaveBeenLastCalledWith(
|
||||
'/sites',
|
||||
{ page: 1, itemsPerPage: 25 },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('race condition : seule la derniere reponse gagne', async () => {
|
||||
// Scenario : user tape tres vite, 2 requetes partent, la premiere
|
||||
// (plus ancienne) arrive apres la seconde. Le composable doit
|
||||
// ignorer la premiere.
|
||||
let resolveFirst!: (value: unknown) => void
|
||||
let resolveSecond!: (value: unknown) => void
|
||||
|
||||
mockApiGet
|
||||
.mockImplementationOnce(() => new Promise((r) => { resolveFirst = r }))
|
||||
.mockImplementationOnce(() => new Promise((r) => { resolveSecond = r }))
|
||||
|
||||
const { items, reload } = useDataTableServerState<{ id: number }>('/sites', {})
|
||||
|
||||
reload() // requete #1
|
||||
reload() // requete #2 (annule #1 du point de vue du token)
|
||||
|
||||
// Resout la seconde d'abord avec id=2
|
||||
resolveSecond(ldResponse([{ id: 2 }]))
|
||||
await vi.runAllTimersAsync()
|
||||
expect(items.value).toEqual([{ id: 2 }])
|
||||
|
||||
// Resout la premiere apres avec id=1 : DOIT etre ignore.
|
||||
resolveFirst(ldResponse([{ id: 1 }]))
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(items.value).toEqual([{ id: 2 }])
|
||||
})
|
||||
})
|
||||
71
frontend/shared/composables/__tests__/useModules.test.ts
Normal file
71
frontend/shared/composables/__tests__/useModules.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useModules } from '../useModules'
|
||||
|
||||
// Mock de useApi : on peut scripter la reponse de /api/modules.
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
|
||||
// useApi est auto-importe par Nuxt en prod. En Vitest isole, on expose le
|
||||
// mock comme global pour que l'appel sans import dans useModules.ts
|
||||
// (pattern aligne sur useSidebar) fonctionne.
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
|
||||
describe('useModules', () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset()
|
||||
// Reset l'etat singleton entre tests.
|
||||
const { resetModules } = useModules()
|
||||
resetModules()
|
||||
})
|
||||
|
||||
it('charge la liste des modules actifs depuis /api/modules', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ modules: ['core', 'sites'] })
|
||||
const { loadModules, activeModuleIds, loaded } = useModules()
|
||||
|
||||
await loadModules()
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledWith('/modules', {}, { toast: false })
|
||||
expect(activeModuleIds.value).toEqual(['core', 'sites'])
|
||||
expect(loaded.value).toBe(true)
|
||||
})
|
||||
|
||||
it('isModuleActive retourne true pour un id present', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ modules: ['core', 'sites'] })
|
||||
const { loadModules, isModuleActive } = useModules()
|
||||
await loadModules()
|
||||
|
||||
expect(isModuleActive('sites')).toBe(true)
|
||||
expect(isModuleActive('core')).toBe(true)
|
||||
})
|
||||
|
||||
it('isModuleActive retourne false pour un id absent', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ modules: ['core'] })
|
||||
const { loadModules, isModuleActive } = useModules()
|
||||
await loadModules()
|
||||
|
||||
expect(isModuleActive('sites')).toBe(false)
|
||||
expect(isModuleActive('inexistant')).toBe(false)
|
||||
})
|
||||
|
||||
it('swallow les erreurs reseau et laisse la liste vide', async () => {
|
||||
mockApiGet.mockRejectedValueOnce(new Error('boom'))
|
||||
const { loadModules, activeModuleIds, loaded, isModuleActive } = useModules()
|
||||
|
||||
await loadModules()
|
||||
|
||||
expect(activeModuleIds.value).toEqual([])
|
||||
expect(loaded.value).toBe(true)
|
||||
expect(isModuleActive('sites')).toBe(false)
|
||||
})
|
||||
|
||||
it('resetModules vide l\'etat', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ modules: ['core', 'sites'] })
|
||||
const { loadModules, resetModules, activeModuleIds, loaded } = useModules()
|
||||
await loadModules()
|
||||
expect(activeModuleIds.value.length).toBeGreaterThan(0)
|
||||
|
||||
resetModules()
|
||||
|
||||
expect(activeModuleIds.value).toEqual([])
|
||||
expect(loaded.value).toBe(false)
|
||||
})
|
||||
})
|
||||
71
frontend/shared/composables/__tests__/useSidebar.test.ts
Normal file
71
frontend/shared/composables/__tests__/useSidebar.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useSidebar } from '../useSidebar'
|
||||
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
|
||||
/**
|
||||
* Tests de l'invariant "loadSidebar ne reject jamais".
|
||||
*
|
||||
* Garantie utilisee par le middleware auth.global.ts qui fait un
|
||||
* Promise.all([loadSidebar(), loadModules()]) — si l'un throw, le
|
||||
* middleware echoue et toute l'app avec. Le swallow interne est donc
|
||||
* load-bearing et ce test le verrouille.
|
||||
*/
|
||||
describe('useSidebar', () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset()
|
||||
const { resetSidebar } = useSidebar()
|
||||
resetSidebar()
|
||||
})
|
||||
|
||||
it('charge sections et disabledRoutes depuis /api/sidebar', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
sections: [{ label: 's', icon: 'i', items: [] }],
|
||||
disabledRoutes: ['/foo'],
|
||||
})
|
||||
const { loadSidebar, sections, disabledRoutes, loaded } = useSidebar()
|
||||
|
||||
await loadSidebar()
|
||||
|
||||
expect(sections.value).toHaveLength(1)
|
||||
expect(disabledRoutes.value).toEqual(['/foo'])
|
||||
expect(loaded.value).toBe(true)
|
||||
})
|
||||
|
||||
it('swallow les erreurs reseau sans rejeter (invariant middleware)', async () => {
|
||||
mockApiGet.mockRejectedValueOnce(new Error('boom'))
|
||||
const { loadSidebar, sections, disabledRoutes, loaded } = useSidebar()
|
||||
|
||||
// Assertion principale : la promise resout normalement meme sur erreur.
|
||||
await expect(loadSidebar()).resolves.toBeUndefined()
|
||||
expect(sections.value).toEqual([])
|
||||
expect(disabledRoutes.value).toEqual([])
|
||||
expect(loaded.value).toBe(true)
|
||||
})
|
||||
|
||||
it('isRouteDisabled matche exactement un chemin', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ sections: [], disabledRoutes: ['/foo'] })
|
||||
const { loadSidebar, isRouteDisabled } = useSidebar()
|
||||
await loadSidebar()
|
||||
|
||||
expect(isRouteDisabled('/foo')).toBe(true)
|
||||
expect(isRouteDisabled('/foo/bar')).toBe(true)
|
||||
expect(isRouteDisabled('/other')).toBe(false)
|
||||
})
|
||||
|
||||
it('resetSidebar vide l\'etat', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({
|
||||
sections: [{ label: 's', icon: 'i', items: [] }],
|
||||
disabledRoutes: ['/foo'],
|
||||
})
|
||||
const { loadSidebar, resetSidebar, sections, loaded } = useSidebar()
|
||||
await loadSidebar()
|
||||
expect(loaded.value).toBe(true)
|
||||
|
||||
resetSidebar()
|
||||
|
||||
expect(sections.value).toEqual([])
|
||||
expect(loaded.value).toBe(false)
|
||||
})
|
||||
})
|
||||
149
frontend/shared/composables/useDataTableServerState.ts
Normal file
149
frontend/shared/composables/useDataTableServerState.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
/**
|
||||
* Composable generique pour les DataTables admin avec pagination, perPage
|
||||
* et filtres cote serveur (API Platform + Hydra).
|
||||
*
|
||||
* Usage type dans une page admin :
|
||||
*
|
||||
* ```ts
|
||||
* const { items, totalItems, page, perPage, filters, loading, reload } =
|
||||
* useDataTableServerState<Site>('/sites', {
|
||||
* name: '',
|
||||
* city: '',
|
||||
* postalCode: '',
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* Le composable :
|
||||
* - traque `page`, `perPage`, et un objet `filters` reactif.
|
||||
* - re-fetch automatiquement a chaque changement (debounce 300ms sur
|
||||
* `filters` pour eviter un spam lors de la frappe clavier).
|
||||
* - re-fetch immediat (pas de debounce) quand `page` ou `perPage` change
|
||||
* — ces changements sont deja des clics user discrets.
|
||||
* - reinitialise `page` a 1 des qu'un filtre bouge (coherence UX : un
|
||||
* filtre ajuste ne doit pas laisser l'user sur "page 5 de 2 pages").
|
||||
* - expose `loading` pour afficher un feedback pendant la requete.
|
||||
* - expose `reload()` pour forcer un fetch (ex: apres une mutation
|
||||
* POST/PATCH/DELETE).
|
||||
*
|
||||
* Type parameter T = la forme d'un item renvoye par l'API (le member[]
|
||||
* du payload Hydra est type T[]).
|
||||
*/
|
||||
export function useDataTableServerState<T = Record<string, unknown>>(
|
||||
endpoint: string,
|
||||
initialFilters: Record<string, string | boolean | null> = {},
|
||||
options: { debounceMs?: number, initialPerPage?: number } = {},
|
||||
) {
|
||||
const api = useApi()
|
||||
|
||||
const debounceMs = options.debounceMs ?? 300
|
||||
const initialPerPage = options.initialPerPage ?? 10
|
||||
|
||||
const items = ref<T[]>([]) as { value: T[] }
|
||||
const totalItems = ref(0)
|
||||
const page = ref(1)
|
||||
const perPage = ref(initialPerPage)
|
||||
const filters = ref<Record<string, string | boolean | null>>({ ...initialFilters })
|
||||
const loading = ref(false)
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
// Token de generation : chaque reload incremente ce compteur. Quand
|
||||
// une reponse arrive, on verifie que son token est toujours le plus
|
||||
// recent — sinon on ignore (protection anti race condition si l'user
|
||||
// tape vite plusieurs filtres).
|
||||
let requestToken = 0
|
||||
|
||||
/**
|
||||
* Construit le payload query params pour useApi.get.
|
||||
* Les filtres a valeur vide (chaine vide, null) sont omis pour eviter
|
||||
* de filtrer sur "rien" (comportement API Platform : filtre present
|
||||
* avec valeur vide = ne retourne aucun resultat).
|
||||
*/
|
||||
function buildQueryParams(): Record<string, string | number | boolean> {
|
||||
const params: Record<string, string | number | boolean> = {
|
||||
page: page.value,
|
||||
itemsPerPage: perPage.value,
|
||||
}
|
||||
for (const [key, value] of Object.entries(filters.value)) {
|
||||
if (value === '' || value === null) continue
|
||||
params[key] = value as string | boolean
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
async function fetchItems(): Promise<void> {
|
||||
const currentToken = ++requestToken
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ member: T[], totalItems: number }>(
|
||||
endpoint,
|
||||
buildQueryParams(),
|
||||
{ toast: false },
|
||||
)
|
||||
// Ignore si une requete plus recente a ete lancee entre-temps.
|
||||
if (currentToken !== requestToken) return
|
||||
// Defensive : un mock/test ou une API mal configuree peut
|
||||
// renvoyer undefined. On ne crash pas, on laisse les valeurs
|
||||
// par defaut.
|
||||
items.value = data?.member ?? []
|
||||
totalItems.value = data?.totalItems ?? 0
|
||||
} finally {
|
||||
if (currentToken === requestToken) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force un refetch immediat, sans debounce. Utile apres une mutation
|
||||
* (POST/PATCH/DELETE) ou au mount initial.
|
||||
*/
|
||||
function reload(): void {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
void fetchItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* Programme un refetch debounced. Utilise par le watcher de `filters`.
|
||||
*/
|
||||
function scheduleReload(): void {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null
|
||||
void fetchItems()
|
||||
}, debounceMs)
|
||||
}
|
||||
|
||||
// Watcher sur page/perPage : refetch immediat (pas de spam possible,
|
||||
// l'user clique sur un bouton pagination).
|
||||
watch([page, perPage], () => {
|
||||
reload()
|
||||
})
|
||||
|
||||
// Watcher sur filters : refetch debounced + reset page a 1 pour
|
||||
// eviter l'etat "filtre qui reduit le total mais user reste sur une
|
||||
// page inexistante".
|
||||
watch(filters, () => {
|
||||
if (page.value !== 1) {
|
||||
page.value = 1
|
||||
// Le changement de page declenchera son propre watcher, qui
|
||||
// appellera reload(). Pas besoin d'en programmer un.
|
||||
return
|
||||
}
|
||||
scheduleReload()
|
||||
}, { deep: true })
|
||||
|
||||
return {
|
||||
items,
|
||||
totalItems,
|
||||
page,
|
||||
perPage,
|
||||
filters,
|
||||
loading,
|
||||
reload,
|
||||
}
|
||||
}
|
||||
49
frontend/shared/composables/useModules.ts
Normal file
49
frontend/shared/composables/useModules.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Composable de lecture des modules actifs (source : `/api/modules`).
|
||||
*
|
||||
* State singleton au niveau module : `useSidebar` suit la meme convention.
|
||||
* Chargement idempotent via le flag `loaded`, reset explicite au logout
|
||||
* (voir pages/logout.vue).
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
|
||||
const activeModuleIds = ref<string[]>([])
|
||||
const loaded = ref(false)
|
||||
|
||||
export function useModules() {
|
||||
async function loadModules() {
|
||||
try {
|
||||
const api = useApi()
|
||||
const data = await api.get<{ modules: string[] }>(
|
||||
'/modules',
|
||||
{},
|
||||
{ toast: false },
|
||||
)
|
||||
activeModuleIds.value = data.modules ?? []
|
||||
loaded.value = true
|
||||
} catch {
|
||||
// Swallow volontaire aligne sur useSidebar : un echec reseau ne
|
||||
// doit pas bloquer le rendu, l'app affichera juste sans la
|
||||
// granularite module (selector masque par defaut).
|
||||
activeModuleIds.value = []
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function isModuleActive(id: string): boolean {
|
||||
return activeModuleIds.value.includes(id)
|
||||
}
|
||||
|
||||
function resetModules() {
|
||||
activeModuleIds.value = []
|
||||
loaded.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
activeModuleIds,
|
||||
loaded,
|
||||
loadModules,
|
||||
isModuleActive,
|
||||
resetModules,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ref } from 'vue'
|
||||
import type { SidebarSection } from '~/shared/types'
|
||||
|
||||
const sections = ref<SidebarSection[]>([])
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { UserData } from '~/shared/types/user-data'
|
||||
import type { Site } from '~/shared/types/sites'
|
||||
import { getCurrentUser, login, logout } from '~/shared/services/auth'
|
||||
|
||||
/**
|
||||
* Callbacks enregistres par les composables singletons qui doivent
|
||||
* reinitialiser leur etat quand la session est invalidee (ex: expiration
|
||||
* JWT, logout depuis un intercepteur 401). Utilise le pattern
|
||||
* "callback registration" (Option C) pour eviter une dependance croisee
|
||||
* depuis shared/ vers modules/ — chaque composable s'auto-enregistre.
|
||||
*/
|
||||
const onSessionClearedCallbacks: Array<() => void> = []
|
||||
|
||||
/**
|
||||
* Enregistre un callback a invoquer lorsque clearSession() est appelee.
|
||||
* Typiquement invoque au setup-time du composable (module-level), donc
|
||||
* une seule fois par instance de composable singleton.
|
||||
*/
|
||||
export function onAuthSessionCleared(cb: () => void): void {
|
||||
onSessionClearedCallbacks.push(cb)
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
user: null as UserData | null,
|
||||
@@ -16,6 +35,10 @@ export const useAuthStore = defineStore('auth', {
|
||||
this.user = null
|
||||
this.checked = true
|
||||
this.isLoading = false
|
||||
// Notifie les composables singletons (useCurrentSite, etc.) afin
|
||||
// qu'ils reinitialisation leur etat — necessaire quand la session
|
||||
// est invalidee par un intercepteur 401 sans passer par logout.vue.
|
||||
onSessionClearedCallbacks.forEach((cb) => cb())
|
||||
},
|
||||
async ensureSession() {
|
||||
if (this.checked) {
|
||||
@@ -66,6 +89,18 @@ export const useAuthStore = defineStore('auth', {
|
||||
} catch {
|
||||
// Silently fail — user session might have expired
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Action dediee au switch du site courant (ticket 3 module Sites).
|
||||
* Utilisee par useCurrentSite apres la confirmation serveur, et en
|
||||
* rollback si la requete PATCH echoue apres une mutation optimistic.
|
||||
* Passer explicitement par une action plutot que muter user.currentSite
|
||||
* directement garantit la tracabilite Pinia (devtools).
|
||||
*/
|
||||
setCurrentSite(site: Site | null) {
|
||||
if (this.user) {
|
||||
this.user.currentSite = site
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface UserListItem {
|
||||
isAdmin: boolean
|
||||
roles: string[]
|
||||
directPermissions: string[]
|
||||
/** IRIs des sites autorises (ticket 2 module Sites). */
|
||||
sites: string[]
|
||||
}
|
||||
|
||||
export interface EffectivePermission {
|
||||
|
||||
24
frontend/shared/types/sites.ts
Normal file
24
frontend/shared/types/sites.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface Site {
|
||||
id: number
|
||||
name: string
|
||||
street: string
|
||||
complement: string | null
|
||||
postalCode: string
|
||||
city: string
|
||||
color: string
|
||||
/** Adresse complete reconstituee cote backend (getter computed). Lecture seule. */
|
||||
fullAddress: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload accepte en POST/PATCH /api/sites. Volontairement sans `fullAddress`
|
||||
* (computed cote backend) ni champs read-only (id, timestamps).
|
||||
*/
|
||||
export interface SiteInput {
|
||||
name: string
|
||||
street: string
|
||||
complement: string | null
|
||||
postalCode: string
|
||||
city: string
|
||||
color: string
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Site } from './sites'
|
||||
|
||||
export interface UserData {
|
||||
id: number
|
||||
username: string
|
||||
@@ -6,4 +8,8 @@ export interface UserData {
|
||||
isAdmin: boolean
|
||||
/** Codes de permission effectifs de l'utilisateur, tries alphabetiquement, sans doublon. */
|
||||
effectivePermissions: string[]
|
||||
/** Sites autorises pour l'utilisateur (ticket 2 module Sites). */
|
||||
sites: Site[]
|
||||
/** Site actuellement selectionne par l'utilisateur, ou null si aucun. */
|
||||
currentSite: Site | null
|
||||
}
|
||||
|
||||
42
frontend/shared/utils/__tests__/color.test.ts
Normal file
42
frontend/shared/utils/__tests__/color.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { isValidSiteColor } from '../color'
|
||||
|
||||
describe('isValidSiteColor', () => {
|
||||
it('accepte un hex majuscule', () => {
|
||||
expect(isValidSiteColor('#ABCDEF')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepte un hex minuscule', () => {
|
||||
expect(isValidSiteColor('#abcdef')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepte un hex mixte', () => {
|
||||
expect(isValidSiteColor('#0a1B2c')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepte les couleurs fixtures du projet', () => {
|
||||
expect(isValidSiteColor('#056CF2')).toBe(true)
|
||||
expect(isValidSiteColor('#F3CB00')).toBe(true)
|
||||
expect(isValidSiteColor('#74BF04')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejette un nom CSS', () => {
|
||||
expect(isValidSiteColor('red')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejette un hex court', () => {
|
||||
expect(isValidSiteColor('#FFF')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejette un hex sans diese', () => {
|
||||
expect(isValidSiteColor('FFFFFF')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejette un caractere non hex', () => {
|
||||
expect(isValidSiteColor('#12345G')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejette une chaine vide', () => {
|
||||
expect(isValidSiteColor('')).toBe(false)
|
||||
})
|
||||
})
|
||||
19
frontend/shared/utils/color.ts
Normal file
19
frontend/shared/utils/color.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Utilitaires de couleur partages.
|
||||
*
|
||||
* Aligne sur la regex backend stricte #RRGGBB (voir Site.php).
|
||||
*/
|
||||
|
||||
const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/
|
||||
|
||||
/**
|
||||
* Valide qu'une chaine respecte le format #RRGGBB strict (7 caracteres,
|
||||
* 6 chiffres hexadecimaux apres le #). Tolere la casse (majuscules,
|
||||
* minuscules, mixte).
|
||||
*
|
||||
* Utilise cote front par SiteDrawer pour bloquer le submit avant l'envoi
|
||||
* backend — miroir du pattern Symfony Assert\Regex sur Site::$color.
|
||||
*/
|
||||
export function isValidSiteColor(hex: string): boolean {
|
||||
return HEX_COLOR_REGEX.test(hex)
|
||||
}
|
||||
@@ -1,13 +1,26 @@
|
||||
import type {Config} from 'tailwindcss'
|
||||
|
||||
/**
|
||||
* Config Tailwind du projet Coltura.
|
||||
*
|
||||
* @nuxtjs/tailwindcss merge automatiquement les configs de chaque layer
|
||||
* Nuxt declare dans `nuxt.config.ts:extends`. Le layer `@malio/layer-ui`
|
||||
* apporte deja :
|
||||
* - borderRadius.malio (var CSS --m-radius)
|
||||
* - colors.m.{primary,surface,border,text,muted,bg,disabled,danger,
|
||||
* success,btn-*,site-blue,site-yellow,site-green}
|
||||
* - fontFamily.sans (Helvetica Neue)
|
||||
*
|
||||
* Cette config locale ne redeclare QUE ce qui est specifique a Coltura
|
||||
* ou absent de la config Malio — evite la duplication et les derives.
|
||||
*/
|
||||
export default <Partial<Config>>{
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['"Helvetica Neue"', 'Helvetica', 'Arial', 'sans-serif']
|
||||
},
|
||||
colors: {
|
||||
// Couleurs applicatives Coltura (hors namespace `m` reserve
|
||||
// au design system Malio partage).
|
||||
primary: {
|
||||
500: '#222783',
|
||||
},
|
||||
@@ -20,27 +33,10 @@ export default <Partial<Config>>{
|
||||
blue: {
|
||||
500: '#056CF2'
|
||||
},
|
||||
// Extensions au namespace `m` non couvertes par Malio 1.4.1.
|
||||
m: {
|
||||
primary: 'rgb(var(--m-primary) / <alpha-value>)',
|
||||
secondary: 'rgb(var(--m-secondary, 75 77 237) / <alpha-value>)',
|
||||
tertiary: 'rgb(var(--m-tertiary, 243 244 248) / <alpha-value>)',
|
||||
border: 'rgb(var(--m-border) / <alpha-value>)',
|
||||
text: 'rgb(var(--m-text) / <alpha-value>)',
|
||||
muted: 'rgb(var(--m-muted) / <alpha-value>)',
|
||||
bg: 'rgb(var(--m-bg) / <alpha-value>)',
|
||||
surface: 'rgb(var(--m-surface) / <alpha-value>)',
|
||||
disabled: 'rgb(var(--m-disabled) / <alpha-value>)',
|
||||
danger: 'rgb(var(--m-danger) / <alpha-value>)',
|
||||
success: 'rgb(var(--m-success) / <alpha-value>)',
|
||||
'btn-primary': 'rgb(var(--m-btn-primary) / <alpha-value>)',
|
||||
'btn-primary-hover': 'rgb(var(--m-btn-primary-hover) / <alpha-value>)',
|
||||
'btn-primary-active': 'rgb(var(--m-btn-primary-active) / <alpha-value>)',
|
||||
'btn-secondary': 'rgb(var(--m-btn-secondary) / <alpha-value>)',
|
||||
'btn-secondary-hover': 'rgb(var(--m-btn-secondary-hover) / <alpha-value>)',
|
||||
'btn-secondary-active': 'rgb(var(--m-btn-secondary-active) / <alpha-value>)',
|
||||
'btn-danger': 'rgb(var(--m-btn-danger) / <alpha-value>)',
|
||||
'btn-danger-hover': 'rgb(var(--m-btn-danger-hover) / <alpha-value>)',
|
||||
'btn-danger-active': 'rgb(var(--m-btn-danger-active) / <alpha-value>)',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
globals: true,
|
||||
|
||||
15
makefile
15
makefile
@@ -85,10 +85,23 @@ migration-migrate:
|
||||
|
||||
# Cree et initialise la base de test utilisee par PHPUnit
|
||||
# (le suffixe "_test" est applique automatiquement par Doctrine en APP_ENV=test)
|
||||
# Ordre : fixtures -> sync-permissions, car fixtures:load purge la table permission
|
||||
#
|
||||
# Ordre :
|
||||
# 1. migrations : crees le schema metier reel.
|
||||
# 2. schema:update : cree les tables mappees en `when@test` uniquement
|
||||
# (ex: fake_site_aware_entity du ticket 4) qui n'ont pas de migration.
|
||||
# `--force` sans `--complete` : ajoute les tables manquantes aux
|
||||
# mappings sans drop les tables DB non mappees (no-op sur un schema
|
||||
# deja aligne avec les migrations). Necessaire car le purger
|
||||
# doctrine:fixtures:load essaie de DELETE toutes les tables connues
|
||||
# via les mappings — si fake_site_aware_entity est mappe mais absent
|
||||
# en DB, le purger crash.
|
||||
# 3. fixtures -> sync-permissions : fixtures:load purge la table permission,
|
||||
# donc sync doit passer apres.
|
||||
test-db-setup:
|
||||
$(SYMFONY_CONSOLE) doctrine:database:create --env=test --if-not-exists
|
||||
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --env=test --no-interaction
|
||||
$(SYMFONY_CONSOLE) doctrine:schema:update --env=test --force
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
|
||||
|
||||
|
||||
@@ -39,6 +39,11 @@ final class Version20260417120000 extends AbstractMigration
|
||||
// - `postal_code` est limite a 10 caracteres pour laisser marge a
|
||||
// d'eventuels formats etrangers plus tard, tout en le validant
|
||||
// strictement en 5 chiffres cote applicatif (format FR).
|
||||
//
|
||||
// Note : `full_address` est restructure au ticket 2 (migration
|
||||
// Version20260420130000) en `street` + `complement` (nullable). La
|
||||
// structure d'origine est conservee ici pour ne pas casser les devs
|
||||
// qui ont deja joue cette migration en local.
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE site (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
|
||||
88
migrations/Version20260417150000.php
Normal file
88
migrations/Version20260417150000.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Module Sites - Ticket 2/4 : rattachement User ↔ Site.
|
||||
*
|
||||
* Introduit deux nouvelles structures sur le schema existant :
|
||||
* - la table de jointure `user_site` (M2M) : liste des sites autorises
|
||||
* pour chaque utilisateur.
|
||||
* - la colonne `"user".current_site_id` (M2O nullable) : site actuellement
|
||||
* selectionne par l'utilisateur pour son contexte UX.
|
||||
*
|
||||
* Cascades choisies :
|
||||
* - `user_site.user_id` → `ON DELETE CASCADE` : supprimer un user purge
|
||||
* naturellement ses rattachements.
|
||||
* - `user_site.site_id` → `ON DELETE CASCADE` : supprimer un site purge
|
||||
* tous les rattachements a ce site.
|
||||
* - `"user".current_site_id` → `ON DELETE SET NULL` : supprimer un site
|
||||
* repasse le currentSite des users concernes a NULL (plutot que de
|
||||
* detruire les users, ce qui serait catastrophique).
|
||||
*
|
||||
* Note sur l'emplacement du fichier (namespace racine `DoctrineMigrations`)
|
||||
* Conforme a l'exception documentee dans `CLAUDE.md` : tant que le bug de
|
||||
* tri alphabetique des MigrationsComparator Doctrine 3.x n'est pas resolu,
|
||||
* toute migration touchant a la topologie des tables (creation, FKs
|
||||
* cross-module) vit au namespace racine. La migration croise ici les tables
|
||||
* `"user"` (module Core) et `site` (module Sites) — placement racine donc
|
||||
* justifie pour garantir l'ordre d'execution deterministe vis-a-vis des
|
||||
* deux migrations d'init deja presentes.
|
||||
*/
|
||||
final class Version20260417150000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Module Sites : table user_site (M2M) + colonne user.current_site_id (M2O SET NULL).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// 1) Creation de la table de jointure user_site.
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE user_site (
|
||||
user_id INT NOT NULL,
|
||||
site_id INT NOT NULL,
|
||||
PRIMARY KEY (user_id, site_id)
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX IDX_user_site_user ON user_site (user_id)');
|
||||
$this->addSql('CREATE INDEX IDX_user_site_site ON user_site (site_id)');
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE user_site
|
||||
ADD CONSTRAINT FK_user_site_user
|
||||
FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE user_site
|
||||
ADD CONSTRAINT FK_user_site_site
|
||||
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE CASCADE
|
||||
SQL);
|
||||
|
||||
// 2) Ajout de la colonne nullable user.current_site_id + FK SET NULL.
|
||||
$this->addSql('ALTER TABLE "user" ADD current_site_id INT DEFAULT NULL');
|
||||
$this->addSql('CREATE INDEX IDX_user_current_site ON "user" (current_site_id)');
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE "user"
|
||||
ADD CONSTRAINT FK_user_current_site
|
||||
FOREIGN KEY (current_site_id) REFERENCES site (id) ON DELETE SET NULL
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Rollback en ordre inverse : enfants avant parents.
|
||||
$this->addSql('ALTER TABLE "user" DROP CONSTRAINT FK_user_current_site');
|
||||
$this->addSql('DROP INDEX IDX_user_current_site');
|
||||
$this->addSql('ALTER TABLE "user" DROP current_site_id');
|
||||
|
||||
$this->addSql('ALTER TABLE user_site DROP CONSTRAINT FK_user_site_site');
|
||||
$this->addSql('ALTER TABLE user_site DROP CONSTRAINT FK_user_site_user');
|
||||
$this->addSql('DROP TABLE user_site');
|
||||
}
|
||||
}
|
||||
78
migrations/Version20260420130000.php
Normal file
78
migrations/Version20260420130000.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Module Sites - Ticket 2/4 : restructuration de l'adresse.
|
||||
*
|
||||
* Splitte la colonne `site.full_address` (TEXT NOT NULL, multi-lignes) en
|
||||
* deux champs structures :
|
||||
* - `street` (VARCHAR(255) NOT NULL) : numero + voie ;
|
||||
* - `complement` (VARCHAR(255) DEFAULT NULL) : batiment, escalier, BP...
|
||||
*
|
||||
* L'adresse complete affichable est desormais reconstituee cote applicatif
|
||||
* par Site::getFullAddress() (concatenation multi-lignes street\n[complement\n]CP ville)
|
||||
* et exposee en lecture API via le groupe `site:read` + `me:read`. Plus de
|
||||
* colonne DB redondante.
|
||||
*
|
||||
* Strategie de backfill (entre creation des nouvelles colonnes et drop de
|
||||
* l'ancienne) :
|
||||
* - `street` recoit la totalite de l'ancien `full_address` pour ne perdre
|
||||
* aucune donnee. C'est imparfait pour les adresses multi-lignes mais
|
||||
* safe : aucun risque de tronquage si l'ancienne adresse depasse 255
|
||||
* chars (PostgreSQL leve une erreur explicite ; charge a l'admin de
|
||||
* nettoyer manuellement si necessaire).
|
||||
* - `complement` reste null : pas d'heuristique fiable pour decouper une
|
||||
* adresse libre en street/complement.
|
||||
*
|
||||
* Cette migration evite un `make db-reset` force pour les developpeurs
|
||||
* ayant deja joue Version20260417120000 dans son etat initial (table site
|
||||
* avec full_address). Les fixtures sont mises a jour en parallele.
|
||||
*/
|
||||
final class Version20260420130000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Module Sites : split full_address en street + complement (getter computed cote applicatif).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// 1) Ajout des nouvelles colonnes en mode permissif :
|
||||
// - `street` nullable temporairement pour permettre le backfill.
|
||||
// - `complement` definitivement nullable.
|
||||
$this->addSql('ALTER TABLE site ADD street VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE site ADD complement VARCHAR(255) DEFAULT NULL');
|
||||
|
||||
// 2) Backfill : recopier full_address dans street pour ne pas perdre
|
||||
// les donnees existantes. Les retours a la ligne sont preserves
|
||||
// (PostgreSQL VARCHAR accepte \n) ; un admin pourra reformater
|
||||
// apres coup si besoin. Cas d'adresse > 255 chars : la migration
|
||||
// echoue cleanly (pas de tronquage silencieux).
|
||||
$this->addSql('UPDATE site SET street = full_address');
|
||||
|
||||
// 3) Bascule street en NOT NULL une fois le backfill applique.
|
||||
$this->addSql('ALTER TABLE site ALTER COLUMN street SET NOT NULL');
|
||||
|
||||
// 4) Drop de l'ancienne colonne full_address.
|
||||
$this->addSql('ALTER TABLE site DROP full_address');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Recreation de full_address (NOT NULL via DEFAULT '' pour eviter
|
||||
// un crash si la table a deja des lignes), puis backfill inverse,
|
||||
// puis drop des nouvelles colonnes.
|
||||
$this->addSql("ALTER TABLE site ADD full_address TEXT NOT NULL DEFAULT ''");
|
||||
$this->addSql("UPDATE site SET full_address = street || COALESCE(E'\\n' || complement, '')");
|
||||
$this->addSql('ALTER TABLE site ALTER COLUMN full_address DROP DEFAULT');
|
||||
|
||||
$this->addSql('ALTER TABLE site DROP street');
|
||||
$this->addSql('ALTER TABLE site DROP complement');
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Module\Core\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
@@ -62,6 +63,15 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
denormalizationContext: ['groups' => ['role:write']],
|
||||
)]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['isSystem'])]
|
||||
// Filtres /admin/roles : recherche partielle insensible a la casse
|
||||
// (ILIKE) sur label/code — un admin qui tape "ad" doit trouver
|
||||
// "Administrateur". Les relations restent en exact (alimentees par un
|
||||
// <select> cote front, donc casse maitrisee).
|
||||
#[ApiFilter(SearchFilter::class, properties: [
|
||||
'label' => 'ipartial',
|
||||
'code' => 'ipartial',
|
||||
'permissions.code' => 'exact',
|
||||
])]
|
||||
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
|
||||
#[ORM\Table(name: '`role`')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])]
|
||||
|
||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
@@ -15,6 +18,14 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserProcessor;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
|
||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
||||
// Note architecture : User.php utilise SiteInterface (Shared) pour les
|
||||
// type-hints afin de ne pas coupler le module Core au module Sites.
|
||||
// La seule reference concrete a Site subsiste dans les metadonnees ORM
|
||||
// (targetEntity) via FQCN string, ce qui est obligatoire pour Doctrine.
|
||||
// SiteNotAuthorizedException est importee depuis Shared (sa location canonique).
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Exception\SiteNotAuthorizedException;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -53,6 +64,18 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
],
|
||||
denormalizationContext: ['groups' => ['user:write']],
|
||||
)]
|
||||
// Filtres /admin/users : recherche partielle insensible a la casse
|
||||
// (ILIKE) sur username + filtre bool isAdmin + filtres exacts sur les
|
||||
// relations (code de role ou nom de site).
|
||||
// Les relations sont filtrees par jointure : `rbacRoles.code=admin` declenche
|
||||
// un INNER JOIN user_role → role. `sites.name=Chatellerault` declenche
|
||||
// INNER JOIN user_site → site.
|
||||
#[ApiFilter(SearchFilter::class, properties: [
|
||||
'username' => 'ipartial',
|
||||
'rbacRoles.code' => 'exact',
|
||||
'sites.name' => 'exact',
|
||||
])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['isAdmin'])]
|
||||
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
|
||||
#[ORM\Table(name: '`user`')]
|
||||
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
@@ -107,6 +130,45 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
#[Groups(['me:read', 'user:list', 'user:rbac:write', 'user:rbac:read'])]
|
||||
private Collection $directPermissions;
|
||||
|
||||
/**
|
||||
* Sites autorises pour l'utilisateur (ticket 2 du module Sites).
|
||||
*
|
||||
* Relation ManyToMany avec table de jointure `user_site`. Fetch LAZY :
|
||||
* le chargement est defere jusqu'a l'acces explicite a la collection.
|
||||
* MeProvider (ou un futur provider avec JOIN FETCH) est responsable de
|
||||
* precharger cette collection pour /api/me afin d'eviter N+1.
|
||||
*
|
||||
* Le groupe `user:list` a ete retire deliberement (securite : evite
|
||||
* de fuiter la liste des sites de chaque user via GET /api/users).
|
||||
*
|
||||
* @var Collection<int, Site>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: 'App\Module\Sites\Domain\Entity\Site', inversedBy: 'users', fetch: 'LAZY')]
|
||||
#[ORM\JoinTable(name: 'user_site')]
|
||||
#[Groups(['me:read', 'user:rbac:read', 'user:rbac:write'])]
|
||||
private Collection $sites;
|
||||
|
||||
/**
|
||||
* Site courant selectionne par l'utilisateur (ticket 2 du module Sites).
|
||||
*
|
||||
* Relation ManyToOne nullable : un user peut ne pas avoir encore choisi
|
||||
* de site actif (par ex. apres creation avant premier login). La FK porte
|
||||
* `onDelete: SET NULL` pour que la suppression d'un site ne detruise pas
|
||||
* les users qui le pointaient — ils repassent simplement a `null`.
|
||||
*
|
||||
* Doit TOUJOURS pointer vers un site present dans `$sites`. L'invariant
|
||||
* est enforce par UserRbacProcessor qui bascule automatiquement a `null`
|
||||
* si le site courant est retire des sites autorises.
|
||||
*
|
||||
* Fetch LAZY : MeProvider (ou un futur provider avec JOIN FETCH) assure
|
||||
* le prechargement pour /api/me. Le groupe `user:list` a ete retire
|
||||
* deliberement (securite : evite de fuiter le site actif via /api/users).
|
||||
*/
|
||||
#[ORM\ManyToOne(targetEntity: 'App\Module\Sites\Domain\Entity\Site', fetch: 'LAZY')]
|
||||
#[ORM\JoinColumn(name: 'current_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['me:read'])]
|
||||
private ?SiteInterface $currentSite = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private ?string $password = null;
|
||||
|
||||
@@ -121,6 +183,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->rbacRoles = new ArrayCollection();
|
||||
$this->directPermissions = new ArrayCollection();
|
||||
$this->sites = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
@@ -313,4 +376,95 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
{
|
||||
$this->plainPassword = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Site>
|
||||
*/
|
||||
public function getSites(): Collection
|
||||
{
|
||||
return $this->sites;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent : ajouter deux fois le meme site n'entraine pas de doublon.
|
||||
* Synchronise la collection inverse Site::$users en memoire pour eviter
|
||||
* un etat incoherent entre les deux cotes de la M2M dans une meme
|
||||
* session Doctrine (cf. ticket 2 review point #1).
|
||||
*
|
||||
* Le parametre est type SiteInterface pour eviter le couplage Core → Sites.
|
||||
* En pratique seule App\Module\Sites\Domain\Entity\Site est passee ici.
|
||||
*/
|
||||
public function addSite(SiteInterface $site): static
|
||||
{
|
||||
if (!$this->sites->contains($site)) {
|
||||
$this->sites->add($site);
|
||||
// @phpstan-ignore-next-line : Site concret toujours passe en pratique
|
||||
$site->addUser($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire un site de la collection + maintient la collection inverse en
|
||||
* memoire (cf. addSite). Attention : ne met PAS a jour `$currentSite`
|
||||
* si le site retire en etait le courant — cet invariant est enforce
|
||||
* par UserRbacProcessor (cote applicatif) ou doit etre maintenu
|
||||
* explicitement par l'appelant. Voir Risque 2 du ticket 2 spec.
|
||||
*/
|
||||
public function removeSite(SiteInterface $site): static
|
||||
{
|
||||
if ($this->sites->removeElement($site)) {
|
||||
// @phpstan-ignore-next-line : Site concret toujours passe en pratique
|
||||
$site->removeUser($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Garde applicative rapide : teste la presence d'un site dans la
|
||||
* collection autorisee, via comparaison d'identite d'objet Doctrine.
|
||||
* Utilise par CurrentSiteProcessor pour valider un switch.
|
||||
*/
|
||||
public function hasSite(SiteInterface $site): bool
|
||||
{
|
||||
return $this->sites->contains($site);
|
||||
}
|
||||
|
||||
public function getCurrentSite(): ?SiteInterface
|
||||
{
|
||||
return $this->currentSite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter brut, sans garde. Usage interne pour les flux qui doivent
|
||||
* pouvoir positionner un site arbitraire ou null (reset de coherence
|
||||
* post-PATCH RBAC, fixtures, init). Pour le flux user-facing
|
||||
* "selectionner un site dans la liste autorisee", utiliser
|
||||
* switchCurrentSite() qui porte la garde domaine.
|
||||
*/
|
||||
public function setCurrentSite(?SiteInterface $currentSite): static
|
||||
{
|
||||
$this->currentSite = $currentSite;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Garde domaine du switch utilisateur : refuse un site qui n'est pas
|
||||
* dans la collection autorisee. Levee d'une exception domaine que le
|
||||
* processor HTTP traduit en 403 (pattern aligne sur Role::ensureDeletable
|
||||
* → SystemRoleDeletionException).
|
||||
*
|
||||
* @throws SiteNotAuthorizedException si $site n'appartient pas a $this->sites
|
||||
*/
|
||||
public function switchCurrentSite(SiteInterface $site): void
|
||||
{
|
||||
if (!$this->hasSite($site)) {
|
||||
throw SiteNotAuthorizedException::forSite($site);
|
||||
}
|
||||
|
||||
$this->currentSite = $site;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@ use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Domain\Exception\LastAdminProtectionException;
|
||||
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\PersistentCollection;
|
||||
use LogicException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
/**
|
||||
@@ -29,6 +31,21 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
* - Dernier admin global : impossible de retirer `isAdmin` si c'est le
|
||||
* dernier administrateur de l'instance, meme par un tiers. Enforce via
|
||||
* AdminHeadcountGuardInterface.
|
||||
* - Permission sites.manage : si le payload mute la collection `sites`,
|
||||
* la permission `sites.manage` est requise en plus de `core.users.manage`.
|
||||
* - Coherence currentSite (ticket 2 module Sites) : apres persist des
|
||||
* sites autorises, si le `currentSite` n'est plus dans la collection,
|
||||
* il est repositionne automatiquement :
|
||||
* a) repasse a `null` s'il pointait vers un site retire ;
|
||||
* b) est auto-selectionne sur le premier site de `sites` s'il etait
|
||||
* null alors que la collection vient d'etre modifiee et n'est pas vide.
|
||||
* Un second flush est emis uniquement si la coherence a du etre corrigee.
|
||||
* La garde coherence est skippee si ni les sites ni le currentSite n'ont
|
||||
* change (evite le silent site-switch sur un PATCH ne touchant pas aux sites).
|
||||
*
|
||||
* Atomicite : persistProcessor->process() + ensureCurrentSiteConsistency() sont
|
||||
* executes dans une meme transaction wrapInTransaction pour eviter un etat
|
||||
* partiellement persiste en cas d'erreur entre les deux flush.
|
||||
*
|
||||
* @implements ProcessorInterface<User, User>
|
||||
*/
|
||||
@@ -80,6 +97,87 @@ final class UserRbacProcessor implements ProcessorInterface
|
||||
}
|
||||
}
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
// Detection de la mutation de la collection `sites` avant tout flush.
|
||||
// La collection est deja denormalisee dans $data quand process() est appele.
|
||||
// On utilise PersistentCollection::isDirty() pour savoir si l'ORM a detecte
|
||||
// une modification depuis le chargement initial (ajout/retrait d'elements).
|
||||
$sitesCollection = $data->getSites();
|
||||
$sitesWereMutated = $sitesCollection instanceof PersistentCollection
|
||||
&& $sitesCollection->isDirty();
|
||||
|
||||
// Capture de l'ID du currentSite avant persist pour la detection post-flush.
|
||||
$originalCurrentSiteId = $data->getCurrentSite()?->getId();
|
||||
|
||||
// Garde sites.manage : la modification de la collection de sites rattaches
|
||||
// a un user est une operation sensible qui requiert une permission distincte
|
||||
// de core.users.manage (evite le bypass de sites.manage via /rbac).
|
||||
if ($sitesWereMutated && !$this->security->isGranted('sites.manage')) {
|
||||
throw new AccessDeniedHttpException(
|
||||
'La modification des sites rattaches a un user requiert la permission sites.manage.'
|
||||
);
|
||||
}
|
||||
|
||||
// Persistance + correction de coherence currentSite dans une seule transaction.
|
||||
// wrapInTransaction rollback automatiquement sur toute exception et la re-lance,
|
||||
// ce qui preserve le comportement attendu pour BadRequestHttpException.
|
||||
$result = null;
|
||||
$this->entityManager->wrapInTransaction(function () use (
|
||||
$data,
|
||||
$operation,
|
||||
$uriVariables,
|
||||
$context,
|
||||
$sitesWereMutated,
|
||||
$originalCurrentSiteId,
|
||||
&$result,
|
||||
): void {
|
||||
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
|
||||
// Garde coherence currentSite (ticket 2 module Sites).
|
||||
// Post-persist : le champ `sites` a ete applique par le persist processor.
|
||||
// On s'assure que `currentSite` pointe toujours vers un site present
|
||||
// dans la collection ou est recale automatiquement — mais UNIQUEMENT si
|
||||
// les sites ou le currentSite ont effectivement ete touches dans ce PATCH.
|
||||
$currentSiteChangedByPersist = $originalCurrentSiteId !== $data->getCurrentSite()?->getId();
|
||||
if ($sitesWereMutated || $currentSiteChangedByPersist) {
|
||||
$this->ensureCurrentSiteConsistency($data);
|
||||
}
|
||||
});
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique deux corrections post-persist sur `currentSite` :
|
||||
* - si l'actuel n'est plus dans `sites` apres update → repasse a null ;
|
||||
* - si null et `sites` non vide → auto-selectionne le premier site
|
||||
* (coherent avec le choix de ne jamais laisser un user rattache a
|
||||
* plusieurs sites sans contexte courant apres une mutation effective).
|
||||
*
|
||||
* N'emet un flush additionnel que si une correction a ete necessaire :
|
||||
* pas de cout DB sur la majorite des requetes /rbac qui ne touchent pas
|
||||
* aux sites.
|
||||
*
|
||||
* Cette methode ne doit etre appelee que si les sites ont reellement
|
||||
* ete mutes dans la requete courante (voir logique dans process()).
|
||||
*/
|
||||
private function ensureCurrentSiteConsistency(User $user): void
|
||||
{
|
||||
$currentSite = $user->getCurrentSite();
|
||||
$sites = $user->getSites();
|
||||
$changed = false;
|
||||
|
||||
if (null !== $currentSite && !$user->hasSite($currentSite)) {
|
||||
$user->setCurrentSite(null);
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if (null === $user->getCurrentSite() && !$sites->isEmpty()) {
|
||||
$user->setCurrentSite($sites->first() ?: null);
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,26 +8,50 @@ use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
|
||||
use App\Module\Core\Domain\Security\SystemRoles;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Module\Sites\Domain\Repository\SiteRepositoryInterface;
|
||||
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* Fixtures de base du module Core : 3 utilisateurs (1 admin + 2 standards)
|
||||
* rattaches aux roles systeme RBAC seedes par la migration Version20260414150034.
|
||||
* rattaches aux roles systeme RBAC seedes par la migration Version20260414150034,
|
||||
* puis (ticket 2 module Sites) rattaches a au moins un site avec un currentSite
|
||||
* coherent.
|
||||
*
|
||||
* Note : le purger Doctrine execute avant load() supprime l'ensemble des
|
||||
* entites managees, ce qui inclut la table role. On re-seede donc les roles
|
||||
* systeme de maniere idempotente avant de rattacher les utilisateurs, afin
|
||||
* que le workflow "make db-reset && make fixtures" reste one-shot.
|
||||
*
|
||||
* Dependance explicite a SitesFixtures (ticket 2) : les 3 sites Chatellerault,
|
||||
* Saint-Jean et Pommevic doivent etre presents en base avant d'etre rattaches
|
||||
* aux users. L'inversion volontaire de l'ordre (AppFixtures ← SitesFixtures)
|
||||
* casse l'independance declaree au ticket 1 : c'est un couplage assume car
|
||||
* apres ticket 2 le modele metier exprime un besoin legitime de rattachement.
|
||||
*/
|
||||
class AppFixtures extends Fixture
|
||||
class AppFixtures extends Fixture implements DependentFixtureInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
private readonly RoleRepositoryInterface $roleRepository,
|
||||
private readonly SiteRepositoryInterface $siteRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<int, class-string>
|
||||
*/
|
||||
public function getDependencies(): array
|
||||
{
|
||||
// SitesFixtures doit tourner AVANT AppFixtures pour que les sites
|
||||
// soient disponibles au rattachement des users ci-dessous.
|
||||
return [SitesFixtures::class];
|
||||
}
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
$adminRole = $this->ensureSystemRole(
|
||||
@@ -43,23 +67,43 @@ class AppFixtures extends Fixture
|
||||
'Role de base sans permission specifique',
|
||||
);
|
||||
|
||||
// Recupere les 3 sites seedes par SitesFixtures. Si absents, c'est
|
||||
// une misconfiguration (fixture hors purge ou dependance ignoree) :
|
||||
// on fail fort avec un message explicite plutot que de continuer
|
||||
// avec des users orphelins de site.
|
||||
$chatellerault = $this->requireSite('Chatellerault');
|
||||
$saintJean = $this->requireSite('Saint-Jean');
|
||||
$pommevic = $this->requireSite('Pommevic');
|
||||
|
||||
$admin = new User();
|
||||
$admin->setUsername('admin');
|
||||
$admin->setIsAdmin(true);
|
||||
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
|
||||
$admin->addRbacRole($adminRole);
|
||||
// Admin rattache aux 3 sites pour faciliter le dev / les tests manuels.
|
||||
$admin->addSite($chatellerault);
|
||||
$admin->addSite($saintJean);
|
||||
$admin->addSite($pommevic);
|
||||
$admin->setCurrentSite($chatellerault);
|
||||
$manager->persist($admin);
|
||||
|
||||
$alice = new User();
|
||||
$alice->setUsername('alice');
|
||||
$alice->setPassword($this->passwordHasher->hashPassword($alice, 'alice'));
|
||||
$alice->addRbacRole($userRole);
|
||||
// Alice : un seul site, site courant = ce site.
|
||||
$alice->addSite($chatellerault);
|
||||
$alice->setCurrentSite($chatellerault);
|
||||
$manager->persist($alice);
|
||||
|
||||
$bob = new User();
|
||||
$bob->setUsername('bob');
|
||||
$bob->setPassword($this->passwordHasher->hashPassword($bob, 'bob'));
|
||||
$bob->addRbacRole($userRole);
|
||||
// Bob : site different de Alice, pour prouver le filtrage par site
|
||||
// dans les futurs tests (ticket 4 outillage SiteAware).
|
||||
$bob->addSite($saintJean);
|
||||
$bob->setCurrentSite($saintJean);
|
||||
$manager->persist($bob);
|
||||
|
||||
$manager->flush();
|
||||
@@ -90,4 +134,19 @@ class AppFixtures extends Fixture
|
||||
|
||||
return $role;
|
||||
}
|
||||
|
||||
private function requireSite(string $name): Site
|
||||
{
|
||||
$site = $this->siteRepository->findByName($name);
|
||||
|
||||
if (null === $site) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'SitesFixtures doit avoir seede le site "%s" avant le chargement des users. '
|
||||
.'Verifier que SitesFixtures est bien en dependance de AppFixtures.',
|
||||
$name,
|
||||
));
|
||||
}
|
||||
|
||||
return $site;
|
||||
}
|
||||
}
|
||||
|
||||
69
src/Module/Sites/Application/Service/CurrentSiteProvider.php
Normal file
69
src/Module/Sites/Application/Service/CurrentSiteProvider.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Application\Service;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Module\Sites\SitesModule;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
use function in_array;
|
||||
|
||||
/**
|
||||
* Resout le site courant de l'utilisateur authentifie pour les besoins de
|
||||
* l'outillage opt-in "site-aware" (ticket 4 module Sites).
|
||||
*
|
||||
* Consomme par :
|
||||
* - SiteScopedQueryExtension : filtrage automatique des collections API.
|
||||
* - SiteAwareInjectionProcessor : injection automatique sur POST/PATCH.
|
||||
*
|
||||
* Retourne `null` dans trois cas distincts (chacun volontairement
|
||||
* silencieux pour que les extensions/processor deviennent no-op sans
|
||||
* erreur visible) :
|
||||
* 1. Le module Sites est desactive dans `config/modules.php`.
|
||||
* 2. Aucun user n'est authentifie (appel depuis un endpoint public).
|
||||
* 3. L'user authentifie n'a pas de `currentSite` positionne (cas rare
|
||||
* grace a la garde `UserRbacProcessor::ensureCurrentSiteConsistency`).
|
||||
*
|
||||
* Le flag `sitesActive` est calcule UNE FOIS au boot du service pour
|
||||
* eviter un `require` a chaque resolution.
|
||||
*/
|
||||
final class CurrentSiteProvider implements CurrentSiteProviderInterface
|
||||
{
|
||||
private readonly bool $sitesActive;
|
||||
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
#[Autowire(param: 'kernel.project_dir')]
|
||||
string $projectDir,
|
||||
) {
|
||||
// Lit config/modules.php (tableau de FQCN) et verifie la presence
|
||||
// de SitesModule::class. Pattern aligne sur ModulesProvider.
|
||||
$configPath = $projectDir.'/config/modules.php';
|
||||
$moduleClasses = file_exists($configPath) ? require $configPath : [];
|
||||
|
||||
$this->sitesActive = in_array(SitesModule::class, $moduleClasses, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le site courant de l'utilisateur authentifie, ou null si
|
||||
* l'une des 3 conditions de desactivation est remplie (cf. docblock
|
||||
* de classe).
|
||||
*/
|
||||
public function get(): ?Site
|
||||
{
|
||||
if (!$this->sitesActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $user->getCurrentSite();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Application\Service;
|
||||
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
|
||||
/**
|
||||
* Contrat de resolution du site courant pour l'outillage opt-in
|
||||
* "site-aware" (ticket 4 module Sites).
|
||||
*
|
||||
* Facilite le test de l'extension et du processor en permettant un mock
|
||||
* sans dependre de l'implementation concrete (qui garde `final` pour
|
||||
* l'immutabilite du service en prod).
|
||||
*
|
||||
* Retourne `null` dans trois cas (cf. CurrentSiteProvider) :
|
||||
* - module Sites desactive dans config/modules.php
|
||||
* - pas d'user authentifie
|
||||
* - user sans currentSite positionne
|
||||
*/
|
||||
interface CurrentSiteProviderInterface
|
||||
{
|
||||
public function get(): ?Site;
|
||||
}
|
||||
@@ -4,43 +4,106 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Site physique (usine / etablissement) appartenant a l'instance Coltura.
|
||||
*
|
||||
* Brique fondatrice du module Sites : cette entite n'est pas exposee par
|
||||
* une ressource API Platform dans ce ticket (ticket 1/4). Elle sert de socle
|
||||
* de donnees aux tickets suivants (rattachement utilisateurs, affichage
|
||||
* navbar, etc.). Aucune dependance dure depuis le module Core : la table
|
||||
* est creee meme si le module est desactive (voir migration dediee).
|
||||
* Adresse decomposee en champs structures (rue, complement, CP, ville) pour
|
||||
* permettre des recherches/tris fins ulterieurs et eviter les divergences
|
||||
* entre champs duplique. La methode `getFullAddress()` fournit la version
|
||||
* concatenee multi-lignes pour les usages d'affichage.
|
||||
*
|
||||
* Expose en API Platform pour l'administration CRUD avec RBAC :
|
||||
* - lecture (GET list / item) : requiert la permission `sites.view`
|
||||
* - ecriture (POST / PATCH / DELETE) : requiert la permission `sites.manage`
|
||||
*
|
||||
* Egalement embarque dans la reponse `/api/me` (groupe `me:read`) pour que
|
||||
* le frontend connaisse les sites autorises et le site courant de l'user.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
normalizationContext: ['groups' => ['site:read']],
|
||||
security: "is_granted('sites.view')",
|
||||
),
|
||||
new Get(
|
||||
normalizationContext: ['groups' => ['site:read']],
|
||||
security: "is_granted('sites.view')",
|
||||
),
|
||||
new Post(
|
||||
normalizationContext: ['groups' => ['site:read']],
|
||||
denormalizationContext: ['groups' => ['site:write']],
|
||||
security: "is_granted('sites.manage')",
|
||||
),
|
||||
new Patch(
|
||||
normalizationContext: ['groups' => ['site:read']],
|
||||
denormalizationContext: ['groups' => ['site:write']],
|
||||
security: "is_granted('sites.manage')",
|
||||
),
|
||||
new Delete(security: "is_granted('sites.manage')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['site:read']],
|
||||
denormalizationContext: ['groups' => ['site:write']],
|
||||
)]
|
||||
// Filtres cote API pour /admin/sites : recherche partielle insensible a
|
||||
// la casse (SQL ILIKE %x%) sur les champs texte saisis dans les headers
|
||||
// de la DataTable. postalCode est purement numerique donc le I/partial
|
||||
// donne le meme resultat, mais on reste coherent avec name/city.
|
||||
#[ApiFilter(SearchFilter::class, properties: [
|
||||
'name' => 'ipartial',
|
||||
'city' => 'ipartial',
|
||||
'postalCode' => 'ipartial',
|
||||
])]
|
||||
#[ORM\Entity(repositoryClass: DoctrineSiteRepository::class)]
|
||||
#[ORM\Table(name: 'site')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_site_name', columns: ['name'])]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[UniqueEntity(fields: ['name'], message: 'Un site avec ce nom existe deja.')]
|
||||
class Site
|
||||
class Site implements SiteInterface
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['site:read', 'me:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 100)]
|
||||
#[Assert\NotBlank(message: 'Le nom du site est requis.')]
|
||||
#[Assert\Length(max: 100, maxMessage: 'Le nom du site ne peut pas depasser {{ limit }} caracteres.')]
|
||||
#[Groups(['site:read', 'site:write', 'me:read'])]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(length: 100)]
|
||||
#[Assert\NotBlank(message: 'La ville du site est requise.')]
|
||||
#[Assert\Length(max: 100, maxMessage: 'La ville ne peut pas depasser {{ limit }} caracteres.')]
|
||||
private string $city;
|
||||
// Premiere ligne d'adresse : numero + voie. Requise.
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank(message: 'La rue est requise.')]
|
||||
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut pas depasser {{ limit }} caracteres.')]
|
||||
#[Groups(['site:read', 'site:write', 'me:read'])]
|
||||
private string $street;
|
||||
|
||||
// Complement d'adresse optionnel : batiment, escalier, BP, etc.
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255, maxMessage: 'Le complement ne peut pas depasser {{ limit }} caracteres.')]
|
||||
#[Groups(['site:read', 'site:write', 'me:read'])]
|
||||
private ?string $complement = null;
|
||||
|
||||
// Colonne mappee sur le snake_case PostgreSQL (convention projet : noms de
|
||||
// colonnes en minuscules dans le SQL brut). Le format est contraint au
|
||||
@@ -52,50 +115,71 @@ class Site
|
||||
pattern: '/^\d{5}$/',
|
||||
message: 'Le code postal doit etre compose de 5 chiffres (format FR).',
|
||||
)]
|
||||
#[Groups(['site:read', 'site:write', 'me:read'])]
|
||||
private string $postalCode;
|
||||
|
||||
#[ORM\Column(length: 100)]
|
||||
#[Assert\NotBlank(message: 'La ville du site est requise.')]
|
||||
#[Assert\Length(max: 100, maxMessage: 'La ville ne peut pas depasser {{ limit }} caracteres.')]
|
||||
#[Groups(['site:read', 'site:write', 'me:read'])]
|
||||
private string $city;
|
||||
|
||||
// Couleur d'identification visuelle du site au format hex #RRGGBB (7 chars
|
||||
// incluant le diese). Utilisee plus tard par la navbar (ticket 3) pour
|
||||
// distinguer les sites d'un coup d'oeil.
|
||||
// incluant le diese). Utilisee par la navbar (ticket 3) pour distinguer
|
||||
// les sites d'un coup d'oeil.
|
||||
#[ORM\Column(length: 7)]
|
||||
#[Assert\NotBlank(message: 'La couleur est requise.')]
|
||||
#[Assert\Regex(
|
||||
pattern: '/^#[0-9A-Fa-f]{6}$/',
|
||||
message: 'La couleur doit etre un code hex de 7 caracteres au format #RRGGBB.',
|
||||
)]
|
||||
#[Groups(['site:read', 'site:write', 'me:read'])]
|
||||
private string $color;
|
||||
|
||||
// Champ TEXT volontaire : l'adresse complete peut courir sur plusieurs
|
||||
// lignes (voie + complement + mention particuliere). Borne haute a 500
|
||||
// caracteres : une adresse francaise complete tient tres largement dans
|
||||
// cette enveloppe, et la limite applicative protege contre les payloads
|
||||
// anormalement volumineux envoyes par un client (garde DoS basique).
|
||||
#[ORM\Column(name: 'full_address', type: Types::TEXT)]
|
||||
#[Assert\NotBlank(message: 'L\'adresse complete est requise.')]
|
||||
#[Assert\Length(max: 500, maxMessage: 'L\'adresse complete ne peut pas depasser {{ limit }} caracteres.')]
|
||||
private string $fullAddress;
|
||||
|
||||
// createdAt / updatedAt volontairement exclus du groupe `me:read` :
|
||||
// le payload `/api/me` doit rester leger, ces metadonnees ne sont utiles
|
||||
// qu'a l'admin (exposees uniquement via `site:read` sur /api/sites).
|
||||
#[ORM\Column(name: 'created_at', type: Types::DATETIME_IMMUTABLE)]
|
||||
#[Groups(['site:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: Types::DATETIME_IMMUTABLE)]
|
||||
#[Groups(['site:read'])]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
/**
|
||||
* Collection inverse des users rattaches a ce site.
|
||||
*
|
||||
* Volontairement SANS `#[Groups]` : la collection n'est jamais exposee via
|
||||
* l'API pour deux raisons :
|
||||
* - eviter une boucle de serialisation infinie User → sites → users → ...
|
||||
* si un jour un developpeur ajoute `me:read` ici par megarde ;
|
||||
* - l'inverse n'a de valeur qu'en interne (compter les users d'un site,
|
||||
* iterer en test de cascade).
|
||||
*
|
||||
* @var Collection<int, User>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: User::class, mappedBy: 'sites')]
|
||||
private Collection $users;
|
||||
|
||||
public function __construct(
|
||||
string $name,
|
||||
string $city,
|
||||
string $street,
|
||||
?string $complement,
|
||||
string $postalCode,
|
||||
string $city,
|
||||
string $color,
|
||||
string $fullAddress,
|
||||
) {
|
||||
$this->name = $name;
|
||||
$this->city = $city;
|
||||
$this->postalCode = $postalCode;
|
||||
$this->color = $color;
|
||||
$this->fullAddress = $fullAddress;
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
$this->name = $name;
|
||||
$this->street = $street;
|
||||
$this->complement = $complement;
|
||||
$this->postalCode = $postalCode;
|
||||
$this->city = $city;
|
||||
$this->color = $color;
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
$this->users = new ArrayCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,14 +209,26 @@ class Site
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCity(): string
|
||||
public function getStreet(): string
|
||||
{
|
||||
return $this->city;
|
||||
return $this->street;
|
||||
}
|
||||
|
||||
public function setCity(string $city): static
|
||||
public function setStreet(string $street): static
|
||||
{
|
||||
$this->city = $city;
|
||||
$this->street = $street;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getComplement(): ?string
|
||||
{
|
||||
return $this->complement;
|
||||
}
|
||||
|
||||
public function setComplement(?string $complement): static
|
||||
{
|
||||
$this->complement = $complement;
|
||||
|
||||
return $this;
|
||||
}
|
||||
@@ -149,6 +245,18 @@ class Site
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCity(): string
|
||||
{
|
||||
return $this->city;
|
||||
}
|
||||
|
||||
public function setCity(string $city): static
|
||||
{
|
||||
$this->city = $city;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getColor(): string
|
||||
{
|
||||
return $this->color;
|
||||
@@ -161,16 +269,26 @@ class Site
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse complete reconstituee : street, [complement,] {CP} {ville},
|
||||
* separes par des sauts de ligne. Methode pure, jamais persistee.
|
||||
*
|
||||
* Expose en lecture API (groupes site:read + me:read) pour que les
|
||||
* consommateurs (frontend, exports PDF) recoivent une adresse prete a
|
||||
* afficher sans dupliquer la logique de concatenation cote client.
|
||||
*/
|
||||
#[Groups(['site:read', 'me:read'])]
|
||||
public function getFullAddress(): string
|
||||
{
|
||||
return $this->fullAddress;
|
||||
}
|
||||
$lines = [$this->street];
|
||||
|
||||
public function setFullAddress(string $fullAddress): static
|
||||
{
|
||||
$this->fullAddress = $fullAddress;
|
||||
if (null !== $this->complement && '' !== trim($this->complement)) {
|
||||
$lines[] = $this->complement;
|
||||
}
|
||||
|
||||
return $this;
|
||||
$lines[] = sprintf('%s %s', $this->postalCode, $this->city);
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
@@ -182,4 +300,39 @@ class Site
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, User>
|
||||
*/
|
||||
public function getUsers(): Collection
|
||||
{
|
||||
return $this->users;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronise la collection inverse cote Site quand User::addSite est
|
||||
* appele. Idempotent. Ne re-appelle pas $user->addSite($this) pour
|
||||
* eviter une recursion infinie : User::addSite est le point d'entree
|
||||
* unique de la mutation.
|
||||
*
|
||||
* @internal Appele uniquement par User::addSite()
|
||||
*/
|
||||
public function addUser(User $user): static
|
||||
{
|
||||
if (!$this->users->contains($user)) {
|
||||
$this->users->add($user);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Appele uniquement par User::removeSite()
|
||||
*/
|
||||
public function removeUser(User $user): static
|
||||
{
|
||||
$this->users->removeElement($user);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Domain\Exception;
|
||||
|
||||
use App\Shared\Domain\Exception\SiteNotAuthorizedException as SharedSiteNotAuthorizedException;
|
||||
|
||||
/**
|
||||
* Alias de retrocompatibilite vers Shared\Domain\Exception\SiteNotAuthorizedException.
|
||||
*
|
||||
* La classe canonique a ete deplacee dans Shared pour rompre le couplage
|
||||
* Core → Sites. Les consommateurs existants dans le module Sites
|
||||
* (CurrentSiteProcessor) continuent de l'attraper ici sans modification.
|
||||
*
|
||||
* @see SharedSiteNotAuthorizedException
|
||||
*/
|
||||
final class SiteNotAuthorizedException extends SharedSiteNotAuthorizedException {}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Infrastructure\ApiPlatform\Extension;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
* Extension API Platform qui restreint les collections et items de la
|
||||
* resource Site (/api/sites) aux seuls sites auxquels l'utilisateur
|
||||
* authentifie est rattache (ticket module Sites — prevention de la fuite
|
||||
* de donnees cross-tenant).
|
||||
*
|
||||
* `Site` n'implemente pas `SiteAwareInterface` (ce serait circulaire : un
|
||||
* site ne s'appartient pas a lui-meme). Cette extension complementaire
|
||||
* cible specifiquement `Site::class` et applique un filtre IN sur les IDs
|
||||
* des sites de l'utilisateur.
|
||||
*
|
||||
* Comportement selon les cas :
|
||||
* - resource != Site::class → no-op (les autres resources sont
|
||||
* gerees par SiteScopedQueryExtension) ;
|
||||
* - is_granted('sites.bypass_scope') → pas de filtre (admin / bypass) ;
|
||||
* - user non authentifie → no-op (API Platform renvoie 401 avant) ;
|
||||
* - user sans aucun site → WHERE 1 = 0 (aucun acces) ;
|
||||
* - cas normal → WHERE site.id IN (:allowedSites).
|
||||
*
|
||||
* Consequence anti-enumeration : GET /api/sites/{id} retourne 404 et non
|
||||
* 403 si l'item existe mais n'appartient pas aux sites de l'utilisateur
|
||||
* (comportement natif API Platform quand Doctrine retourne null).
|
||||
*/
|
||||
final class SiteCollectionScopedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function applyToCollection(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
$this->applyScope($queryBuilder, $queryNameGenerator, $resourceClass);
|
||||
}
|
||||
|
||||
public function applyToItem(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
array $identifiers,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
$this->applyScope($queryBuilder, $queryNameGenerator, $resourceClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique le filtre IN sur les IDs de sites autorises si les conditions
|
||||
* d'application sont remplies. No-op sinon.
|
||||
*/
|
||||
private function applyScope(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
): void {
|
||||
// 1) Cette extension cible uniquement la resource Site.
|
||||
if (Site::class !== $resourceClass) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Admin ou user avec bypass explicite : visibilite globale.
|
||||
if ($this->security->isGranted('sites.bypass_scope')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) Pas d'user authentifie -> no-op (API Platform gere le 401 en amont).
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rootAlias = $queryBuilder->getRootAliases()[0];
|
||||
|
||||
// 4) User sans aucun site rattache -> aucun acces possible.
|
||||
$siteIds = $user->getSites()->map(fn (Site $s) => $s->getId())->toArray();
|
||||
if (empty($siteIds)) {
|
||||
$queryBuilder->andWhere('1 = 0');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 5) Cas normal : restriction aux sites autorises de l'utilisateur.
|
||||
$param = $queryNameGenerator->generateParameterName('allowedSites');
|
||||
$queryBuilder
|
||||
->andWhere(sprintf('%s.id IN (:%s)', $rootAlias, $param))
|
||||
->setParameter($param, $siteIds)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Infrastructure\ApiPlatform\Extension;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Shared\Domain\Contract\SiteAwareInterface;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
use function is_subclass_of;
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
* Extension API Platform qui filtre automatiquement les collections et les
|
||||
* items des resources implementant SiteAwareInterface selon le site
|
||||
* courant de l'utilisateur authentifie (ticket 4 module Sites).
|
||||
*
|
||||
* Appliquee automatiquement par API Platform sur toutes les requetes GET
|
||||
* (collection + item), mais devient no-op si :
|
||||
* - la resource cible n'implemente pas SiteAwareInterface ;
|
||||
* - l'user a la permission `sites.bypass_scope` ;
|
||||
* - CurrentSiteProvider::get() retourne null (module desactive, pas
|
||||
* d'user authentifie, ou user sans currentSite).
|
||||
*
|
||||
* Le filtrage est identique pour les deux interfaces Collection et Item,
|
||||
* factorise dans `applyScope()`. Consequence sur GET /api/resource/{id} :
|
||||
* si l'item existe en base mais appartient a un autre site, Doctrine
|
||||
* retourne null apres filtrage et API Platform converti en 404
|
||||
* (anti-enumeration : le user ne peut pas distinguer "n'existe pas" de
|
||||
* "appartient a un autre site").
|
||||
*/
|
||||
final class SiteScopedQueryExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function applyToCollection(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
$this->applyScope($queryBuilder, $queryNameGenerator, $resourceClass);
|
||||
}
|
||||
|
||||
public function applyToItem(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
array $identifiers,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
$this->applyScope($queryBuilder, $queryNameGenerator, $resourceClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute la clause `WHERE <alias>.site = :currentSite` au query builder
|
||||
* si les 3 conditions d'application sont remplies. No-op sinon.
|
||||
*/
|
||||
private function applyScope(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
): void {
|
||||
// 1) Filtrer uniquement les resources qui ont opt-in via l'interface.
|
||||
// `is_subclass_of` gere a la fois `implements` direct et herite.
|
||||
if (!is_subclass_of($resourceClass, SiteAwareInterface::class)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Admin ou user avec bypass explicite : visibilite globale.
|
||||
// is_granted('sites.bypass_scope') retourne true pour les admins
|
||||
// (bypass total via isAdmin) meme sans permission explicite.
|
||||
if ($this->security->isGranted('sites.bypass_scope')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) Pas de site courant -> no-op plutot que collection vide.
|
||||
// Decision assumee (cf. ticket 4 spec Risque 1) : un user sans
|
||||
// currentSite voit tout. L'alternative "collection vide" est
|
||||
// rejetee car elle rendrait l'app inutilisable pour un user
|
||||
// mal configure.
|
||||
$currentSite = $this->currentSiteProvider->get();
|
||||
if (null === $currentSite) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Application du filtre : alias racine du QueryBuilder, parametre
|
||||
// genere pour eviter les collisions avec d'autres extensions.
|
||||
$rootAlias = $queryBuilder->getRootAliases()[0];
|
||||
$parameterName = $queryNameGenerator->generateParameterName('currentSite');
|
||||
|
||||
$queryBuilder
|
||||
->andWhere(sprintf('%s.site = :%s', $rootAlias, $parameterName))
|
||||
->setParameter($parameterName, $currentSite)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Infrastructure\ApiPlatform\Extension;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
* Extension API Platform qui restreint /api/users (collection + item) aux
|
||||
* utilisateurs partageant au moins un site commun avec l'appelant.
|
||||
*
|
||||
* Objectif : empecher l'enumeration cross-site des utilisateurs. Sans ce
|
||||
* filtre, un user du site A pourrait lister tous les users du site B via
|
||||
* GET /api/users.
|
||||
*
|
||||
* Conditions de bypass :
|
||||
* - is_granted('sites.bypass_scope') → visibilite totale (admin ou bypass
|
||||
* explicite) ;
|
||||
* - user non authentifie → no-op (API Platform renvoie 401) ;
|
||||
*
|
||||
* Cas particulier — appelant sans aucun site rattache :
|
||||
* Comportement defensif : l'utilisateur ne voit que lui-meme. Cela evite
|
||||
* de bloquer completement un user mal configure tout en ne lui revelant
|
||||
* aucun autre utilisateur.
|
||||
*
|
||||
* Strategie DQL : JOIN sur la relation ManyToMany `.sites` + DISTINCT pour
|
||||
* eviter les doublons si un user partage plusieurs sites avec l'appelant.
|
||||
* Le alias `s_scope` est utilise pour la jointure intermediaire.
|
||||
*/
|
||||
final class UserSiteScopedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function applyToCollection(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
$this->applyScope($queryBuilder, $queryNameGenerator, $resourceClass);
|
||||
}
|
||||
|
||||
public function applyToItem(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
array $identifiers,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
$this->applyScope($queryBuilder, $queryNameGenerator, $resourceClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique le filtre de partage de site si les conditions d'application
|
||||
* sont remplies. No-op sinon.
|
||||
*/
|
||||
private function applyScope(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
): void {
|
||||
// 1) Cette extension cible uniquement la resource User.
|
||||
if (User::class !== $resourceClass) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Admin ou bypass explicite : visibilite totale.
|
||||
if ($this->security->isGranted('sites.bypass_scope')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) Pas d'user authentifie -> no-op (API Platform gere le 401 en amont).
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rootAlias = $queryBuilder->getRootAliases()[0];
|
||||
$callerSiteIds = $user->getSites()->map(fn (Site $s) => $s->getId())->toArray();
|
||||
|
||||
// 4) Appelant sans site : comportement defensif -> il ne voit que lui-meme.
|
||||
if (empty($callerSiteIds)) {
|
||||
$queryBuilder
|
||||
->andWhere(sprintf('%s.id = :self', $rootAlias))
|
||||
->setParameter('self', $user->getId())
|
||||
;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 5) Cas normal : garder uniquement les users qui partagent au moins
|
||||
// un site avec l'appelant. JOIN sur la relation ManyToMany `.sites`
|
||||
// + filtre IN + DISTINCT pour eviter les lignes dupliquees.
|
||||
$param = $queryNameGenerator->generateParameterName('callerSites');
|
||||
$queryBuilder
|
||||
->innerJoin(sprintf('%s.sites', $rootAlias), 's_scope')
|
||||
->andWhere(sprintf('s_scope.id IN (:%s)', $param))
|
||||
->setParameter($param, $callerSiteIds)
|
||||
->distinct()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Infrastructure\ApiPlatform\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Module\Sites\Infrastructure\ApiPlatform\State\Processor\CurrentSiteProcessor;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
/**
|
||||
* Ressource API Platform virtuelle (non mappee Doctrine) qui porte
|
||||
* l'operation `PATCH /api/me/current-site` : basculement du site courant
|
||||
* de l'utilisateur authentifie.
|
||||
*
|
||||
* `read: false` informe API Platform qu'il ne doit pas tenter de charger
|
||||
* une entite existante via un Provider — l'operation denormalise le payload
|
||||
* directement dans cette ressource, puis CurrentSiteProcessor prend le relais.
|
||||
*
|
||||
* `shortName: 'CurrentSite'` : evite toute collision avec l'entite `Site`
|
||||
* dans le routage et la documentation OpenAPI.
|
||||
*
|
||||
* Securite : l'autorisation "ROLE_USER" suffit au niveau voter — la verification
|
||||
* fine (le site demande fait-il partie des sites autorises de l'user ?)
|
||||
* est faite par CurrentSiteProcessor, car elle dependence de l'user
|
||||
* authentifie, pas d'une permission statique.
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'CurrentSite',
|
||||
operations: [
|
||||
new Patch(
|
||||
uriTemplate: '/me/current-site',
|
||||
security: "is_granted('ROLE_USER')",
|
||||
normalizationContext: ['groups' => ['me:read']],
|
||||
denormalizationContext: ['groups' => ['current-site:write']],
|
||||
processor: CurrentSiteProcessor::class,
|
||||
read: false,
|
||||
priority: 1,
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class CurrentSiteResource
|
||||
{
|
||||
/**
|
||||
* Site cible du switch, denormalise depuis l'IRI envoye dans le body :
|
||||
* `{ "site": "/api/sites/{id}" }`. Resolu automatiquement par
|
||||
* l'IriConverter d'API Platform en instance de `Site`.
|
||||
*/
|
||||
#[Groups(['current-site:write'])]
|
||||
public ?Site $site = null;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Sites\Domain\Exception\SiteNotAuthorizedException;
|
||||
use App\Module\Sites\Infrastructure\ApiPlatform\Resource\CurrentSiteResource;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\OptimisticLockException;
|
||||
use LogicException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
/**
|
||||
* Processor de l'operation `PATCH /api/me/current-site`.
|
||||
*
|
||||
* Flux :
|
||||
* 1. Recupere l'user authentifie via Security.
|
||||
* 2. Extrait le site cible depuis la ressource denormalisee.
|
||||
* 3. Refresh de l'user depuis la BDD pour eliminer la race condition TOCTOU :
|
||||
* si un autre thread a revoque le site entre le chargement de session et
|
||||
* ce PATCH, le refresh garantit que hasSite() reflete l'etat reel en base.
|
||||
* 4. Valide que le site fait partie des `sites` de l'user — sinon leve
|
||||
* SiteNotAuthorizedException convertie immediatement en 403.
|
||||
* 5. Positionne `currentSite`, flush, retourne l'user pour normalisation
|
||||
* par API Platform via les groupes `me:read` (payload identique a /api/me).
|
||||
*
|
||||
* Les etapes 3-5 sont executees dans une meme transaction pour garantir
|
||||
* un rollback propre en cas d'erreur entre le refresh et le flush.
|
||||
*
|
||||
* @implements ProcessorInterface<CurrentSiteResource, User>
|
||||
*/
|
||||
final class CurrentSiteProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof CurrentSiteResource) {
|
||||
throw new LogicException(sprintf(
|
||||
'CurrentSiteProcessor attend une instance de %s, %s recu.',
|
||||
CurrentSiteResource::class,
|
||||
get_debug_type($data),
|
||||
));
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
// security: "is_granted('ROLE_USER')" sur l'operation doit deja
|
||||
// bloquer ce cas — garde defensive si la config change.
|
||||
throw new AccessDeniedHttpException('Authentification requise pour changer de site courant.');
|
||||
}
|
||||
|
||||
$targetSite = $data->site;
|
||||
if (null === $targetSite) {
|
||||
throw new BadRequestHttpException('Le champ "site" est requis.');
|
||||
}
|
||||
|
||||
// Refresh + switchCurrentSite + flush dans une transaction atomique.
|
||||
// Le refresh elimine la race condition TOCTOU : si un PATCH /rbac concurrent
|
||||
// a revoque le site de l'user entre le chargement de session et ici, le
|
||||
// refresh force un re-fetch de l'user et de sa collection de sites depuis
|
||||
// la BDD, garantissant que hasSite() reflete l'etat reel persisté.
|
||||
try {
|
||||
$this->entityManager->wrapInTransaction(function () use ($user, $targetSite): void {
|
||||
// Re-fetch de l'user + ses collections depuis la BDD (elimination TOCTOU).
|
||||
$this->entityManager->refresh($user);
|
||||
|
||||
try {
|
||||
$user->switchCurrentSite($targetSite);
|
||||
} catch (SiteNotAuthorizedException $e) {
|
||||
// Traduction HTTP immediate (pas de listener kernel necessaire) :
|
||||
// aligne sur le pattern RoleProcessor → SystemRoleDeletionException.
|
||||
throw new AccessDeniedHttpException($e->getMessage(), $e);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
});
|
||||
} catch (OptimisticLockException $e) {
|
||||
// Protection future : si un champ @Version est ajoute sur User,
|
||||
// le conflit de version sera intercepte ici plutot que de remonter
|
||||
// comme une erreur generique.
|
||||
throw new BadRequestHttpException(
|
||||
'Conflit de version detecte lors du changement de site courant. Veuillez reessayer.',
|
||||
$e,
|
||||
);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Sites\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Shared\Domain\Contract\SiteAwareInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
/**
|
||||
* Decorator du processor de persistance Doctrine d'API Platform qui injecte
|
||||
* automatiquement le site courant de l'utilisateur sur les entites
|
||||
* implementant SiteAwareInterface, si le payload ne precise pas de site.
|
||||
*
|
||||
* S'applique a TOUTES les operations POST/PATCH qui deleguent au persist
|
||||
* processor natif. Les processors custom qui appellent
|
||||
* `$this->persistProcessor->process()` (pattern UserRbacProcessor) passent
|
||||
* aussi par ce decorator, transparent pour les entites non-SiteAware.
|
||||
*
|
||||
* Comportement :
|
||||
* - $data pas SiteAware -> delegation directe (no-op).
|
||||
* - $data SiteAware avec site deja positionne, appelant a `sites.bypass_scope`
|
||||
* -> delegation directe (ex: admin qui cree une entite dans un autre site).
|
||||
* - $data SiteAware avec site deja positionne, appelant SANS `sites.bypass_scope`
|
||||
* -> validation que le site precise appartient aux sites autorises de l'user.
|
||||
* Si non, leve AccessDeniedHttpException (cross-site write interdite).
|
||||
* - $data SiteAware sans site, provider retourne un Site -> injection
|
||||
* puis delegation.
|
||||
* - $data SiteAware sans site, provider retourne null -> throw 400
|
||||
* BadRequestHttpException avec message explicite.
|
||||
*
|
||||
* Volontairement HTTP-only : ne couvre pas les persistances hors API
|
||||
* Platform (fixtures, commandes CLI, imports). Ces contextes doivent
|
||||
* positionner le site explicitement — c'est assume dans la doc
|
||||
* d'adoption (`docs/modules/site-aware.md`).
|
||||
*
|
||||
* @implements ProcessorInterface<mixed|SiteAwareInterface, mixed>
|
||||
*/
|
||||
#[AsDecorator('api_platform.doctrine.orm.state.persist_processor')]
|
||||
final class SiteAwareInjectionProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProcessorInterface $inner,
|
||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if ($data instanceof SiteAwareInterface) {
|
||||
if (null !== $data->getSite()) {
|
||||
// Le payload precise un site explicite : on valide que le site
|
||||
// appartient aux sites autorises de l'utilisateur courant, sauf
|
||||
// si celui-ci dispose de la permission `sites.bypass_scope`
|
||||
// (ex: admin effectuant une operation cross-site).
|
||||
if (!$this->security->isGranted('sites.bypass_scope')) {
|
||||
$user = $this->security->getUser();
|
||||
$explicitSite = $data->getSite();
|
||||
// hasSite() attend un Site concret. Si l'agent entity fait
|
||||
// evoluer la signature vers SiteInterface, le instanceof
|
||||
// reste valide (Site implemente SiteInterface) et le cast
|
||||
// disparaitra naturellement lors du prochain nettoyage.
|
||||
if ($user instanceof User && $explicitSite instanceof Site && !$user->hasSite($explicitSite)) {
|
||||
throw new AccessDeniedHttpException(
|
||||
'Le site specifie n\'est pas dans les sites autorises pour cet utilisateur.'
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Aucun site dans le payload : injection automatique depuis le
|
||||
// site courant de l'utilisateur.
|
||||
$currentSite = $this->currentSiteProvider->get();
|
||||
|
||||
if (null === $currentSite) {
|
||||
throw new BadRequestHttpException(
|
||||
'Impossible de creer l\'enregistrement : aucun site selectionne.',
|
||||
);
|
||||
}
|
||||
|
||||
$data->setSite($currentSite);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->inner->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
}
|
||||
@@ -36,42 +36,47 @@ class SitesFixtures extends Fixture
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
// Chatellerault : couleur imposee par le ticket (bleu Coltura).
|
||||
// Chatellerault : bleu Coltura.
|
||||
$this->ensureSite(
|
||||
$manager,
|
||||
name: 'Chatellerault',
|
||||
city: 'Chatellerault',
|
||||
street: "14 All. d'Argenson",
|
||||
complement: null,
|
||||
postalCode: '86100',
|
||||
city: 'Châtellerault',
|
||||
color: '#056CF2',
|
||||
fullAddress: "1 avenue de l'Europe\n86100 Chatellerault",
|
||||
);
|
||||
|
||||
// Saint-Jean : vert emeraude pour contraster avec le bleu Chatellerault.
|
||||
// Saint-Jean : jaune vif. Le nom du site (identifier) ne reflete
|
||||
// pas la ville reelle (Fontenet) — c'est une nomenclature interne
|
||||
// client.
|
||||
$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",
|
||||
street: 'Z i',
|
||||
complement: null,
|
||||
postalCode: '17400',
|
||||
city: 'Fontenet',
|
||||
color: '#F3CB00',
|
||||
);
|
||||
|
||||
// Pommevic : ambre pour une troisieme teinte nettement distincte.
|
||||
// Pommevic : vert clair.
|
||||
$this->ensureSite(
|
||||
$manager,
|
||||
name: 'Pommevic',
|
||||
city: 'Pommevic',
|
||||
street: '1 Av. Jean Duquesne',
|
||||
complement: null,
|
||||
postalCode: '82400',
|
||||
color: '#F59E0B',
|
||||
fullAddress: "5 chemin des Peupliers\n82400 Pommevic",
|
||||
city: 'Pommevic',
|
||||
color: '#74BF04',
|
||||
);
|
||||
|
||||
$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.
|
||||
* Cree le site s'il n'existe pas encore, sinon re-aligne rue, complement,
|
||||
* code postal, ville et couleur 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
|
||||
@@ -81,24 +86,26 @@ class SitesFixtures extends Fixture
|
||||
private function ensureSite(
|
||||
ObjectManager $manager,
|
||||
string $name,
|
||||
string $city,
|
||||
string $street,
|
||||
?string $complement,
|
||||
string $postalCode,
|
||||
string $city,
|
||||
string $color,
|
||||
string $fullAddress,
|
||||
): Site {
|
||||
$site = $this->siteRepository->findByName($name);
|
||||
|
||||
if (null === $site) {
|
||||
$site = new Site($name, $city, $postalCode, $color, $fullAddress);
|
||||
$site = new Site($name, $street, $complement, $postalCode, $city, $color);
|
||||
$manager->persist($site);
|
||||
|
||||
return $site;
|
||||
}
|
||||
|
||||
$site->setCity($city);
|
||||
$site->setStreet($street);
|
||||
$site->setComplement($complement);
|
||||
$site->setPostalCode($postalCode);
|
||||
$site->setCity($city);
|
||||
$site->setColor($color);
|
||||
$site->setFullAddress($fullAddress);
|
||||
|
||||
return $site;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ final class SitesModule
|
||||
return [
|
||||
['code' => 'sites.view', 'label' => 'Voir les sites'],
|
||||
['code' => 'sites.manage', 'label' => 'Gerer les sites (creer, editer, supprimer)'],
|
||||
['code' => 'sites.bypass_scope', 'label' => 'Voir les donnees site-scoped de tous les sites (bypass du filtrage)'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
37
src/Shared/Domain/Contract/SiteAwareInterface.php
Normal file
37
src/Shared/Domain/Contract/SiteAwareInterface.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
/**
|
||||
* Contrat opt-in pour les entites dont la visibilite est scopee par site.
|
||||
*
|
||||
* Une entite implementant cette interface sera :
|
||||
* - filtree en lecture par SiteScopedQueryExtension (collection + item)
|
||||
* selon le site courant de l'utilisateur authentifie ;
|
||||
* - alimentee automatiquement en POST/PATCH par SiteAwareInjectionProcessor
|
||||
* si le payload ne precise pas de site.
|
||||
*
|
||||
* L'implementation concrete doit :
|
||||
* - Declarer une relation ManyToOne vers l'entite concrete Site avec colonne
|
||||
* `site_id` NOT NULL (targetEntity: \App\Module\Sites\Domain\Entity\Site).
|
||||
* - Indexer `site_id` en base (sinon le filtre WHERE genere un full-scan).
|
||||
*
|
||||
* Les signatures utilisent SiteInterface (et non la classe concrete Site)
|
||||
* pour que Shared n'importe pas directement le module Sites.
|
||||
*
|
||||
* Ne PAS implementer cette interface pour :
|
||||
* - Des entites globales (catalogue partage, roles, permissions, users).
|
||||
* - Des entites dont le scope est "par tenant" plus large que le site
|
||||
* (utiliser TenantAwareInterface le cas echeant).
|
||||
* - Des entites transversales references par plusieurs sites.
|
||||
*
|
||||
* Voir `docs/modules/site-aware.md` pour le guide d'adoption complet.
|
||||
*/
|
||||
interface SiteAwareInterface
|
||||
{
|
||||
public function getSite(): ?SiteInterface;
|
||||
|
||||
public function setSite(SiteInterface $site): void;
|
||||
}
|
||||
20
src/Shared/Domain/Contract/SiteInterface.php
Normal file
20
src/Shared/Domain/Contract/SiteInterface.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
/**
|
||||
* Interface minimale exposant ce que le noyau (Shared/Core) doit connaitre
|
||||
* d'un Site, sans creer de couplage direct vers le module Sites.
|
||||
*
|
||||
* Implemente par App\Module\Sites\Domain\Entity\Site.
|
||||
* Utilisee comme type-hint dans SiteAwareInterface, User et toute entite
|
||||
* Shared/Core qui manipule un site sans avoir besoin des details metier.
|
||||
*/
|
||||
interface SiteInterface
|
||||
{
|
||||
public function getId(): ?int;
|
||||
|
||||
public function getName(): ?string;
|
||||
}
|
||||
31
src/Shared/Domain/Exception/SiteNotAuthorizedException.php
Normal file
31
src/Shared/Domain/Exception/SiteNotAuthorizedException.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Exception;
|
||||
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use DomainException;
|
||||
|
||||
/**
|
||||
* Levee lorsqu'un utilisateur tente de selectionner comme site courant un
|
||||
* site qui ne fait pas partie de ses sites autorises.
|
||||
*
|
||||
* Exception purement domaine : la traduction HTTP (403) est faite par le
|
||||
* CurrentSiteProcessor via try/catch, aligne sur le pattern
|
||||
* SystemRoleDeletionException du module Core.
|
||||
*
|
||||
* Deplacee dans Shared/Domain/Exception/ pour eviter que le module Core
|
||||
* n'importe directement depuis le module Sites (violation du principe de
|
||||
* non-couplage inter-modules).
|
||||
*/
|
||||
class SiteNotAuthorizedException extends DomainException
|
||||
{
|
||||
public static function forSite(SiteInterface $site): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Le site "%s" ne fait pas partie de vos sites autorises.',
|
||||
$site->getName(),
|
||||
));
|
||||
}
|
||||
}
|
||||
74
tests/Fixtures/SiteAware/FakeSiteAwareEntity.php
Normal file
74
tests/Fixtures/SiteAware/FakeSiteAwareEntity.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Fixtures\SiteAware;
|
||||
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Shared\Domain\Contract\SiteAwareInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Entite fictive utilisee UNIQUEMENT en tests (ticket 4 module Sites).
|
||||
*
|
||||
* Implemente SiteAwareInterface pour valider que l'outillage
|
||||
* (SiteScopedQueryExtension + SiteAwareInjectionProcessor) se comporte
|
||||
* correctement sans avoir a adopter le pattern sur une entite metier
|
||||
* reelle. Le mapping Doctrine n'est charge qu'en environnement `test`
|
||||
* via un bloc `when@test` dans `config/packages/doctrine.yaml`, donc
|
||||
* cette classe n'existe jamais dans un schema prod.
|
||||
*
|
||||
* Le nom de table `fake_site_aware_entity` est volontairement verbeux
|
||||
* pour reduire le risque de collision avec une future table metier.
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'fake_site_aware_entity')]
|
||||
class FakeSiteAwareEntity implements SiteAwareInterface
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 100)]
|
||||
private string $name;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Site::class)]
|
||||
#[ORM\JoinColumn(name: 'site_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Site $site = null;
|
||||
|
||||
public function __construct(string $name)
|
||||
{
|
||||
$this->name = $name;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): void
|
||||
{
|
||||
$this->name = $name;
|
||||
}
|
||||
|
||||
public function getSite(): ?SiteInterface
|
||||
{
|
||||
return $this->site;
|
||||
}
|
||||
|
||||
public function setSite(SiteInterface $site): void
|
||||
{
|
||||
if (!$site instanceof Site) {
|
||||
throw new InvalidArgumentException('FakeSiteAwareEntity requires a concrete Site (Doctrine ManyToOne target).');
|
||||
}
|
||||
$this->site = $site;
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
219
tests/Module/Core/Api/AdminFiltersApiTest.php
Normal file
219
tests/Module/Core/Api/AdminFiltersApiTest.php
Normal file
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Core\Api;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels des ApiFilter ajoutes sur User, Role et Site pour
|
||||
* les DataTables admin (filtrage serveur + pagination negociee).
|
||||
*
|
||||
* Ces tests s'appuient uniquement sur les fixtures (admin, alice, bob +
|
||||
* 3 sites + 2 roles systeme + 6 permissions) — aucune mutation entre
|
||||
* tests, pas de cleanup necessaire.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class AdminFiltersApiTest extends AbstractApiTestCase
|
||||
{
|
||||
// ========================================================================
|
||||
// User filters
|
||||
// ========================================================================
|
||||
|
||||
public function testUsersFilterByUsernamePartial(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/users?username=ali');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$data = $response->toArray();
|
||||
self::assertSame(1, $data['totalItems']);
|
||||
self::assertSame('alice', $data['member'][0]['username']);
|
||||
}
|
||||
|
||||
public function testUsersFilterByIsAdminTrueReturnsOnlyAdmins(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/users?isAdmin=true');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$data = $response->toArray();
|
||||
self::assertGreaterThanOrEqual(1, $data['totalItems']);
|
||||
foreach ($data['member'] as $user) {
|
||||
self::assertTrue($user['isAdmin']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testUsersFilterByIsAdminFalseExcludesAdmins(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/users?isAdmin=false');
|
||||
|
||||
$data = $response->toArray();
|
||||
foreach ($data['member'] as $user) {
|
||||
self::assertFalse($user['isAdmin']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testUsersFilterBySiteNameReturnsUsersOfThatSite(): void
|
||||
{
|
||||
// alice est rattachee a Chatellerault uniquement, bob a Saint-Jean.
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/users?sites.name=Saint-Jean');
|
||||
|
||||
$data = $response->toArray();
|
||||
$usernames = array_column($data['member'], 'username');
|
||||
self::assertContains('admin', $usernames);
|
||||
self::assertContains('bob', $usernames);
|
||||
self::assertNotContains('alice', $usernames);
|
||||
}
|
||||
|
||||
public function testUsersFilterByRoleCodeReturnsUsersWithThatRole(): void
|
||||
{
|
||||
// admin porte le role systeme 'admin', alice/bob portent 'user'.
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/users?rbacRoles.code=admin');
|
||||
|
||||
$data = $response->toArray();
|
||||
$usernames = array_column($data['member'], 'username');
|
||||
self::assertContains('admin', $usernames);
|
||||
self::assertNotContains('alice', $usernames);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Site filters
|
||||
// ========================================================================
|
||||
|
||||
public function testSitesFilterByNamePartial(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/sites?name=Chat');
|
||||
|
||||
$data = $response->toArray();
|
||||
self::assertSame(1, $data['totalItems']);
|
||||
self::assertSame('Chatellerault', $data['member'][0]['name']);
|
||||
}
|
||||
|
||||
public function testSitesFilterByCityPartial(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
// Fontenet est la ville du site Saint-Jean.
|
||||
$response = $client->request('GET', '/api/sites?city=Fonten');
|
||||
|
||||
$data = $response->toArray();
|
||||
self::assertSame(1, $data['totalItems']);
|
||||
self::assertSame('Saint-Jean', $data['member'][0]['name']);
|
||||
}
|
||||
|
||||
public function testSitesFilterByPostalCodePartial(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/sites?postalCode=82');
|
||||
|
||||
$data = $response->toArray();
|
||||
self::assertSame(1, $data['totalItems']);
|
||||
self::assertSame('Pommevic', $data['member'][0]['name']);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Role filters
|
||||
// ========================================================================
|
||||
|
||||
public function testRolesFilterByLabelPartial(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/roles?label=Admin');
|
||||
|
||||
$data = $response->toArray();
|
||||
self::assertGreaterThanOrEqual(1, $data['totalItems']);
|
||||
foreach ($data['member'] as $role) {
|
||||
self::assertStringContainsStringIgnoringCase('admin', $role['label']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testRolesFilterByLabelIsCaseInsensitive(): void
|
||||
{
|
||||
// Garde explicite : la strategy est `ipartial` (ILIKE) et pas
|
||||
// `partial` (LIKE). Chercher "ad" en minuscules DOIT trouver
|
||||
// "Administrateur" (A majuscule). Si un futur dev retombe en
|
||||
// strategy `partial` par megarde, ce test cassera.
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/roles?label=ad');
|
||||
|
||||
$data = $response->toArray();
|
||||
$labels = array_column($data['member'], 'label');
|
||||
self::assertContains('Administrateur', $labels);
|
||||
}
|
||||
|
||||
public function testRolesFilterByCodePartial(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/roles?code=user');
|
||||
|
||||
$data = $response->toArray();
|
||||
self::assertGreaterThanOrEqual(1, $data['totalItems']);
|
||||
foreach ($data['member'] as $role) {
|
||||
self::assertStringContainsString('user', $role['code']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testRolesFilterByIsSystemTrueReturnsOnlySystemRoles(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/roles?isSystem=true');
|
||||
|
||||
$data = $response->toArray();
|
||||
self::assertGreaterThanOrEqual(2, $data['totalItems']);
|
||||
foreach ($data['member'] as $role) {
|
||||
self::assertTrue($role['isSystem']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testRolesFilterByPermissionCodeReturnsRolesWithThatPermission(): void
|
||||
{
|
||||
// Le role systeme 'admin' a le flag isAdmin qui bypass toutes les
|
||||
// permissions — il n'a pas necessairement des permissions explicites.
|
||||
// On teste donc avec la permission sites.view qui devrait exister
|
||||
// mais potentiellement n'etre sur aucun role custom. Le filtre
|
||||
// fonctionne techniquement meme sur un resultat vide.
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/roles?permissions.code=sites.view');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$data = $response->toArray();
|
||||
// On valide juste que la requete est acceptee (200) et que le
|
||||
// filtre transforme bien l'IRI en JOIN — nombre de resultats
|
||||
// depend de l'etat des fixtures.
|
||||
self::assertArrayHasKey('totalItems', $data);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Pagination
|
||||
// ========================================================================
|
||||
|
||||
public function testPaginationWithItemsPerPageReducesMember(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/sites?itemsPerPage=2');
|
||||
|
||||
$data = $response->toArray();
|
||||
self::assertLessThanOrEqual(2, count($data['member']));
|
||||
// totalItems reflete le TOTAL pas la page courante.
|
||||
self::assertGreaterThanOrEqual(3, $data['totalItems']);
|
||||
}
|
||||
|
||||
public function testPaginationPage2SkipsFirstItems(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$page1 = $client->request('GET', '/api/sites?itemsPerPage=1&page=1')->toArray();
|
||||
$page2 = $client->request('GET', '/api/sites?itemsPerPage=1&page=2')->toArray();
|
||||
|
||||
self::assertCount(1, $page1['member']);
|
||||
self::assertCount(1, $page2['member']);
|
||||
self::assertNotSame(
|
||||
$page1['member'][0]['id'],
|
||||
$page2['member'][0]['id'],
|
||||
'Les items de la page 2 doivent differer de ceux de la page 1.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
189
tests/Module/Core/Api/UserRbacSitesApiTest.php
Normal file
189
tests/Module/Core/Api/UserRbacSitesApiTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
99
tests/Module/Sites/Api/CurrentSiteSwitchApiTest.php
Normal file
99
tests/Module/Sites/Api/CurrentSiteSwitchApiTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
116
tests/Module/Sites/Api/MeEndpointSitesTest.php
Normal file
116
tests/Module/Sites/Api/MeEndpointSitesTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
235
tests/Module/Sites/Api/SiteApiTest.php
Normal file
235
tests/Module/Sites/Api/SiteApiTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
90
tests/Module/Sites/Api/SiteCascadeTest.php
Normal file
90
tests/Module/Sites/Api/SiteCascadeTest.php
Normal 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,9 +50,15 @@ final class SiteValidationTest extends KernelTestCase
|
||||
|
||||
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');
|
||||
$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);
|
||||
@@ -61,8 +67,7 @@ final class SiteValidationTest extends KernelTestCase
|
||||
#[DataProvider('invalidColorProvider')]
|
||||
public function testColorMustBeHexRrggbb(string $color): void
|
||||
{
|
||||
$site = new Site('Test-'.uniqid('', true), 'Y', '12345', $color, 'Addr');
|
||||
|
||||
$site = $this->makeSite(color: $color);
|
||||
$violations = $this->validator->validate($site);
|
||||
|
||||
self::assertGreaterThan(0, $violations->count(), sprintf('La couleur "%s" devrait etre rejetee.', $color));
|
||||
@@ -91,8 +96,7 @@ final class SiteValidationTest extends KernelTestCase
|
||||
#[DataProvider('validColorProvider')]
|
||||
public function testValidColorsAreAccepted(string $color): void
|
||||
{
|
||||
$site = new Site('Test-'.uniqid('', true), 'Y', '12345', $color, 'Addr');
|
||||
|
||||
$site = $this->makeSite(color: $color);
|
||||
$violations = $this->validator->validate($site);
|
||||
|
||||
self::assertCount(0, $violations, sprintf('La couleur "%s" devrait etre acceptee.', $color));
|
||||
@@ -117,8 +121,7 @@ final class SiteValidationTest extends KernelTestCase
|
||||
#[DataProvider('invalidPostalCodeProvider')]
|
||||
public function testPostalCodeMustMatchFrFormat(string $postalCode): void
|
||||
{
|
||||
$site = new Site('Test-'.uniqid('', true), 'Y', $postalCode, '#000000', 'Addr');
|
||||
|
||||
$site = $this->makeSite(postalCode: $postalCode);
|
||||
$violations = $this->validator->validate($site);
|
||||
|
||||
self::assertGreaterThan(0, $violations->count(), sprintf('Le CP "%s" devrait etre rejete.', $postalCode));
|
||||
@@ -145,8 +148,7 @@ final class SiteValidationTest extends KernelTestCase
|
||||
#[DataProvider('validPostalCodeProvider')]
|
||||
public function testValidPostalCodesAreAccepted(string $postalCode): void
|
||||
{
|
||||
$site = new Site('Test-'.uniqid('', true), 'Y', $postalCode, '#000000', 'Addr');
|
||||
|
||||
$site = $this->makeSite(postalCode: $postalCode);
|
||||
$violations = $this->validator->validate($site);
|
||||
|
||||
self::assertCount(0, $violations, (string) $violations);
|
||||
@@ -168,8 +170,15 @@ final class SiteValidationTest extends KernelTestCase
|
||||
|
||||
public function testBlankNameIsRejected(): void
|
||||
{
|
||||
$site = new Site('', 'Y', '12345', '#000000', 'Addr');
|
||||
$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());
|
||||
@@ -177,17 +186,7 @@ final class SiteValidationTest extends KernelTestCase
|
||||
|
||||
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', '');
|
||||
|
||||
$site = $this->makeSite(city: '');
|
||||
$violations = $this->validator->validate($site);
|
||||
|
||||
self::assertGreaterThan(0, $violations->count());
|
||||
@@ -195,8 +194,7 @@ final class SiteValidationTest extends KernelTestCase
|
||||
|
||||
public function testNameLongerThan100CharsIsRejected(): void
|
||||
{
|
||||
$site = new Site(str_repeat('a', 101), 'Y', '12345', '#000000', 'Addr');
|
||||
|
||||
$site = $this->makeSite(name: str_repeat('a', 101));
|
||||
$violations = $this->validator->validate($site);
|
||||
|
||||
self::assertGreaterThan(0, $violations->count());
|
||||
@@ -204,8 +202,23 @@ final class SiteValidationTest extends KernelTestCase
|
||||
|
||||
public function testCityLongerThan100CharsIsRejected(): void
|
||||
{
|
||||
$site = new Site('Test-'.uniqid('', true), str_repeat('a', 101), '12345', '#000000', 'Addr');
|
||||
$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());
|
||||
@@ -223,16 +236,13 @@ final class SiteValidationTest extends KernelTestCase
|
||||
*/
|
||||
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');
|
||||
$original = $this->makeSite(name: $name);
|
||||
$this->em->persist($original);
|
||||
$this->em->flush();
|
||||
|
||||
try {
|
||||
$duplicate = new Site($name, 'Autre ville', '75001', '#FF0000', 'Autre adresse');
|
||||
$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.');
|
||||
@@ -256,4 +266,26 @@ final class SiteValidationTest extends KernelTestCase
|
||||
$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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
53
tests/Module/Sites/SitesModuleTest.php
Normal file
53
tests/Module/Sites/SitesModuleTest.php
Normal 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]).',
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user