WellNuo/__tests__/services/errorHandler.test.ts
Sergei a238b7e35f Add comprehensive error handling system
- Add extended error types with severity levels, retry policies,
  and contextual information (types/errors.ts)
- Create centralized error handler service with user-friendly
  message translation and classification (services/errorHandler.ts)
- Add ErrorContext for global error state management with
  auto-dismiss and error queue support (contexts/ErrorContext.tsx)
- Create error UI components: ErrorToast, FieldError,
  FieldErrorSummary, FullScreenError, EmptyState, OfflineState
- Add useError hook with retry strategies and API response handling
- Add useAsync hook for async operations with comprehensive state
- Create error message utilities with validation helpers
- Add tests for errorHandler and errorMessages (88 tests)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-31 17:43:07 -08:00

372 lines
12 KiB
TypeScript

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