- Add networkErrorRecovery utility with: - Request timeout handling via AbortController - Circuit breaker pattern to prevent cascading failures - Request deduplication for concurrent identical requests - Enhanced fetch with timeout, circuit breaker, and retry support - Add useApiWithErrorHandling hooks: - useApiCall for single API calls with auto error display - useMutation for mutations with optimistic update support - useMultipleApiCalls for parallel API execution - Add ErrorBoundary component: - Catches React errors in component tree - Displays fallback UI with retry option - Supports custom fallback components - withErrorBoundary HOC for easy wrapping - Add comprehensive tests (64 passing tests): - Circuit breaker state transitions - Request deduplication - Timeout detection - Error type classification - Hook behavior and error handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
407 lines
11 KiB
TypeScript
407 lines
11 KiB
TypeScript
/**
|
|
* Tests for useApiWithErrorHandling hooks
|
|
*/
|
|
|
|
import { renderHook, act, waitFor } from '@testing-library/react-native';
|
|
import { useApiCall, useMutation, useMultipleApiCalls } from '../useApiWithErrorHandling';
|
|
import { ApiResponse } from '@/types';
|
|
import { AppError } from '@/types/errors';
|
|
|
|
// Mock ErrorContext
|
|
const mockShowError = jest.fn();
|
|
jest.mock('@/contexts/ErrorContext', () => ({
|
|
useShowErrorSafe: () => mockShowError,
|
|
}));
|
|
|
|
// Mock errorHandler
|
|
jest.mock('@/services/errorHandler', () => ({
|
|
createErrorFromApi: jest.fn((error) => ({
|
|
message: error.message,
|
|
code: error.code || 'API_ERROR',
|
|
severity: 'error',
|
|
category: 'unknown',
|
|
retry: { isRetryable: false },
|
|
userMessage: error.message,
|
|
timestamp: new Date(),
|
|
errorId: 'test-error-id',
|
|
})),
|
|
createNetworkError: jest.fn((message, isTimeout = false) => ({
|
|
message,
|
|
code: isTimeout ? 'NETWORK_TIMEOUT' : 'NETWORK_ERROR',
|
|
severity: 'warning',
|
|
category: 'network',
|
|
retry: { isRetryable: true },
|
|
userMessage: message,
|
|
timestamp: new Date(),
|
|
errorId: 'test-network-error-id',
|
|
})),
|
|
}));
|
|
|
|
// Mock networkErrorRecovery
|
|
jest.mock('@/utils/networkErrorRecovery', () => ({
|
|
isNetworkError: jest.fn(() => false),
|
|
isTimeoutError: jest.fn(() => false),
|
|
toApiError: jest.fn((error) => ({
|
|
message: error instanceof Error ? error.message : String(error),
|
|
code: 'UNKNOWN_ERROR',
|
|
})),
|
|
}));
|
|
|
|
describe('useApiCall', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it('should start with initial state', () => {
|
|
const mockApiCall = jest.fn();
|
|
const { result } = renderHook(() => useApiCall(mockApiCall));
|
|
|
|
expect(result.current.isLoading).toBe(false);
|
|
expect(result.current.error).toBe(null);
|
|
expect(result.current.data).toBe(null);
|
|
});
|
|
|
|
it('should handle successful API call', async () => {
|
|
const mockData = { id: 1, name: 'Test' };
|
|
const mockApiCall = jest.fn().mockResolvedValue({
|
|
ok: true,
|
|
data: mockData,
|
|
} as ApiResponse<typeof mockData>);
|
|
|
|
const onSuccess = jest.fn();
|
|
const { result } = renderHook(() =>
|
|
useApiCall(mockApiCall, { onSuccess })
|
|
);
|
|
|
|
await act(async () => {
|
|
await result.current.execute();
|
|
});
|
|
|
|
expect(result.current.isLoading).toBe(false);
|
|
expect(result.current.data).toEqual(mockData);
|
|
expect(result.current.error).toBe(null);
|
|
expect(onSuccess).toHaveBeenCalled();
|
|
expect(mockShowError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle API error response', async () => {
|
|
const mockApiCall = jest.fn().mockResolvedValue({
|
|
ok: false,
|
|
error: { message: 'Not found', code: 'NOT_FOUND' },
|
|
} as ApiResponse<never>);
|
|
|
|
const onError = jest.fn();
|
|
const { result } = renderHook(() =>
|
|
useApiCall(mockApiCall, { onError })
|
|
);
|
|
|
|
await act(async () => {
|
|
await result.current.execute();
|
|
});
|
|
|
|
expect(result.current.isLoading).toBe(false);
|
|
expect(result.current.data).toBe(null);
|
|
expect(result.current.error).not.toBe(null);
|
|
expect(result.current.error?.message).toBe('Not found');
|
|
expect(onError).toHaveBeenCalled();
|
|
expect(mockShowError).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not show error when showError is false', async () => {
|
|
const mockApiCall = jest.fn().mockResolvedValue({
|
|
ok: false,
|
|
error: { message: 'Error' },
|
|
} as ApiResponse<never>);
|
|
|
|
const { result } = renderHook(() =>
|
|
useApiCall(mockApiCall, { showError: false })
|
|
);
|
|
|
|
await act(async () => {
|
|
await result.current.execute();
|
|
});
|
|
|
|
expect(result.current.error).not.toBe(null);
|
|
expect(mockShowError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle thrown exceptions', async () => {
|
|
const mockApiCall = jest.fn().mockRejectedValue(new Error('Network failed'));
|
|
|
|
const { result } = renderHook(() => useApiCall(mockApiCall));
|
|
|
|
await act(async () => {
|
|
await result.current.execute();
|
|
});
|
|
|
|
expect(result.current.isLoading).toBe(false);
|
|
expect(result.current.error).not.toBe(null);
|
|
expect(mockShowError).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should reset state', async () => {
|
|
const mockData = { id: 1 };
|
|
const mockApiCall = jest.fn().mockResolvedValue({
|
|
ok: true,
|
|
data: mockData,
|
|
});
|
|
|
|
const { result } = renderHook(() => useApiCall(mockApiCall));
|
|
|
|
await act(async () => {
|
|
await result.current.execute();
|
|
});
|
|
|
|
expect(result.current.data).toEqual(mockData);
|
|
|
|
act(() => {
|
|
result.current.reset();
|
|
});
|
|
|
|
expect(result.current.isLoading).toBe(false);
|
|
expect(result.current.error).toBe(null);
|
|
expect(result.current.data).toBe(null);
|
|
});
|
|
|
|
it('should retry last call', async () => {
|
|
let callCount = 0;
|
|
const mockApiCall = jest.fn().mockImplementation(async () => {
|
|
callCount++;
|
|
if (callCount === 1) {
|
|
return { ok: false, error: { message: 'First failed' } };
|
|
}
|
|
return { ok: true, data: { success: true } };
|
|
});
|
|
|
|
const { result } = renderHook(() => useApiCall(mockApiCall));
|
|
|
|
// First call fails
|
|
await act(async () => {
|
|
await result.current.execute();
|
|
});
|
|
|
|
expect(result.current.error).not.toBe(null);
|
|
|
|
// Retry succeeds
|
|
await act(async () => {
|
|
await result.current.retry();
|
|
});
|
|
|
|
expect(result.current.data).toEqual({ success: true });
|
|
expect(result.current.error).toBe(null);
|
|
});
|
|
|
|
it('should set loading state during execution', async () => {
|
|
let resolvePromise: (value: ApiResponse<any>) => void;
|
|
const mockApiCall = jest.fn().mockReturnValue(
|
|
new Promise(resolve => {
|
|
resolvePromise = resolve;
|
|
})
|
|
);
|
|
|
|
const { result } = renderHook(() => useApiCall(mockApiCall));
|
|
|
|
// Start execution
|
|
let executePromise: Promise<any>;
|
|
act(() => {
|
|
executePromise = result.current.execute();
|
|
});
|
|
|
|
expect(result.current.isLoading).toBe(true);
|
|
|
|
// Resolve the promise
|
|
await act(async () => {
|
|
resolvePromise!({ ok: true, data: {} });
|
|
await executePromise;
|
|
});
|
|
|
|
expect(result.current.isLoading).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('useMutation', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it('should start with initial state', () => {
|
|
const mockMutationFn = jest.fn();
|
|
const { result } = renderHook(() => useMutation(mockMutationFn));
|
|
|
|
expect(result.current.isLoading).toBe(false);
|
|
expect(result.current.error).toBe(null);
|
|
expect(result.current.data).toBe(null);
|
|
expect(result.current.isError).toBe(false);
|
|
expect(result.current.isSuccess).toBe(false);
|
|
});
|
|
|
|
it('should handle successful mutation', async () => {
|
|
const mockData = { id: 1, created: true };
|
|
const mockMutationFn = jest.fn().mockResolvedValue({
|
|
ok: true,
|
|
data: mockData,
|
|
});
|
|
|
|
const onMutate = jest.fn();
|
|
const onSuccess = jest.fn();
|
|
const onSettled = jest.fn();
|
|
|
|
const { result } = renderHook(() =>
|
|
useMutation(mockMutationFn, { onMutate, onSuccess, onSettled })
|
|
);
|
|
|
|
await act(async () => {
|
|
await result.current.mutate({ name: 'Test' });
|
|
});
|
|
|
|
expect(onMutate).toHaveBeenCalledWith({ name: 'Test' });
|
|
expect(onSuccess).toHaveBeenCalledWith(mockData, { name: 'Test' });
|
|
expect(onSettled).toHaveBeenCalledWith(mockData, null, { name: 'Test' });
|
|
expect(result.current.data).toEqual(mockData);
|
|
expect(result.current.isSuccess).toBe(true);
|
|
expect(result.current.isError).toBe(false);
|
|
});
|
|
|
|
it('should handle mutation error', async () => {
|
|
const mockMutationFn = jest.fn().mockResolvedValue({
|
|
ok: false,
|
|
error: { message: 'Mutation failed' },
|
|
});
|
|
|
|
const onError = jest.fn();
|
|
const onSettled = jest.fn();
|
|
|
|
const { result } = renderHook(() =>
|
|
useMutation(mockMutationFn, { onError, onSettled })
|
|
);
|
|
|
|
await act(async () => {
|
|
await result.current.mutate({ name: 'Test' });
|
|
});
|
|
|
|
expect(onError).toHaveBeenCalled();
|
|
expect(onSettled).toHaveBeenCalled();
|
|
expect(result.current.isError).toBe(true);
|
|
expect(result.current.isSuccess).toBe(false);
|
|
});
|
|
|
|
it('should call onMutate for optimistic updates', async () => {
|
|
const callOrder: string[] = [];
|
|
const mockMutationFn = jest.fn().mockImplementation(async () => {
|
|
callOrder.push('mutation');
|
|
return { ok: true, data: {} };
|
|
});
|
|
const onMutate = jest.fn().mockImplementation(async () => {
|
|
callOrder.push('onMutate');
|
|
});
|
|
|
|
const { result } = renderHook(() =>
|
|
useMutation(mockMutationFn, { onMutate })
|
|
);
|
|
|
|
await act(async () => {
|
|
await result.current.mutate({ id: 1 });
|
|
});
|
|
|
|
expect(onMutate).toHaveBeenCalledWith({ id: 1 });
|
|
expect(callOrder).toEqual(['onMutate', 'mutation']);
|
|
});
|
|
|
|
it('should reset state', async () => {
|
|
const mockMutationFn = jest.fn().mockResolvedValue({
|
|
ok: true,
|
|
data: { id: 1 },
|
|
});
|
|
|
|
const { result } = renderHook(() => useMutation(mockMutationFn));
|
|
|
|
await act(async () => {
|
|
await result.current.mutate({});
|
|
});
|
|
|
|
expect(result.current.data).not.toBe(null);
|
|
|
|
act(() => {
|
|
result.current.reset();
|
|
});
|
|
|
|
expect(result.current.isLoading).toBe(false);
|
|
expect(result.current.error).toBe(null);
|
|
expect(result.current.data).toBe(null);
|
|
});
|
|
});
|
|
|
|
describe('useMultipleApiCalls', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it('should execute multiple calls in parallel', async () => {
|
|
const call1 = jest.fn().mockResolvedValue({ ok: true, data: { id: 1 } });
|
|
const call2 = jest.fn().mockResolvedValue({ ok: true, data: { id: 2 } });
|
|
const call3 = jest.fn().mockResolvedValue({ ok: true, data: { id: 3 } });
|
|
|
|
const { result } = renderHook(() => useMultipleApiCalls());
|
|
|
|
let results: any[];
|
|
await act(async () => {
|
|
results = await result.current.executeAll([call1, call2, call3]);
|
|
});
|
|
|
|
expect(results!).toHaveLength(3);
|
|
expect(results![0].data).toEqual({ id: 1 });
|
|
expect(results![1].data).toEqual({ id: 2 });
|
|
expect(results![2].data).toEqual({ id: 3 });
|
|
expect(result.current.hasErrors).toBe(false);
|
|
});
|
|
|
|
it('should collect errors from failed calls', async () => {
|
|
const call1 = jest.fn().mockResolvedValue({ ok: true, data: { id: 1 } });
|
|
const call2 = jest.fn().mockResolvedValue({ ok: false, error: { message: 'Failed' } });
|
|
const call3 = jest.fn().mockResolvedValue({ ok: true, data: { id: 3 } });
|
|
|
|
const onError = jest.fn();
|
|
const { result } = renderHook(() => useMultipleApiCalls());
|
|
|
|
await act(async () => {
|
|
await result.current.executeAll([call1, call2, call3], { onError });
|
|
});
|
|
|
|
expect(result.current.hasErrors).toBe(true);
|
|
expect(result.current.errors).toHaveLength(1);
|
|
expect(onError).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should reset state', async () => {
|
|
const call1 = jest.fn().mockResolvedValue({ ok: false, error: { message: 'Error' } });
|
|
|
|
const { result } = renderHook(() => useMultipleApiCalls());
|
|
|
|
await act(async () => {
|
|
await result.current.executeAll([call1]);
|
|
});
|
|
|
|
expect(result.current.hasErrors).toBe(true);
|
|
|
|
act(() => {
|
|
result.current.reset();
|
|
});
|
|
|
|
expect(result.current.isLoading).toBe(false);
|
|
expect(result.current.errors).toEqual([]);
|
|
expect(result.current.hasErrors).toBe(false);
|
|
});
|
|
|
|
it('should handle thrown exceptions', async () => {
|
|
const call1 = jest.fn().mockRejectedValue(new Error('Exception'));
|
|
|
|
const { result } = renderHook(() => useMultipleApiCalls());
|
|
|
|
await act(async () => {
|
|
await result.current.executeAll([call1]);
|
|
});
|
|
|
|
expect(result.current.hasErrors).toBe(true);
|
|
});
|
|
});
|