- 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>
372 lines
12 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|