All checks were successful
continuous-integration/drone/push Build is passing
171 lines
4.4 KiB
TypeScript
171 lines
4.4 KiB
TypeScript
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<Item>({
|
|
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<Item>({
|
|
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<Item>({
|
|
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<Item>({
|
|
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);
|
|
});
|
|
});
|