import { act } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { usePaginatedResource } from '../../src/hooks/usePaginatedResource'; import { renderHook } from '../helpers/renderHook'; type Item = { id: string; }; async function flushDebounce(delay = 250) { await act(async () => { vi.advanceTimersByTime(delay); await Promise.resolve(); }); } describe('usePaginatedResource', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it('loads first page after debounce and stores response fields', async () => { const loadMock = vi.fn(async () => ({ items: [{ id: '1' }] as Item[], page: 1, pageSize: 10, total: 25, totalPages: 3, })); const { result } = renderHook(() => usePaginatedResource({ load: loadMock, sort: '-createdAt', }), ); await flushDebounce(); expect(loadMock).toHaveBeenCalledWith({ q: '', page: 1, pageSize: 10, sort: '-createdAt', }); expect(result.current.items).toEqual([{ id: '1' }]); expect(result.current.total).toBe(25); expect(result.current.totalPages).toBe(3); expect(result.current.isLoading).toBe(false); expect(result.current.error).toBeNull(); }); it('resets page to 1 when query or page size changes', async () => { const loadMock = vi.fn( async ({ q, page, pageSize }: { q: string; page: number; pageSize: number }) => ({ items: [{ id: `${q}:${page}:${pageSize}` }] as Item[], page, pageSize, total: 1, totalPages: 1, }), ); const { result } = renderHook(() => usePaginatedResource({ load: loadMock, }), ); await flushDebounce(); act(() => { result.current.setPage(2); }); await flushDebounce(); expect(loadMock).toHaveBeenLastCalledWith({ q: '', page: 2, pageSize: 10, sort: undefined, }); act(() => { result.current.setQuery('search'); }); await flushDebounce(); expect(loadMock).toHaveBeenLastCalledWith({ q: 'search', page: 1, pageSize: 10, sort: undefined, }); act(() => { result.current.setPage(3); }); await flushDebounce(); act(() => { result.current.setPageSize(20); }); await flushDebounce(); expect(loadMock).toHaveBeenLastCalledWith({ q: 'search', page: 1, pageSize: 20, sort: undefined, }); }); it('cancels stale debounce timers when inputs change quickly', async () => { const loadMock = vi.fn(async () => ({ items: [] as Item[], page: 1, pageSize: 10, total: 0, totalPages: 0, })); const { result } = renderHook(() => usePaginatedResource({ load: loadMock, debounceMs: 100, }), ); act(() => { result.current.setQuery('latest'); }); await flushDebounce(100); expect(loadMock).toHaveBeenCalledTimes(1); expect(loadMock).toHaveBeenCalledWith({ q: 'latest', page: 1, pageSize: 10, sort: undefined, }); }); it('maps thrown errors into string error state', async () => { const loadMock = vi .fn() .mockRejectedValueOnce(new Error('Load failed')) .mockRejectedValueOnce('unknown'); const { result } = renderHook(() => usePaginatedResource({ load: loadMock, }), ); await flushDebounce(); expect(result.current.error).toBe('Load failed'); expect(result.current.isLoading).toBe(false); act(() => { result.current.setPage(2); }); await flushDebounce(); expect(result.current.error).toBe('Request failed. Please try again.'); expect(result.current.isLoading).toBe(false); }); });