Files
Coltura/frontend/shared/composables/__tests__/useDataTableServerState.test.ts
tristan 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

204 lines
6.6 KiB
TypeScript

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