WellNuo/hooks/__tests__/useApiWithErrorHandling.test.ts
Sergei 3260119ece Add comprehensive network error handling system
- 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>
2026-02-01 09:29:19 -08:00

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