- 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>
453 lines
14 KiB
TypeScript
453 lines
14 KiB
TypeScript
/**
|
|
* Tests for Network Error Recovery Utilities
|
|
*/
|
|
|
|
import {
|
|
createTimeoutController,
|
|
isCircuitOpen,
|
|
recordSuccess,
|
|
recordFailure,
|
|
resetCircuit,
|
|
resetAllCircuits,
|
|
generateRequestKey,
|
|
deduplicateRequest,
|
|
isTimeoutError,
|
|
isNetworkError,
|
|
getErrorCode,
|
|
toApiError,
|
|
createNetworkErrorResponse,
|
|
DEFAULT_REQUEST_TIMEOUT,
|
|
SHORT_REQUEST_TIMEOUT,
|
|
LONG_REQUEST_TIMEOUT,
|
|
} from '../networkErrorRecovery';
|
|
import { ErrorCodes } from '@/types/errors';
|
|
|
|
// Mock isOnline to always return true for tests
|
|
jest.mock('../networkStatus', () => ({
|
|
isOnline: jest.fn().mockResolvedValue(true),
|
|
getNetworkStatus: jest.fn().mockResolvedValue('online'),
|
|
}));
|
|
|
|
describe('networkErrorRecovery', () => {
|
|
beforeEach(() => {
|
|
resetAllCircuits();
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('Timeout Constants', () => {
|
|
it('should have correct default timeout values', () => {
|
|
expect(DEFAULT_REQUEST_TIMEOUT).toBe(30000);
|
|
expect(SHORT_REQUEST_TIMEOUT).toBe(10000);
|
|
expect(LONG_REQUEST_TIMEOUT).toBe(60000);
|
|
});
|
|
});
|
|
|
|
describe('createTimeoutController', () => {
|
|
it('should create an AbortController with signal', () => {
|
|
const { controller, signal, cleanup } = createTimeoutController(1000);
|
|
|
|
expect(controller).toBeInstanceOf(AbortController);
|
|
expect(signal).toBe(controller.signal);
|
|
expect(typeof cleanup).toBe('function');
|
|
|
|
cleanup(); // Clean up to prevent timer leaks
|
|
});
|
|
|
|
it('should report not timed out initially', () => {
|
|
const { isTimedOut, cleanup } = createTimeoutController(1000);
|
|
|
|
expect(isTimedOut()).toBe(false);
|
|
|
|
cleanup();
|
|
});
|
|
|
|
it('should abort after timeout', async () => {
|
|
const { signal, isTimedOut, cleanup } = createTimeoutController(50);
|
|
|
|
expect(signal.aborted).toBe(false);
|
|
|
|
// Wait for timeout
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
expect(signal.aborted).toBe(true);
|
|
expect(isTimedOut()).toBe(true);
|
|
|
|
cleanup();
|
|
});
|
|
|
|
it('should not abort if cleaned up before timeout', async () => {
|
|
const { signal, cleanup } = createTimeoutController(100);
|
|
|
|
cleanup();
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
|
|
expect(signal.aborted).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Circuit Breaker', () => {
|
|
const circuitKey = 'test-circuit';
|
|
|
|
describe('isCircuitOpen', () => {
|
|
it('should return false for new circuit', () => {
|
|
expect(isCircuitOpen(circuitKey)).toBe(false);
|
|
});
|
|
|
|
it('should return false when failures are below threshold', () => {
|
|
recordFailure(circuitKey);
|
|
recordFailure(circuitKey);
|
|
recordFailure(circuitKey);
|
|
recordFailure(circuitKey);
|
|
// 4 failures, threshold is 5
|
|
|
|
expect(isCircuitOpen(circuitKey)).toBe(false);
|
|
});
|
|
|
|
it('should return true when failures reach threshold', () => {
|
|
for (let i = 0; i < 5; i++) {
|
|
recordFailure(circuitKey);
|
|
}
|
|
|
|
expect(isCircuitOpen(circuitKey)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('recordSuccess', () => {
|
|
it('should reset failure count on success', () => {
|
|
recordFailure(circuitKey);
|
|
recordFailure(circuitKey);
|
|
recordSuccess(circuitKey);
|
|
|
|
// After success, failures reset, so 5 more needed
|
|
for (let i = 0; i < 4; i++) {
|
|
recordFailure(circuitKey);
|
|
}
|
|
|
|
expect(isCircuitOpen(circuitKey)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('recordFailure', () => {
|
|
it('should increment failure count', () => {
|
|
recordFailure(circuitKey);
|
|
|
|
// Get state to verify (circuit still closed)
|
|
expect(isCircuitOpen(circuitKey)).toBe(false);
|
|
|
|
// Add more failures to reach threshold
|
|
for (let i = 0; i < 4; i++) {
|
|
recordFailure(circuitKey);
|
|
}
|
|
|
|
expect(isCircuitOpen(circuitKey)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('resetCircuit', () => {
|
|
it('should reset circuit state', () => {
|
|
for (let i = 0; i < 5; i++) {
|
|
recordFailure(circuitKey);
|
|
}
|
|
|
|
expect(isCircuitOpen(circuitKey)).toBe(true);
|
|
|
|
resetCircuit(circuitKey);
|
|
|
|
expect(isCircuitOpen(circuitKey)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Circuit transitions', () => {
|
|
it('should transition from open to half-open after reset timeout', async () => {
|
|
const fastConfig = {
|
|
failureThreshold: 2,
|
|
resetTimeout: 50,
|
|
successThreshold: 1,
|
|
};
|
|
|
|
recordFailure(circuitKey, fastConfig);
|
|
recordFailure(circuitKey, fastConfig);
|
|
|
|
expect(isCircuitOpen(circuitKey, fastConfig)).toBe(true);
|
|
|
|
// Wait for reset timeout
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
// Should transition to half-open (allows requests)
|
|
expect(isCircuitOpen(circuitKey, fastConfig)).toBe(false);
|
|
});
|
|
|
|
it('should close circuit after successes in half-open state', async () => {
|
|
const fastConfig = {
|
|
failureThreshold: 2,
|
|
resetTimeout: 50,
|
|
successThreshold: 2,
|
|
};
|
|
|
|
recordFailure(circuitKey, fastConfig);
|
|
recordFailure(circuitKey, fastConfig);
|
|
|
|
// Wait for half-open
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
isCircuitOpen(circuitKey, fastConfig); // Trigger transition
|
|
|
|
recordSuccess(circuitKey, fastConfig);
|
|
recordSuccess(circuitKey, fastConfig);
|
|
|
|
// Should be closed now, 2 failures needed again
|
|
recordFailure(circuitKey, fastConfig);
|
|
expect(isCircuitOpen(circuitKey, fastConfig)).toBe(false);
|
|
});
|
|
|
|
it('should reopen on failure in half-open state', async () => {
|
|
const fastConfig = {
|
|
failureThreshold: 2,
|
|
resetTimeout: 50,
|
|
successThreshold: 2,
|
|
};
|
|
|
|
recordFailure(circuitKey, fastConfig);
|
|
recordFailure(circuitKey, fastConfig);
|
|
|
|
// Wait for half-open
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
isCircuitOpen(circuitKey, fastConfig); // Trigger transition
|
|
|
|
// Fail in half-open state
|
|
recordFailure(circuitKey, fastConfig);
|
|
|
|
expect(isCircuitOpen(circuitKey, fastConfig)).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Request Deduplication', () => {
|
|
describe('generateRequestKey', () => {
|
|
it('should generate unique keys for different requests', () => {
|
|
const key1 = generateRequestKey('GET', 'https://api.example.com/users');
|
|
const key2 = generateRequestKey('GET', 'https://api.example.com/posts');
|
|
const key3 = generateRequestKey('POST', 'https://api.example.com/users');
|
|
|
|
expect(key1).not.toBe(key2);
|
|
expect(key1).not.toBe(key3);
|
|
});
|
|
|
|
it('should generate same key for identical requests', () => {
|
|
const key1 = generateRequestKey('GET', 'https://api.example.com/users');
|
|
const key2 = generateRequestKey('GET', 'https://api.example.com/users');
|
|
|
|
expect(key1).toBe(key2);
|
|
});
|
|
|
|
it('should include body hash in key', () => {
|
|
const key1 = generateRequestKey('POST', 'https://api.example.com/users', '{"name":"test"}');
|
|
const key2 = generateRequestKey('POST', 'https://api.example.com/users', '{"name":"other"}');
|
|
const key3 = generateRequestKey('POST', 'https://api.example.com/users', '{"name":"test"}');
|
|
|
|
expect(key1).not.toBe(key2);
|
|
expect(key1).toBe(key3);
|
|
});
|
|
});
|
|
|
|
describe('deduplicateRequest', () => {
|
|
it('should return same promise for concurrent identical requests', async () => {
|
|
let callCount = 0;
|
|
const mockRequest = jest.fn().mockImplementation(async () => {
|
|
callCount++;
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
return { data: 'result' };
|
|
});
|
|
|
|
const key = 'test-dedupe';
|
|
|
|
// Start both requests concurrently
|
|
const promise1 = deduplicateRequest(key, mockRequest);
|
|
const promise2 = deduplicateRequest(key, mockRequest);
|
|
|
|
const [result1, result2] = await Promise.all([promise1, promise2]);
|
|
|
|
expect(callCount).toBe(1);
|
|
expect(result1).toEqual({ data: 'result' });
|
|
expect(result2).toEqual({ data: 'result' });
|
|
});
|
|
|
|
it('should make separate calls for sequential requests', async () => {
|
|
let callCount = 0;
|
|
const mockRequest = jest.fn().mockImplementation(async () => {
|
|
callCount++;
|
|
return { data: callCount };
|
|
});
|
|
|
|
const key = 'test-sequential';
|
|
|
|
const result1 = await deduplicateRequest(key, mockRequest);
|
|
const result2 = await deduplicateRequest(key, mockRequest);
|
|
|
|
expect(callCount).toBe(2);
|
|
expect(result1).toEqual({ data: 1 });
|
|
expect(result2).toEqual({ data: 2 });
|
|
});
|
|
|
|
it('should handle errors and allow retry', async () => {
|
|
let callCount = 0;
|
|
const mockRequest = jest.fn().mockImplementation(async () => {
|
|
callCount++;
|
|
if (callCount === 1) {
|
|
throw new Error('First call failed');
|
|
}
|
|
return { data: 'success' };
|
|
});
|
|
|
|
const key = 'test-error-retry';
|
|
|
|
// First call fails
|
|
await expect(deduplicateRequest(key, mockRequest)).rejects.toThrow('First call failed');
|
|
|
|
// Second call should succeed
|
|
const result = await deduplicateRequest(key, mockRequest);
|
|
expect(result).toEqual({ data: 'success' });
|
|
expect(callCount).toBe(2);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Error Type Detection', () => {
|
|
describe('isTimeoutError', () => {
|
|
it('should return true for timeout errors', () => {
|
|
const error1 = new Error('Request timed out');
|
|
(error1 as any).code = ErrorCodes.NETWORK_TIMEOUT;
|
|
|
|
const error2 = new Error('timeout');
|
|
error2.name = 'TimeoutError';
|
|
|
|
const error3 = new Error('AbortError');
|
|
error3.name = 'AbortError';
|
|
|
|
const error4 = new Error('Connection timeout occurred');
|
|
|
|
expect(isTimeoutError(error1)).toBe(true);
|
|
expect(isTimeoutError(error2)).toBe(true);
|
|
expect(isTimeoutError(error3)).toBe(true);
|
|
expect(isTimeoutError(error4)).toBe(true);
|
|
});
|
|
|
|
it('should return false for non-timeout errors', () => {
|
|
const error = new Error('Network error');
|
|
(error as any).code = ErrorCodes.NETWORK_ERROR;
|
|
|
|
expect(isTimeoutError(error)).toBe(false);
|
|
expect(isTimeoutError(new Error('Something else'))).toBe(false);
|
|
expect(isTimeoutError(null)).toBe(false);
|
|
expect(isTimeoutError(undefined)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isNetworkError', () => {
|
|
it('should return true for network errors', () => {
|
|
const error1 = new Error('Network error');
|
|
(error1 as any).code = ErrorCodes.NETWORK_ERROR;
|
|
|
|
const error2 = new Error('');
|
|
(error2 as any).code = ErrorCodes.NETWORK_OFFLINE;
|
|
|
|
const error3 = new Error('Failed to fetch');
|
|
|
|
expect(isNetworkError(error1)).toBe(true);
|
|
expect(isNetworkError(error2)).toBe(true);
|
|
expect(isNetworkError(error3)).toBe(true);
|
|
});
|
|
|
|
it('should return true for timeout errors (which are network errors)', () => {
|
|
const error = new Error('');
|
|
(error as any).code = ErrorCodes.NETWORK_TIMEOUT;
|
|
|
|
expect(isNetworkError(error)).toBe(true);
|
|
});
|
|
|
|
it('should return false for non-network errors', () => {
|
|
const error = new Error('Validation failed');
|
|
(error as any).code = ErrorCodes.VALIDATION_ERROR;
|
|
|
|
expect(isNetworkError(error)).toBe(false);
|
|
expect(isNetworkError(new Error('Something else'))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getErrorCode', () => {
|
|
it('should return error code if present', () => {
|
|
const error = new Error('Test');
|
|
(error as any).code = 'CUSTOM_ERROR';
|
|
|
|
expect(getErrorCode(error)).toBe('CUSTOM_ERROR');
|
|
});
|
|
|
|
it('should return NETWORK_TIMEOUT for timeout errors', () => {
|
|
const error = new Error('');
|
|
error.name = 'TimeoutError';
|
|
|
|
expect(getErrorCode(error)).toBe(ErrorCodes.NETWORK_TIMEOUT);
|
|
});
|
|
|
|
it('should return NETWORK_OFFLINE for offline errors', () => {
|
|
const error = new Error('Device is offline');
|
|
|
|
expect(getErrorCode(error)).toBe(ErrorCodes.NETWORK_OFFLINE);
|
|
});
|
|
|
|
it('should return NETWORK_ERROR for general network errors', () => {
|
|
const error = new Error('Failed to fetch');
|
|
|
|
expect(getErrorCode(error)).toBe(ErrorCodes.NETWORK_ERROR);
|
|
});
|
|
|
|
it('should return UNKNOWN_ERROR for unknown errors', () => {
|
|
expect(getErrorCode(new Error('Random error'))).toBe(ErrorCodes.UNKNOWN_ERROR);
|
|
expect(getErrorCode(null)).toBe(ErrorCodes.UNKNOWN_ERROR);
|
|
expect(getErrorCode(undefined)).toBe(ErrorCodes.UNKNOWN_ERROR);
|
|
expect(getErrorCode(42)).toBe(ErrorCodes.UNKNOWN_ERROR);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Error Conversion', () => {
|
|
describe('toApiError', () => {
|
|
it('should convert Error to ApiError', () => {
|
|
const error = new Error('Something failed');
|
|
(error as any).code = 'TEST_ERROR';
|
|
|
|
const apiError = toApiError(error);
|
|
|
|
expect(apiError.message).toBe('Something failed');
|
|
expect(apiError.code).toBe('TEST_ERROR');
|
|
});
|
|
|
|
it('should convert string to ApiError', () => {
|
|
const apiError = toApiError('Error message');
|
|
|
|
expect(apiError.message).toBe('Error message');
|
|
expect(apiError.code).toBe(ErrorCodes.UNKNOWN_ERROR);
|
|
});
|
|
|
|
it('should handle unknown values', () => {
|
|
const apiError = toApiError(null);
|
|
|
|
expect(apiError.message).toBe('An unknown error occurred');
|
|
expect(apiError.code).toBe(ErrorCodes.UNKNOWN_ERROR);
|
|
});
|
|
});
|
|
|
|
describe('createNetworkErrorResponse', () => {
|
|
it('should create ApiResponse with error', () => {
|
|
const response = createNetworkErrorResponse('TEST_CODE', 'Test message');
|
|
|
|
expect(response.ok).toBe(false);
|
|
expect(response.error).toEqual({
|
|
message: 'Test message',
|
|
code: 'TEST_CODE',
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|