Compare commits

..

12 Commits

Author SHA1 Message Date
edbe54cb8a fix(admin) : recherche insensible a la casse + label permissions dans filtres
SearchFilter partial etait case-sensitive en PostgreSQL : taper "ad" ne
trouvait pas "Administrateur". Passage en strategy `ipartial` (ILIKE) sur
tous les champs texte filtrables :
- Role.label / Role.code
- Site.name / Site.city / Site.postalCode
- User.username

Les filtres exacts sur relations (rbacRoles.code, sites.name,
permissions.code) restent en `exact` — alimentes par des <select> donc
casse maitrisee.

Test de non-regression ajoute : chercher "ad" (minuscule) trouve bien
"Administrateur" (A majuscule) sur /api/roles?label=ad.

UI select permissions du filtre /admin/roles : affiche `perm.label`
("Gerer les roles et permissions") au lieu de `perm.code`
("core.roles.manage"). Value reste sur `code` pour le backend. Tri
par label pour coherence alphabetique avec l'affichage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:28:01 +02:00
1550f46b23 Merge remote-tracking branch 'origin/feat/module-site-backend' into feat/admin-tables-filter-pagination
# Conflicts:
#	frontend/modules/sites/pages/admin/sites.vue
2026-04-20 17:04:04 +02:00
cb6d2d72ec feat(admin) : filtres + pagination serveur sur /admin/users/sites/roles
Ajoute le filtrage par colonne et la pagination negociee via query params
sur les 3 DataTables admin existantes. Tout est cote serveur (API Platform
SearchFilter + BooleanFilter) pour scaler naturellement.

Backend :
- api_platform.yaml : scan du mapping Sites + pagination_client_items_per_page
  (avec borne max 100 pour proteger contre les payloads exagerement grands).
- User : SearchFilter username (partial), rbacRoles.code (exact),
  sites.name (exact) + BooleanFilter isAdmin.
- Site : SearchFilter name/city/postalCode (partial).
- Role : SearchFilter label/code (partial), permissions.code (exact).
  (BooleanFilter isSystem deja present.)

Frontend :
- Composable useDataTableServerState (shared) : singleton de page/perPage/
  filters avec debounce 300ms sur les filters, fetch immediat sur page/
  perPage, reset page=1 au changement filter, token anti-race-condition.
- Pages admin : chaque filtre dans un slot #header-{key} (input text avec
  debounce, select mono-selection pour les relations). Font-size 20px sur
  les inputs de filtre.
- /admin/users : colonne Sites + filtre Sites conditionnes par
  useModules().isModuleActive('sites') — preserve l'invariant "module
  desactivable sans casse".

Tests : 215/215 PHPUnit (14 nouveaux filtres/pagination) + 48/48 Vitest
(8 nouveaux useDataTableServerState).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:00:34 +02:00
Matthieu
a15fc83222 fix(sites-front) : refresh state apres switch/delete/401 + redirect logout
- logout.vue : navigateTo('/login') dans le finally, garanti meme si
  auth.logout() rejette.
- auth.ts : systeme de callbacks onAuthSessionCleared appeles par
  clearSession() (intercepteur 401 de useApi). Les composables modules
  s'abonnent pour reset leur state sans que Shared n'importe depuis
  modules/ (Option C validee par CLAUDE.md, module -> shared autorise).
- useCurrentSite.ts : enregistre un reset callback + apres un switch
  reussi, rafraichit useSidebar().loadSidebar() + refreshNuxtData()
  (sinon donnees de page obsoletes cote ancien site sous toast success).
- SiteSelector.vue : le court-circuit "tile deja active" est retire
  pour permettre un PATCH de resync quand un autre onglet a bascule le
  site entre temps. TODO cross-tab : ecouter un storage event dedie.
- sites.vue admin : auth.refreshUser() apres delete pour refleter le
  ON DELETE SET NULL cote user.current_site_id.
- Specs vitest : stub useSidebar/refreshNuxtData, test "tile active"
  retourne sur le nouveau contrat PATCH-toujours.
2026-04-20 16:47:57 +02:00
Matthieu
caae752130 fix(sites) : processors transactionnels + garde sites.manage + anti-TOCTOU
- UserRbacProcessor : persist + ensureCurrentSiteConsistency wrappes dans
  wrapInTransaction (plus de double flush non atomique qui pouvait laisser
  currentSite orphelin sur un crash entre les deux flush).
- UserRbacProcessor : detecte la mutation de `sites` via
  PersistentCollection::isDirty() et verifie is_granted('sites.manage')
  avant de deleguer (empeche core.users.manage de contourner sites.manage).
- UserRbacProcessor : skip ensureCurrentSiteConsistency si ni sites ni
  currentSite n'ont ete modifies (plus de bascule silencieuse de site sur
  un simple toggle isAdmin apres suppression de site).
- CurrentSiteProcessor : refresh($user) avant hasSite() pour fermer la
  fenetre TOCTOU entre /rbac revoke et /me/current-site. Catch
  OptimisticLockException pour etre pret a un futur @ORM\Version.
- SiteAwareInjectionProcessor : valide un site explicite contre
  $user->getSites() (bypass via sites.bypass_scope) — bloque le cross-site
  write quand l'entite expose `site` en ecriture.
2026-04-20 16:47:28 +02:00
Matthieu
8bedab407d feat(sites) : scope /api/sites et /api/users aux sites autorises du caller
- SiteCollectionScopedExtension filtre /api/sites aux sites du user
  (name/adresse/CP/ville plus lisibles par un delegataire sites.view qui
  n'appartient pas a ces sites). Bypass via sites.bypass_scope.
- UserSiteScopedExtension filtre /api/users aux users partageant au moins
  un site avec le caller. Empeche un delegataire de core.users.view
  d'enumerer l'organigramme complet + les sites de tous les tenants.
- Helper createUserWithPermission rattache le user jetable a tous les
  sites fixtures, sinon le scoping le rend aveugle aux cibles.
- test_target de UserRbacApiTest attache de meme aux sites pour rester
  visible depuis un caller non-admin.
- testUserCannotSwitchToUnauthorizedSite : 403 -> 400 (anti-enumeration).
2026-04-20 16:46:57 +02:00
Matthieu
fd5d3fe36f refactor(sites) : decouple module Sites via SiteInterface + leaks groupes user:list
- Introduit Shared/Domain/Contract/SiteInterface que Site implemente
- SiteAwareInterface + User.php typent contre SiteInterface (plus d'import
  direct Core -> Sites, respect regle CLAUDE.md 138)
- Exception SiteNotAuthorizedException deplacee dans Shared/, alias
  retrocompat dans le module
- Retire `sites` et `currentSite` des groupes `user:list` et `user:rbac:write`
  (info leak via /api/users, escalade core.users.manage -> sites.manage)
- User::$sites et User::$currentSite en fetch LAZY (N+1 sur /api/users paginee)
2026-04-20 16:46:27 +02:00
296befe187 feat(sites) : outillage opt-in site-aware (ticket 4/4)
Livre l'infrastructure permettant aux modules metier de declarer leurs
entites comme "scopees par site" via SiteAwareInterface. Strictement
opt-in : aucune entite metier touchee, aucune migration sur tables
existantes.

Composants :
- SiteAwareInterface (Shared/Domain/Contract) : getSite/setSite
- CurrentSiteProvider + interface (Module/Sites/Application) : resolve
  ?Site selon 3 conditions (module actif, user authentifie, currentSite).
  Interface extraite pour mockabilite en tests (implementation reste final).
- SiteScopedQueryExtension : QueryCollection + QueryItem API Platform,
  ajoute WHERE site = :currentSite si resource SiteAware + provider
  non-null + pas sites.bypass_scope.
- SiteAwareInjectionProcessor : decorator de api_platform.doctrine.orm.
  state.persist_processor (#[AsDecorator]). Injecte currentSite sur
  entites SiteAware sans site ; throw 400 si provider null.
- Permission sites.bypass_scope declaree dans SitesModule::permissions().

Tests :
- FakeSiteAwareEntity dans tests/Fixtures/ + mapping when@test dans
  doctrine.yaml. Table creee a la volee via SchemaTool dans setUp.
  schema:update --force ajoute dans test-db-setup pour que fixtures:load
  ne crashe pas au purger.
- 17 tests dedies au ticket 4 (CurrentSiteProvider unitaire, Injection
  Processor unitaire, Extension integration avec 7 cas couvrant filtrage
  collection + item, bypass, no-op, resource non SiteAware).
- SitesModuleTest : verifie le set de 3 permissions + que le decorator
  est bien enregistre sur le persist processor.

Documentation docs/modules/site-aware.md : guide developpeur 8 sections
(quand/ne pas adopter, comment, migration, mode degrade, anti-patterns,
exemple d'adoption Supplier, cascade delete).

Upgrade @malio/layer-ui 1.4.0 → 1.4.2 (bug 1.4.0 : tailwind.config.ts
oublie dans les files publies npm → classe rounded-malio manquante sur
les DataTables). Simplification tailwind.config.ts Coltura : retrait des
colors/fontFamily/borderRadius dupliques, seule la specifique projet
(primary, secondary, tertiary, m.secondary, m.tertiary) est conservee.

Tests : 201/201 avec et sans SitesModule actif (2 skipped en disabled).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:11:07 +02:00
03c761eed4 feat(sites) : barre de selection de site (ticket 3/4)
Barre horizontale en haut de l'app qui liste les sites autorises de
l'utilisateur et permet de switcher d'un click. Consomme le composant
MalioSiteSelector de @malio/layer-ui 1.4.0 (upgrade depuis 1.3.0).

Composables :
- useModules (shared) : consomme /api/modules, expose isModuleActive.
  Pattern aligne sur useSidebar.
- useCurrentSite (layer sites) : singleton state, switchSite optimistic
  avec rollback sur erreur, garde anti-double-submit, propagation au
  store auth via action setCurrentSite dediee.

Composant :
- SiteSelector.vue : wrapper thin autour de MalioSiteSelector. Texte
  blanc uniforme (conforme maquette Figma) avec taille 24px forcee via
  labelClass="text-2xl". aria-label du group via ariaGroupLabel i18n.

Integration :
- Middleware auth.global.ts : chargement parallele sidebar + modules.
- layouts/default.vue : render conditionnel si module Sites actif ET
  user.sites.length > 0.
- logout.vue : reset des 3 composables (sidebar, modules, currentSite)
  dans un try/finally.
- nuxt.config.ts : auto-detection des composables/ de chaque layer
  module (necessaire car imports.dirs explicite override les defaults
  Nuxt).

Couleurs fixtures finales : Chatellerault #056CF2, Saint-Jean #F3CB00,
Pommevic #74BF04. Charge aux admins de choisir des teintes foncees
(texte blanc non contrastable via calcul WCAG, design choisi).

Tests : 40 Vitest (color, useModules, useSidebar, useCurrentSite,
SiteSelector) incluant garde anti-regression pour useI18n hors setup.
182/182 PHPUnit backend, avec et sans module actif.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:45:48 +02:00
d137828919 feat(sites) : API CRUD + rattachement User<->Site + admin (ticket 2/4)
Exposition de Site via API Platform (5 operations RBAC sites.view/sites.manage),
relation User.sites (M2M user_site EAGER) + User.currentSite (M2O nullable,
ON DELETE SET NULL). Endpoint PATCH /api/me/current-site via ressource
virtuelle + processor (SiteNotAuthorizedException → 403). UserRbacProcessor
etendu avec gardes post-persist : auto-reset si currentSite retire, auto-select
premier site si null + sites non vide.

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 10:09:05 +02:00
105574ba2f docs(sites) : specifications des 4 tickets de l'epic Sites
Une spec par ticket dans docs/sites/, alignee sur le pattern RBAC :
  - ticket-01 : brique de donnees (entite, repo, migration, fixtures, RBAC)
  - ticket-02 : API Platform CRUD + User<->Site (M2M + currentSite) + admin CRUD
  - ticket-03 : barre horizontale SiteSelector (consomme MalioSiteSelector)
  - ticket-04 : outillage opt-in site-aware (interface + extensions + doc)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:45:54 +02:00
8590e3e850 feat(sites) : brique fondatrice du module Sites (ticket 1/4)
Module Sites optionnel et desactivable via config/modules.php.
Entite Site (nom unique, ville, CP FR, couleur hex, adresse),
repository + impl Doctrine, migration racine (namespace DoctrineMigrations
conforme exception CLAUDE.md), fixtures idempotentes (Chatellerault,
Saint-Jean, Pommevic), permissions RBAC sites.view/sites.manage.
Tests unitaires + validation via KernelTestCase (UniqueEntity, regex
hex et CP, NotBlank, Length).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:45:33 +02:00
13 changed files with 899 additions and 92 deletions

View File

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

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.33'
app.version: '0.1.31'

View File

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

View File

@@ -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,30 +98,77 @@
</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[]>([])
// 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 allRoles = ref<Role[]>([])
const allSites = ref<Site[]>([])
const sitesById = ref(new Map<number, Site>())
const loading = ref(false)
const drawerOpen = ref(false)
const selectedUser = ref<UserListItem | null>(null)
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') },
{ key: 'sites', label: t('admin.users.table.sites') },
]
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 {
@@ -68,7 +176,7 @@ function iriToId(iri: string): number {
}
const userItems = computed(() =>
users.value.map(user => ({
items.value.map(user => ({
id: user.id,
username: user.username,
admin: user.isAdmin,
@@ -76,7 +184,7 @@ const userItems = computed(() =>
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 en parallele depuis /api/sites.
// 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))
@@ -84,24 +192,11 @@ const userItems = computed(() =>
})),
)
async function loadUsers() {
loading.value = true
try {
// Chargement parallele : les sites alimentent la Map de resolution
// IRI→name pour la colonne "Sites" de la table.
const [usersData, sitesData] = await Promise.all([
api.get<{ member: UserListItem[] }>('/users', {}, { toast: false }),
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false }),
])
users.value = usersData.member
sitesById.value = new Map(sitesData.member.map(s => [s.id, s]))
} 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) {
@@ -115,10 +210,11 @@ function onRowClick(item: Record<string, unknown>) {
}
function onUserSaved() {
loadUsers()
reload()
}
onMounted(() => {
loadUsers()
loadFilterOptions()
reload()
})
</script>

View File

@@ -14,16 +14,43 @@
/>
</div>
<!-- Table des sites -->
<!-- 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="sites.length"
: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
@@ -69,8 +96,20 @@ const canManage = computed(() => can('sites.manage'))
useHead({ title: t('admin.sites.title') })
const sites = ref<Site[]>([])
const loading = ref(false)
// 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') },
@@ -84,7 +123,7 @@ const columns = [
// `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(() =>
sites.value.map(site => ({
items.value.map(site => ({
id: site.id,
name: site.name,
city: site.city,
@@ -95,7 +134,7 @@ const siteItems = computed(() =>
)
function getSiteById(id: number): Site | undefined {
return sites.value.find(s => s.id === id)
return items.value.find(s => s.id === id)
}
function onRowClick(item: Record<string, unknown>) {
@@ -109,20 +148,6 @@ const deleteModalOpen = ref(false)
const siteToDelete = ref<Site | null>(null)
const deleting = ref(false)
async function loadSites() {
loading.value = true
try {
const data = await api.get<{ member: Site[] }>(
'/sites',
{ itemsPerPage: 999 },
{ toast: false },
)
sites.value = data.member
} finally {
loading.value = false
}
}
function openCreateDrawer() {
selectedSite.value = null
drawerOpen.value = true
@@ -149,7 +174,7 @@ async function handleDelete() {
deleteModalOpen.value = false
siteToDelete.value = null
drawerOpen.value = false
await loadSites()
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
@@ -161,10 +186,10 @@ async function handleDelete() {
}
function onSiteSaved() {
loadSites()
reload()
}
onMounted(() => {
loadSites()
reload()
})
</script>

View File

@@ -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 }])
})
})

View 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,
}
}

View File

@@ -81,7 +81,7 @@ RUN mkdir -p /var/www/.composer/cache/vcs \
ENV COMPOSER_HOME=/var/www/.composer
# Création de la structure du projet
RUN mkdir -p /var/www/html/LOG /var/www/html/var/cache /var/www/html/var/log
RUN mkdir /var/www/html/LOG
###> User ###
ARG CURRENT_UID

View File

@@ -44,8 +44,6 @@ install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migrat
reset: delete_built_dir remove_orphans build-without-cache start wait install
composer-install:
$(EXEC_PHP_ROOT) mkdir -p /var/www/html/var/cache /var/www/html/var/log
$(EXEC_PHP_ROOT) chown -R www-data:www-data /var/www/html/var
$(EXEC_PHP) composer install
$(SYMFONY_CONSOLE) lexik:jwt:generate-keypair --skip-if-exists

View File

@@ -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'])]

View File

@@ -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;
@@ -61,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

View File

@@ -4,6 +4,8 @@ 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;
@@ -62,6 +64,15 @@ use Symfony\Component\Validator\Constraints as Assert;
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'])]

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