299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
// src/hooks/useInfiniteQuery.test.ts
|
|
import { renderHook, waitFor, act } from '@testing-library/react';
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { useInfiniteQuery, PaginatedResponse } from './useInfiniteQuery';
|
|
|
|
// Mock the API function that the hook will call
|
|
const mockApiFunction = vi.fn();
|
|
|
|
describe('useInfiniteQuery Hook', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
// Helper to create a mock paginated response
|
|
const createMockResponse = <T>(
|
|
items: T[],
|
|
nextCursor: number | string | null | undefined,
|
|
): Response => {
|
|
const paginatedResponse: PaginatedResponse<T> = { items, nextCursor };
|
|
return new Response(JSON.stringify(paginatedResponse));
|
|
};
|
|
|
|
it('should be in loading state initially and fetch the first page', async () => {
|
|
const page1Items = [{ id: 1 }, { id: 2 }];
|
|
mockApiFunction.mockResolvedValue(createMockResponse(page1Items, 2));
|
|
|
|
const { result } = renderHook(() => useInfiniteQuery(mockApiFunction, { initialCursor: 1 }));
|
|
|
|
// Initial state
|
|
expect(result.current.isLoading).toBe(true);
|
|
expect(result.current.data).toEqual([]);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false);
|
|
expect(result.current.data).toEqual(page1Items);
|
|
expect(result.current.hasNextPage).toBe(true);
|
|
});
|
|
|
|
expect(mockApiFunction).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it('should fetch the next page and append data', async () => {
|
|
const page1Items = [{ id: 1 }];
|
|
const page2Items = [{ id: 2 }];
|
|
mockApiFunction
|
|
.mockResolvedValueOnce(createMockResponse(page1Items, 2))
|
|
.mockResolvedValueOnce(createMockResponse(page2Items, null)); // Last page
|
|
|
|
const { result } = renderHook(() => useInfiniteQuery(mockApiFunction, { initialCursor: 1 }));
|
|
|
|
// Wait for the first page to load
|
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
expect(result.current.data).toEqual(page1Items);
|
|
|
|
// Act: fetch the next page
|
|
act(() => {
|
|
result.current.fetchNextPage();
|
|
});
|
|
|
|
// Check fetching state
|
|
expect(result.current.isFetchingNextPage).toBe(true);
|
|
|
|
// Wait for the second page to load
|
|
await waitFor(() => {
|
|
expect(result.current.isFetchingNextPage).toBe(false);
|
|
// Data should be appended
|
|
expect(result.current.data).toEqual([...page1Items, ...page2Items]);
|
|
// hasNextPage should now be false
|
|
expect(result.current.hasNextPage).toBe(false);
|
|
});
|
|
|
|
expect(mockApiFunction).toHaveBeenCalledTimes(2);
|
|
expect(mockApiFunction).toHaveBeenCalledWith(2); // Called with the next cursor
|
|
});
|
|
|
|
it('should handle API errors', async () => {
|
|
const apiError = new Error('Network Error');
|
|
mockApiFunction.mockRejectedValue(apiError);
|
|
|
|
const { result } = renderHook(() => useInfiniteQuery(mockApiFunction));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false);
|
|
expect(result.current.error).toEqual(apiError);
|
|
expect(result.current.data).toEqual([]);
|
|
});
|
|
});
|
|
|
|
it('should handle a non-ok response with a simple JSON error message', async () => {
|
|
const errorPayload = { message: 'Server is on fire' };
|
|
mockApiFunction.mockResolvedValue(new Response(JSON.stringify(errorPayload), { status: 500 }));
|
|
|
|
const { result } = renderHook(() => useInfiniteQuery(mockApiFunction));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false);
|
|
expect(result.current.error).toBeInstanceOf(Error);
|
|
expect(result.current.error?.message).toBe('Server is on fire');
|
|
});
|
|
});
|
|
|
|
it('should handle a non-ok response with a Zod-style error message array', async () => {
|
|
const errorPayload = {
|
|
issues: [
|
|
{ path: ['query', 'limit'], message: 'Limit must be a positive number' },
|
|
{ path: ['query', 'offset'], message: 'Offset must be non-negative' },
|
|
],
|
|
};
|
|
mockApiFunction.mockResolvedValue(new Response(JSON.stringify(errorPayload), { status: 400 }));
|
|
|
|
const { result } = renderHook(() => useInfiniteQuery(mockApiFunction));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false);
|
|
expect(result.current.error).toBeInstanceOf(Error);
|
|
expect(result.current.error?.message).toBe(
|
|
'query.limit: Limit must be a positive number; query.offset: Offset must be non-negative',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should handle a Zod-style error message where path is missing', async () => {
|
|
const errorPayload = {
|
|
issues: [{ message: 'Global error' }],
|
|
};
|
|
mockApiFunction.mockResolvedValue(new Response(JSON.stringify(errorPayload), { status: 400 }));
|
|
|
|
const { result } = renderHook(() => useInfiniteQuery(mockApiFunction));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false);
|
|
expect(result.current.error).toBeInstanceOf(Error);
|
|
expect(result.current.error?.message).toBe('Error: Global error');
|
|
});
|
|
});
|
|
|
|
it('should handle a non-ok response with a non-JSON body', async () => {
|
|
mockApiFunction.mockResolvedValue(
|
|
new Response('Internal Server Error', {
|
|
status: 500,
|
|
statusText: 'Server Error',
|
|
}),
|
|
);
|
|
|
|
const { result } = renderHook(() => useInfiniteQuery(mockApiFunction));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false);
|
|
expect(result.current.error).toBeInstanceOf(Error);
|
|
expect(result.current.error?.message).toBe('Request failed with status 500: Server Error');
|
|
});
|
|
});
|
|
|
|
it('should set hasNextPage to false when nextCursor is null', async () => {
|
|
const page1Items = [{ id: 1 }];
|
|
mockApiFunction.mockResolvedValue(createMockResponse(page1Items, null));
|
|
|
|
const { result } = renderHook(() => useInfiniteQuery(mockApiFunction));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.hasNextPage).toBe(false);
|
|
});
|
|
});
|
|
|
|
it('should not fetch next page if hasNextPage is false or already fetching', async () => {
|
|
const page1Items = [{ id: 1 }];
|
|
mockApiFunction.mockResolvedValue(createMockResponse(page1Items, null)); // No next page
|
|
|
|
const { result } = renderHook(() => useInfiniteQuery(mockApiFunction));
|
|
|
|
// Wait for initial fetch
|
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
expect(result.current.hasNextPage).toBe(false);
|
|
expect(mockApiFunction).toHaveBeenCalledTimes(1);
|
|
|
|
// Act: try to fetch next page
|
|
act(() => {
|
|
result.current.fetchNextPage();
|
|
});
|
|
|
|
// Assert: no new API call was made
|
|
expect(mockApiFunction).toHaveBeenCalledTimes(1);
|
|
expect(result.current.isFetchingNextPage).toBe(false);
|
|
});
|
|
|
|
it('should refetch the first page when refetch is called', async () => {
|
|
const page1Items = [{ id: 1 }];
|
|
const page2Items = [{ id: 2 }];
|
|
const refetchedItems = [{ id: 10 }];
|
|
|
|
mockApiFunction
|
|
.mockResolvedValueOnce(createMockResponse(page1Items, 2))
|
|
.mockResolvedValueOnce(createMockResponse(page2Items, 3))
|
|
.mockResolvedValueOnce(createMockResponse(refetchedItems, 11)); // Refetch response
|
|
|
|
const { result } = renderHook(() => useInfiniteQuery(mockApiFunction, { initialCursor: 1 }));
|
|
|
|
// Load first two pages
|
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
act(() => {
|
|
result.current.fetchNextPage();
|
|
});
|
|
await waitFor(() => expect(result.current.isFetchingNextPage).toBe(false));
|
|
|
|
expect(result.current.data).toEqual([...page1Items, ...page2Items]);
|
|
expect(mockApiFunction).toHaveBeenCalledTimes(2);
|
|
|
|
// Act: call refetch
|
|
act(() => {
|
|
result.current.refetch();
|
|
});
|
|
|
|
// Assert: data is cleared and then repopulated with the first page
|
|
expect(result.current.isLoading).toBe(true);
|
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
expect(result.current.data).toEqual(refetchedItems);
|
|
expect(mockApiFunction).toHaveBeenCalledTimes(3);
|
|
expect(mockApiFunction).toHaveBeenLastCalledWith(1); // Called with initial cursor
|
|
});
|
|
|
|
it('should use 0 as default initialCursor if not provided', async () => {
|
|
mockApiFunction.mockResolvedValue(createMockResponse([], null));
|
|
renderHook(() => useInfiniteQuery(mockApiFunction));
|
|
expect(mockApiFunction).toHaveBeenCalledWith(0);
|
|
});
|
|
|
|
it('should clear error when fetching next page', async () => {
|
|
const page1Items = [{ id: 1 }];
|
|
const error = new Error('Fetch failed');
|
|
|
|
// First page succeeds
|
|
mockApiFunction.mockResolvedValueOnce(createMockResponse(page1Items, 2));
|
|
// Second page fails
|
|
mockApiFunction.mockRejectedValueOnce(error);
|
|
// Third attempt (retry second page) succeeds
|
|
mockApiFunction.mockResolvedValueOnce(createMockResponse([], null));
|
|
|
|
const { result } = renderHook(() => useInfiniteQuery(mockApiFunction));
|
|
|
|
// Wait for first page
|
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
expect(result.current.data).toEqual(page1Items);
|
|
|
|
// Try fetch next page -> fails
|
|
act(() => {
|
|
result.current.fetchNextPage();
|
|
});
|
|
await waitFor(() => expect(result.current.error).toEqual(error));
|
|
expect(result.current.isFetchingNextPage).toBe(false);
|
|
|
|
// Try fetch next page again -> succeeds, error should be cleared
|
|
act(() => {
|
|
result.current.fetchNextPage();
|
|
});
|
|
expect(result.current.error).toBeNull();
|
|
expect(result.current.isFetchingNextPage).toBe(true);
|
|
|
|
await waitFor(() => expect(result.current.isFetchingNextPage).toBe(false));
|
|
expect(result.current.error).toBeNull();
|
|
});
|
|
|
|
it('should clear error when refetching', async () => {
|
|
const error = new Error('Initial fail');
|
|
mockApiFunction.mockRejectedValueOnce(error);
|
|
mockApiFunction.mockResolvedValueOnce(createMockResponse([], null));
|
|
|
|
const { result } = renderHook(() => useInfiniteQuery(mockApiFunction));
|
|
|
|
await waitFor(() => expect(result.current.error).toEqual(error));
|
|
|
|
act(() => {
|
|
result.current.refetch();
|
|
});
|
|
expect(result.current.error).toBeNull();
|
|
expect(result.current.isLoading).toBe(true);
|
|
|
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
expect(result.current.error).toBeNull();
|
|
});
|
|
|
|
it('should set hasNextPage to false if nextCursor is undefined', async () => {
|
|
mockApiFunction.mockResolvedValue(createMockResponse([], undefined));
|
|
const { result } = renderHook(() => useInfiniteQuery(mockApiFunction));
|
|
await waitFor(() => expect(result.current.hasNextPage).toBe(false));
|
|
});
|
|
|
|
it('should handle non-Error objects thrown by apiFunction', async () => {
|
|
mockApiFunction.mockRejectedValue('String Error');
|
|
|
|
const { result } = renderHook(() => useInfiniteQuery(mockApiFunction));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false);
|
|
expect(result.current.error).toBeInstanceOf(Error);
|
|
expect(result.current.error?.message).toBe('An unknown error occurred.');
|
|
});
|
|
});
|
|
});
|