Files
flyer-crawler.projectium.com/src/hooks/useInfiniteQuery.test.ts

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.');
});
});
});