/** * Tests for Error Handler Service */ import { createErrorFromApi, createErrorFromException, createNetworkError, createValidationError, createErrorFromUnknown, createError, calculateRetryDelay, ErrorCodes, } from '@/services/errorHandler'; import { ErrorCategory, ErrorSeverity, isAppError, isRetryableError, isAuthError } from '@/types/errors'; describe('errorHandler', () => { describe('createErrorFromApi', () => { it('should create error from API error with code', () => { const apiError = { message: 'Invalid email address', code: 'INVALID_EMAIL', status: 400, }; const error = createErrorFromApi(apiError); expect(error.message).toBe('Invalid email address'); expect(error.code).toBe('INVALID_EMAIL'); expect(error.status).toBe(400); expect(error.category).toBe('validation'); expect(error.severity).toBe('error'); expect(error.errorId).toBeDefined(); expect(error.timestamp).toBeInstanceOf(Date); }); it('should classify 401 status as authentication error', () => { const apiError = { message: 'Unauthorized', status: 401, }; const error = createErrorFromApi(apiError); expect(error.category).toBe('authentication'); expect(error.retry.isRetryable).toBe(false); }); it('should classify 404 status as notFound error', () => { const apiError = { message: 'Not found', status: 404, }; const error = createErrorFromApi(apiError); expect(error.category).toBe('notFound'); expect(error.retry.isRetryable).toBe(false); }); it('should classify 500 status as server error with retry', () => { const apiError = { message: 'Internal server error', status: 500, }; const error = createErrorFromApi(apiError); expect(error.category).toBe('server'); expect(error.retry.isRetryable).toBe(true); }); it('should classify 429 status as rate limit error', () => { const apiError = { message: 'Too many requests', status: 429, }; const error = createErrorFromApi(apiError); expect(error.category).toBe('rateLimit'); expect(error.retry.isRetryable).toBe(true); }); it('should include context in error', () => { const apiError = { message: 'Error', code: 'TEST' }; const context = { userId: 123, action: 'test' }; const error = createErrorFromApi(apiError, context); expect(error.context).toEqual(context); }); }); describe('createErrorFromException', () => { it('should create error from JavaScript Error', () => { const jsError = new Error('Something went wrong'); const error = createErrorFromException(jsError); expect(error.message).toBe('Something went wrong'); expect(error.code).toBe(ErrorCodes.EXCEPTION); expect(error.originalError).toBe(jsError); }); it('should use custom code when provided', () => { const jsError = new Error('Network failed'); const error = createErrorFromException(jsError, ErrorCodes.NETWORK_ERROR); expect(error.code).toBe(ErrorCodes.NETWORK_ERROR); expect(error.category).toBe('network'); }); }); describe('createNetworkError', () => { it('should create network error with default message', () => { const error = createNetworkError(); expect(error.code).toBe(ErrorCodes.NETWORK_ERROR); expect(error.category).toBe('network'); expect(error.severity).toBe('warning'); expect(error.retry.isRetryable).toBe(true); }); it('should create timeout error when specified', () => { const error = createNetworkError('Request timed out', true); expect(error.code).toBe(ErrorCodes.NETWORK_TIMEOUT); expect(error.category).toBe('timeout'); expect(error.retry.isRetryable).toBe(true); }); }); describe('createValidationError', () => { it('should create validation error with field errors', () => { const fieldErrors = [ { field: 'email', message: 'Invalid email' }, { field: 'phone', message: 'Invalid phone' }, ]; const error = createValidationError('Validation failed', fieldErrors); expect(error.code).toBe(ErrorCodes.VALIDATION_ERROR); expect(error.category).toBe('validation'); expect(error.fieldErrors).toEqual(fieldErrors); expect(error.retry.isRetryable).toBe(false); }); }); describe('createErrorFromUnknown', () => { it('should handle string errors', () => { const error = createErrorFromUnknown('Something bad'); expect(error.message).toBe('Something bad'); }); it('should handle Error objects', () => { const jsError = new Error('Test error'); const error = createErrorFromUnknown(jsError); expect(error.message).toBe('Test error'); expect(error.originalError).toBe(jsError); }); it('should handle API-like objects', () => { const apiLike = { message: 'API error', code: 'TEST_CODE' }; const error = createErrorFromUnknown(apiLike); expect(error.message).toBe('API error'); expect(error.code).toBe('TEST_CODE'); }); it('should handle null/undefined', () => { const error = createErrorFromUnknown(null); expect(error.message).toBe('An unknown error occurred'); expect(error.code).toBe(ErrorCodes.UNKNOWN_ERROR); }); it('should pass through AppError objects', () => { const original = createError(ErrorCodes.NETWORK_ERROR); const result = createErrorFromUnknown(original); expect(result.errorId).toBe(original.errorId); }); }); describe('createError', () => { it('should create error with predefined code', () => { const error = createError(ErrorCodes.SUBSCRIPTION_REQUIRED); expect(error.code).toBe(ErrorCodes.SUBSCRIPTION_REQUIRED); expect(error.category).toBe('subscription'); expect(error.userMessage).toBeDefined(); }); it('should use custom message when provided', () => { const error = createError(ErrorCodes.NOT_FOUND, 'User not found'); expect(error.message).toBe('User not found'); }); }); describe('calculateRetryDelay', () => { it('should return base delay without exponential backoff', () => { const policy = { isRetryable: true, retryDelayMs: 1000, useExponentialBackoff: false, }; expect(calculateRetryDelay(policy, 0)).toBe(1000); expect(calculateRetryDelay(policy, 1)).toBe(1000); expect(calculateRetryDelay(policy, 2)).toBe(1000); }); it('should increase delay with exponential backoff', () => { const policy = { isRetryable: true, retryDelayMs: 1000, useExponentialBackoff: true, }; const delay0 = calculateRetryDelay(policy, 0); const delay1 = calculateRetryDelay(policy, 1); const delay2 = calculateRetryDelay(policy, 2); // With jitter, delays should be approximately 1000, 2000, 4000 expect(delay0).toBeGreaterThanOrEqual(1000); expect(delay0).toBeLessThan(1300); // 1000 + 30% jitter expect(delay1).toBeGreaterThanOrEqual(2000); expect(delay1).toBeLessThan(2600); expect(delay2).toBeGreaterThanOrEqual(4000); expect(delay2).toBeLessThan(5200); }); it('should cap delay at 30 seconds', () => { const policy = { isRetryable: true, retryDelayMs: 10000, useExponentialBackoff: true, }; const delay = calculateRetryDelay(policy, 5); // Would be 320000ms without cap expect(delay).toBeLessThanOrEqual(30000); }); it('should return 1000ms when no delay specified', () => { const policy = { isRetryable: true }; expect(calculateRetryDelay(policy, 0)).toBe(1000); }); }); describe('type guards', () => { describe('isAppError', () => { it('should return true for valid AppError', () => { const error = createError(ErrorCodes.NETWORK_ERROR); expect(isAppError(error)).toBe(true); }); it('should return false for plain objects', () => { expect(isAppError({ message: 'test' })).toBe(false); }); it('should return false for null/undefined', () => { expect(isAppError(null)).toBe(false); expect(isAppError(undefined)).toBe(false); }); }); describe('isRetryableError', () => { it('should return true for network errors', () => { const error = createNetworkError(); expect(isRetryableError(error)).toBe(true); }); it('should return false for auth errors', () => { const error = createError(ErrorCodes.UNAUTHORIZED); expect(isRetryableError(error)).toBe(false); }); it('should return false for validation errors', () => { const error = createValidationError('Invalid input'); expect(isRetryableError(error)).toBe(false); }); }); describe('isAuthError', () => { it('should return true for unauthorized errors', () => { const error = createError(ErrorCodes.UNAUTHORIZED); expect(isAuthError(error)).toBe(true); }); it('should return true for token expired errors', () => { const error = createError(ErrorCodes.TOKEN_EXPIRED); expect(isAuthError(error)).toBe(true); }); it('should return false for other errors', () => { const error = createNetworkError(); expect(isAuthError(error)).toBe(false); }); }); }); describe('error code classification', () => { const testCases: [string, ErrorCategory][] = [ [ErrorCodes.NETWORK_ERROR, 'network'], [ErrorCodes.NETWORK_TIMEOUT, 'timeout'], [ErrorCodes.UNAUTHORIZED, 'authentication'], [ErrorCodes.TOKEN_EXPIRED, 'authentication'], [ErrorCodes.FORBIDDEN, 'permission'], [ErrorCodes.NOT_FOUND, 'notFound'], [ErrorCodes.BENEFICIARY_NOT_FOUND, 'notFound'], [ErrorCodes.CONFLICT, 'conflict'], [ErrorCodes.DUPLICATE_ENTRY, 'conflict'], [ErrorCodes.VALIDATION_ERROR, 'validation'], [ErrorCodes.INVALID_EMAIL, 'validation'], [ErrorCodes.RATE_LIMITED, 'rateLimit'], [ErrorCodes.SERVER_ERROR, 'server'], [ErrorCodes.BLE_NOT_AVAILABLE, 'ble'], [ErrorCodes.SENSOR_OFFLINE, 'sensor'], [ErrorCodes.SUBSCRIPTION_REQUIRED, 'subscription'], ]; it.each(testCases)('should classify %s as %s category', (code, expectedCategory) => { const error = createError(code as any); expect(error.category).toBe(expectedCategory); }); }); describe('user messages', () => { it('should provide user-friendly message for network error', () => { const error = createError(ErrorCodes.NETWORK_ERROR); expect(error.userMessage).toContain('internet'); }); it('should provide user-friendly message for auth error', () => { const error = createError(ErrorCodes.UNAUTHORIZED); expect(error.userMessage).toContain('session'); }); it('should provide user-friendly message for subscription error', () => { const error = createError(ErrorCodes.SUBSCRIPTION_REQUIRED); expect(error.userMessage).toContain('subscription'); }); }); describe('action hints', () => { it('should provide action hint for network error', () => { const error = createError(ErrorCodes.NETWORK_ERROR); expect(error.actionHint).toBeDefined(); expect(error.actionHint).toContain('connection'); }); it('should provide action hint for auth error', () => { const error = createError(ErrorCodes.UNAUTHORIZED); expect(error.actionHint).toBeDefined(); expect(error.actionHint).toContain('log in'); }); it('should provide action hint for BLE error', () => { const error = createError(ErrorCodes.BLE_NOT_ENABLED); expect(error.actionHint).toBeDefined(); expect(error.actionHint).toContain('Bluetooth'); }); }); });