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