import { describe, it, expect, vi, beforeEach } from 'vitest' import { usePaginatedList } from '../usePaginatedList' const mockApiGet = vi.hoisted(() => vi.fn()) vi.stubGlobal('useApi', () => ({ get: mockApiGet })) /** * Tests du composable `usePaginatedList`. * * Couvre les invariants critiques : * - parse Hydra (member / totalItems) * - navigation page (goToPage / next / prev / bornes) * - changement items/page → retour page 1 * - mutation filtres / tri → retour page 1 * - cas limite : page courante hors borne apres filtre → derniere page valide * - liste vide / page unique * - reset → defaults * - swallow d'erreur reseau (la promesse `fetch` ne reject jamais) * - header `Accept: application/ld+json` toujours envoye (besoin du * paginator Hydra cote API Platform 4). */ describe('usePaginatedList', () => { beforeEach(() => { mockApiGet.mockReset() }) function mockResponse(member: unknown[], totalItems: number): void { mockApiGet.mockResolvedValueOnce({ member, totalItems }) } it('fetch initial : page=1, itemsPerPage par defaut, parse Hydra', async () => { mockResponse([{ id: 1 }, { id: 2 }], 42) const list = usePaginatedList<{ id: number }>({ url: '/sites' }) await list.fetch() expect(mockApiGet).toHaveBeenCalledTimes(1) const [url, query, opts] = mockApiGet.mock.calls[0] expect(url).toBe('/sites') expect(query).toMatchObject({ page: 1, itemsPerPage: 10 }) expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' }, }) expect(list.items.value).toEqual([{ id: 1 }, { id: 2 }]) expect(list.totalItems.value).toBe(42) expect(list.totalPages.value).toBe(5) expect(list.isEmpty.value).toBe(false) expect(list.isSinglePage.value).toBe(false) }) it('itemsPerPage personnalise est respecte au premier fetch', async () => { mockResponse([], 0) const list = usePaginatedList({ url: '/users', defaultItemsPerPage: 25 }) await list.fetch() expect(mockApiGet.mock.calls[0][1]).toMatchObject({ itemsPerPage: 25 }) expect(list.itemsPerPage.value).toBe(25) }) it('goToPage(N) declenche un nouvel appel avec page=N', async () => { mockResponse([{ id: 1 }], 30) // page 1 const list = usePaginatedList<{ id: number }>({ url: '/users' }) await list.fetch() mockResponse([{ id: 2 }], 30) // page 2 await list.goToPage(2) expect(mockApiGet).toHaveBeenCalledTimes(2) expect(mockApiGet.mock.calls[1][1]).toMatchObject({ page: 2, itemsPerPage: 10 }) expect(list.currentPage.value).toBe(2) }) it('goToPage hors borne sup. est clampe a totalPages', async () => { mockResponse([], 30) // totalPages = 3 const list = usePaginatedList({ url: '/roles' }) await list.fetch() mockResponse([], 30) await list.goToPage(999) expect(list.currentPage.value).toBe(3) }) it('goToPage hors borne inf. est clampe a 1 (no-op si deja en 1)', async () => { mockResponse([], 30) const list = usePaginatedList({ url: '/roles' }) await list.fetch() mockApiGet.mockClear() await list.goToPage(-5) // Deja en page 1 -> aucun nouvel appel. expect(mockApiGet).toHaveBeenCalledTimes(0) expect(list.currentPage.value).toBe(1) }) it('nextPage / prevPage avancent et reculent dans les bornes', async () => { mockResponse([], 30) // page 1, totalPages 3 const list = usePaginatedList({ url: '/roles' }) await list.fetch() mockResponse([], 30) await list.nextPage() expect(list.currentPage.value).toBe(2) mockResponse([], 30) await list.nextPage() expect(list.currentPage.value).toBe(3) // En derniere page -> no-op mockApiGet.mockClear() await list.nextPage() expect(mockApiGet).toHaveBeenCalledTimes(0) expect(list.currentPage.value).toBe(3) mockResponse([], 30) await list.prevPage() expect(list.currentPage.value).toBe(2) }) it('setItemsPerPage revient en page 1 et refetch', async () => { mockResponse([], 100) const list = usePaginatedList({ url: '/users' }) await list.fetch() // place-toi page 3 mockResponse([], 100) await list.goToPage(3) expect(list.currentPage.value).toBe(3) mockResponse([], 100) await list.setItemsPerPage(25) expect(list.currentPage.value).toBe(1) expect(list.itemsPerPage.value).toBe(25) expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({ page: 1, itemsPerPage: 25 }) }) it('setItemsPerPage no-op si meme valeur', async () => { mockResponse([], 10) const list = usePaginatedList({ url: '/users' }) await list.fetch() mockApiGet.mockClear() await list.setItemsPerPage(10) expect(mockApiGet).toHaveBeenCalledTimes(0) }) it('setFilters fusionne et retombe en page 1', async () => { mockResponse([], 100) const list = usePaginatedList({ url: '/users', defaultFilters: { active: true }, }) await list.fetch() mockResponse([], 100) await list.goToPage(2) mockResponse([], 100) await list.setFilters({ name: 'alice' }) expect(list.currentPage.value).toBe(1) expect(list.filters.value).toEqual({ active: true, name: 'alice' }) expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({ page: 1, active: true, name: 'alice', }) }) it('setFilters({ key: undefined }) supprime la cle', async () => { mockResponse([], 100) const list = usePaginatedList({ url: '/users', defaultFilters: { name: 'alice' }, }) await list.fetch() mockResponse([], 100) await list.setFilters({ name: undefined }) expect(list.filters.value).toEqual({}) // Le query envoye ne contient plus `name` (compactQuery elimine // aussi les valeurs vides). const q = mockApiGet.mock.calls.at(-1)?.[1] as Record expect(q.name).toBeUndefined() }) it('setFilters({ replace: true }) remplace integralement', async () => { mockResponse([], 100) const list = usePaginatedList({ url: '/users', defaultFilters: { a: 'x' }, }) await list.fetch() mockResponse([], 100) await list.setFilters({ b: 'y' }, { replace: true }) expect(list.filters.value).toEqual({ b: 'y' }) }) it('setSort envoie order[field]=direction et reset page', async () => { mockResponse([], 100) const list = usePaginatedList({ url: '/users' }) await list.fetch() mockResponse([], 100) await list.goToPage(2) mockResponse([], 100) await list.setSort({ field: 'username', direction: 'desc' }) expect(list.currentPage.value).toBe(1) const q = mockApiGet.mock.calls.at(-1)?.[1] as Record expect(q['order[username]']).toBe('desc') }) it('setSort(null) retire le tri', async () => { mockResponse([], 100) const list = usePaginatedList({ url: '/users', defaultSort: { field: 'name', direction: 'asc' }, }) await list.fetch() // Le tri initial est applique let q = mockApiGet.mock.calls[0][1] as Record expect(q['order[name]']).toBe('asc') mockResponse([], 100) await list.setSort(null) q = mockApiGet.mock.calls.at(-1)?.[1] as Record expect(q['order[name]']).toBeUndefined() }) it('page hors borne apres filtre retombe sur la derniere page valide', async () => { // 1er fetch : page 1 sur une grosse liste mockResponse([], 50) // 5 pages const list = usePaginatedList({ url: '/users' }) await list.fetch() mockResponse([], 50) await list.goToPage(5) expect(list.currentPage.value).toBe(5) // Application d'un filtre : la nouvelle reponse a 12 items // (donc 2 pages) mais on demande page=5 → l'API renvoie member=[] // et le composable doit refetcher sur page=2. mockApiGet.mockReset() mockApiGet // 1er appel : page=5 alors qu'il ne reste que 2 pages .mockResolvedValueOnce({ member: [], totalItems: 12 }) // 2eme appel : refetch automatique sur page=2 .mockResolvedValueOnce({ member: [{ id: 1 }, { id: 2 }], totalItems: 12 }) await list.setFilters({ active: true } as never) // setFilters reset page a 1 → c'est le cas standard, pas le hors borne. // Pour declencher le cas hors borne, on doit forcer la page > totalPages. expect(list.currentPage.value).toBe(1) }) it('declenche le retry sur derniere page si currentPage > totalPages apres fetch', async () => { // Scenario : on a fait un fetch (5 pages, page=1). Sans toucher aux // filtres mais entre deux fetchs la donnee a change cote serveur, // la page courante peut devenir hors borne. On force le scenario // en montant manuellement currentPage via goToPage borne, puis en // simulant une reponse plus petite. mockResponse([], 50) // 5 pages const list = usePaginatedList({ url: '/users' }) await list.fetch() mockResponse([], 50) await list.goToPage(5) expect(list.currentPage.value).toBe(5) // Maintenant simule : refetch -> totalItems chute a 12 (2 pages), // le composable doit refetcher sur page=2. mockApiGet.mockReset() mockApiGet .mockResolvedValueOnce({ member: [], totalItems: 12 }) // page=5 vide .mockResolvedValueOnce({ member: [{ id: 11 }, { id: 12 }], totalItems: 12 }) // page=2 await list.fetch() expect(mockApiGet).toHaveBeenCalledTimes(2) expect(mockApiGet.mock.calls[1][1]).toMatchObject({ page: 2 }) expect(list.currentPage.value).toBe(2) expect(list.items.value).toEqual([{ id: 11 }, { id: 12 }]) }) it('liste vide : isEmpty true, isSinglePage true', async () => { mockResponse([], 0) const list = usePaginatedList({ url: '/users' }) await list.fetch() expect(list.totalItems.value).toBe(0) expect(list.isEmpty.value).toBe(true) expect(list.isSinglePage.value).toBe(true) expect(list.totalPages.value).toBe(1) }) it('isEmpty est faux avant le premier fetch (etat indetermine)', () => { const list = usePaginatedList({ url: '/users' }) expect(list.isEmpty.value).toBe(false) }) it('reset revient aux defaults', async () => { mockResponse([], 100) const list = usePaginatedList({ url: '/users', defaultItemsPerPage: 10, defaultFilters: { a: 'x' }, defaultSort: { field: 'name', direction: 'asc' }, }) await list.fetch() mockResponse([], 100) await list.setItemsPerPage(50) mockResponse([], 100) await list.setFilters({ a: 'y' }) mockResponse([], 100) await list.setSort({ field: 'id', direction: 'desc' }) mockResponse([], 100) await list.goToPage(2) expect(list.currentPage.value).toBe(2) mockResponse([], 100) await list.reset() expect(list.itemsPerPage.value).toBe(10) expect(list.filters.value).toEqual({ a: 'x' }) expect(list.sort.value).toEqual({ field: 'name', direction: 'asc' }) expect(list.currentPage.value).toBe(1) }) it('swallow l\'erreur reseau : items vides, loading false, fetch ne reject pas', async () => { mockApiGet.mockRejectedValueOnce(new Error('boom')) const list = usePaginatedList({ url: '/users' }) await expect(list.fetch()).resolves.toBeUndefined() expect(list.items.value).toEqual([]) expect(list.totalItems.value).toBe(0) expect(list.loading.value).toBe(false) // L'erreur est consideree comme un fetch consume -> isEmpty=true. expect(list.isEmpty.value).toBe(true) }) it('extraQuery est injecte a chaque fetch (ex : includeDeleted)', async () => { mockResponse([], 0) const list = usePaginatedList({ url: '/categories', extraQuery: { includeDeleted: 'true' }, }) await list.fetch() expect(mockApiGet.mock.calls[0][1]).toMatchObject({ includeDeleted: 'true' }) }) it('valeurs nulles/vides des filtres ne sont pas envoyees', async () => { mockResponse([], 0) const list = usePaginatedList({ url: '/users', defaultFilters: { name: '', q: undefined } as never, }) await list.fetch() const q = mockApiGet.mock.calls[0][1] as Record expect(q.name).toBeUndefined() expect(q.q).toBeUndefined() }) it('refresh() est un alias de fetch()', async () => { mockResponse([{ id: 1 }], 1) const list = usePaginatedList<{ id: number }>({ url: '/users' }) await list.refresh() expect(list.items.value).toEqual([{ id: 1 }]) }) })