WellNuo/utils/__tests__/networkErrorRecovery.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

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