657 lines
19 KiB
TypeScript
657 lines
19 KiB
TypeScript
import { describe, test, expect, vi } from 'vitest';
|
|
import {
|
|
ErrorHelper,
|
|
ErrorCodes,
|
|
type ApiErrorResponse,
|
|
type AxiosErrorWithCode,
|
|
type ErrorCode
|
|
} from '../errors';
|
|
|
|
describe('ErrorHelper', () => {
|
|
describe('getErrorInfo', () => {
|
|
test('should handle structured API error response', () => {
|
|
const structuredError = {
|
|
response: {
|
|
data: {
|
|
error: 'User not found',
|
|
code: 'USER_NOT_FOUND',
|
|
status: 404
|
|
},
|
|
status: 404
|
|
}
|
|
};
|
|
|
|
const result = ErrorHelper.getErrorInfo(structuredError);
|
|
|
|
expect(result).toEqual({
|
|
message: 'User not found',
|
|
code: 'USER_NOT_FOUND',
|
|
status: 404
|
|
});
|
|
});
|
|
|
|
test('should handle legacy error format with message', () => {
|
|
const legacyError = {
|
|
response: {
|
|
data: {
|
|
message: 'Legacy error message'
|
|
},
|
|
status: 500
|
|
}
|
|
};
|
|
|
|
const result = ErrorHelper.getErrorInfo(legacyError);
|
|
|
|
expect(result).toEqual({
|
|
message: 'Legacy error message',
|
|
status: 500
|
|
});
|
|
});
|
|
|
|
test('should handle axios errors without structured data', () => {
|
|
const axiosError = {
|
|
message: 'Network Error',
|
|
response: {
|
|
status: 500
|
|
}
|
|
};
|
|
|
|
const result = ErrorHelper.getErrorInfo(axiosError);
|
|
|
|
expect(result).toEqual({
|
|
message: 'Network Error',
|
|
status: 500
|
|
});
|
|
});
|
|
|
|
test('should handle Error objects', () => {
|
|
const error = new Error('Something went wrong');
|
|
|
|
const result = ErrorHelper.getErrorInfo(error);
|
|
|
|
expect(result).toEqual({
|
|
message: 'Something went wrong'
|
|
});
|
|
});
|
|
|
|
test('should handle string errors', () => {
|
|
const result = ErrorHelper.getErrorInfo('Simple error string');
|
|
|
|
expect(result).toEqual({
|
|
message: 'An unknown error occurred'
|
|
});
|
|
});
|
|
|
|
test('should handle null/undefined errors', () => {
|
|
expect(ErrorHelper.getErrorInfo(null)).toEqual({
|
|
message: 'An unknown error occurred'
|
|
});
|
|
|
|
expect(ErrorHelper.getErrorInfo(undefined)).toEqual({
|
|
message: 'An unknown error occurred'
|
|
});
|
|
});
|
|
|
|
test('should handle empty response data', () => {
|
|
const emptyError = {
|
|
response: {
|
|
data: {},
|
|
status: 400
|
|
},
|
|
message: 'Bad Request'
|
|
};
|
|
|
|
const result = ErrorHelper.getErrorInfo(emptyError);
|
|
|
|
expect(result).toEqual({
|
|
message: 'Bad Request',
|
|
status: 400
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('isErrorCode', () => {
|
|
test('should return true for matching structured error code', () => {
|
|
const error = {
|
|
response: {
|
|
data: {
|
|
error: 'User not found',
|
|
code: 'USER_NOT_FOUND',
|
|
status: 404
|
|
}
|
|
}
|
|
};
|
|
|
|
expect(ErrorHelper.isErrorCode(error, ErrorCodes.USER_NOT_FOUND)).toBe(true);
|
|
});
|
|
|
|
test('should return false for non-matching structured error code', () => {
|
|
const error = {
|
|
response: {
|
|
data: {
|
|
error: 'User not found',
|
|
code: 'USER_NOT_FOUND',
|
|
status: 404
|
|
}
|
|
}
|
|
};
|
|
|
|
expect(ErrorHelper.isErrorCode(error, ErrorCodes.USER_DUPLICATE_USERNAME)).toBe(false);
|
|
});
|
|
|
|
test('should return false for legacy error without code', () => {
|
|
const error = {
|
|
response: {
|
|
data: {
|
|
message: 'Legacy error'
|
|
}
|
|
}
|
|
};
|
|
|
|
expect(ErrorHelper.isErrorCode(error, ErrorCodes.USER_NOT_FOUND)).toBe(false);
|
|
});
|
|
|
|
test('should return false for errors without response', () => {
|
|
const error = new Error('Network Error');
|
|
|
|
expect(ErrorHelper.isErrorCode(error, ErrorCodes.USER_NOT_FOUND)).toBe(false);
|
|
});
|
|
|
|
test('should handle null/undefined errors', () => {
|
|
expect(ErrorHelper.isErrorCode(null, ErrorCodes.USER_NOT_FOUND)).toBe(false);
|
|
expect(ErrorHelper.isErrorCode(undefined, ErrorCodes.USER_NOT_FOUND)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getUserMessage', () => {
|
|
test('should return error message for structured error', () => {
|
|
const error = {
|
|
response: {
|
|
data: {
|
|
error: 'Custom error message',
|
|
code: 'USER_NOT_FOUND',
|
|
status: 404
|
|
}
|
|
}
|
|
};
|
|
|
|
expect(ErrorHelper.getUserMessage(error)).toBe('Custom error message');
|
|
});
|
|
|
|
test('should return fallback message when provided', () => {
|
|
const error = null;
|
|
expect(ErrorHelper.getUserMessage(error, 'Custom fallback')).toBe('Custom fallback');
|
|
});
|
|
|
|
test('should return default fallback when no message', () => {
|
|
const error = null;
|
|
expect(ErrorHelper.getUserMessage(error)).toBe('An error occurred');
|
|
});
|
|
});
|
|
|
|
describe('getSuggestedAction', () => {
|
|
test('should return specific action for user duplicate username', () => {
|
|
const error = {
|
|
response: {
|
|
data: {
|
|
error: 'Username already exists',
|
|
code: 'USER_DUPLICATE_USERNAME',
|
|
status: 409
|
|
}
|
|
}
|
|
};
|
|
|
|
expect(ErrorHelper.getSuggestedAction(error)).toBe('Please choose a different username');
|
|
});
|
|
|
|
test('should return specific action for invalid credentials', () => {
|
|
const error = {
|
|
response: {
|
|
data: {
|
|
error: 'Invalid login',
|
|
code: 'USER_INVALID_CREDENTIALS',
|
|
status: 401
|
|
}
|
|
}
|
|
};
|
|
|
|
expect(ErrorHelper.getSuggestedAction(error)).toBe('Please check your username and password');
|
|
});
|
|
|
|
test('should return null for unknown error codes', () => {
|
|
const error = {
|
|
response: {
|
|
data: {
|
|
error: 'Unknown error',
|
|
code: 'UNKNOWN_ERROR_CODE',
|
|
status: 500
|
|
}
|
|
}
|
|
};
|
|
|
|
expect(ErrorHelper.getSuggestedAction(error)).toBe(null);
|
|
});
|
|
|
|
test('should return null for errors without codes', () => {
|
|
const error = new Error('Generic error');
|
|
expect(ErrorHelper.getSuggestedAction(error)).toBe(null);
|
|
});
|
|
});
|
|
|
|
describe('shouldShowRetry', () => {
|
|
test('should return true for retryable error codes', () => {
|
|
const error = {
|
|
response: {
|
|
data: {
|
|
error: 'Connection failed',
|
|
code: 'SOURCE_CONNECTION_FAILED',
|
|
status: 503
|
|
}
|
|
}
|
|
};
|
|
|
|
expect(ErrorHelper.shouldShowRetry(error)).toBe(true);
|
|
});
|
|
|
|
test('should return true for 5xx server errors', () => {
|
|
const error = {
|
|
response: {
|
|
data: {
|
|
message: 'Internal server error'
|
|
},
|
|
status: 500
|
|
}
|
|
};
|
|
|
|
expect(ErrorHelper.shouldShowRetry(error)).toBe(true);
|
|
});
|
|
|
|
test('should return false for client errors', () => {
|
|
const error = {
|
|
response: {
|
|
data: {
|
|
error: 'Bad request',
|
|
code: 'USER_INVALID_CREDENTIALS',
|
|
status: 400
|
|
}
|
|
}
|
|
};
|
|
|
|
expect(ErrorHelper.shouldShowRetry(error)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getErrorCategory', () => {
|
|
test('should categorize user auth errors correctly', () => {
|
|
const authCodes = [
|
|
'USER_INVALID_CREDENTIALS',
|
|
'USER_TOKEN_EXPIRED',
|
|
'USER_SESSION_EXPIRED'
|
|
];
|
|
|
|
authCodes.forEach(code => {
|
|
const error = {
|
|
response: {
|
|
data: { error: 'Test', code, status: 401 }
|
|
}
|
|
};
|
|
expect(ErrorHelper.getErrorCategory(error)).toBe('auth');
|
|
});
|
|
});
|
|
|
|
test('should categorize user validation errors correctly', () => {
|
|
const validationCodes = [
|
|
'USER_INVALID_PASSWORD',
|
|
'USER_INVALID_EMAIL',
|
|
'USER_INVALID_USERNAME'
|
|
];
|
|
|
|
validationCodes.forEach(code => {
|
|
const error = {
|
|
response: {
|
|
data: { error: 'Test', code, status: 400 }
|
|
}
|
|
};
|
|
expect(ErrorHelper.getErrorCategory(error)).toBe('validation');
|
|
});
|
|
});
|
|
|
|
test('should categorize network errors correctly', () => {
|
|
const error = {
|
|
response: {
|
|
data: {
|
|
error: 'Connection failed',
|
|
code: 'SOURCE_CONNECTION_FAILED',
|
|
status: 503
|
|
}
|
|
}
|
|
};
|
|
|
|
expect(ErrorHelper.getErrorCategory(error)).toBe('network');
|
|
});
|
|
|
|
test('should categorize by HTTP status for errors without specific codes', () => {
|
|
const statusTests = [
|
|
{ status: 400, expectedCategory: 'validation' },
|
|
{ status: 401, expectedCategory: 'auth' },
|
|
{ status: 403, expectedCategory: 'auth' },
|
|
{ status: 422, expectedCategory: 'validation' },
|
|
{ status: 500, expectedCategory: 'server' },
|
|
{ status: 502, expectedCategory: 'server' },
|
|
{ status: 503, expectedCategory: 'server' }
|
|
];
|
|
|
|
statusTests.forEach(({ status, expectedCategory }) => {
|
|
const error = {
|
|
response: {
|
|
data: { message: 'Test error' },
|
|
status
|
|
}
|
|
};
|
|
expect(ErrorHelper.getErrorCategory(error)).toBe(expectedCategory);
|
|
});
|
|
});
|
|
|
|
test('should return unknown for unclassified errors', () => {
|
|
const error = new Error('Generic error');
|
|
expect(ErrorHelper.getErrorCategory(error)).toBe('unknown');
|
|
});
|
|
});
|
|
|
|
describe('getErrorIcon', () => {
|
|
test('should return appropriate icons for error categories', () => {
|
|
const iconTests = [
|
|
{ category: 'auth', expectedIcon: '🔒' },
|
|
{ category: 'validation', expectedIcon: '⚠️' },
|
|
{ category: 'network', expectedIcon: '🌐' },
|
|
{ category: 'server', expectedIcon: '🔧' },
|
|
{ category: 'unknown', expectedIcon: '❌' }
|
|
];
|
|
|
|
iconTests.forEach(({ category, expectedIcon }) => {
|
|
// Create an error that will categorize to the desired category
|
|
let error;
|
|
switch (category) {
|
|
case 'auth':
|
|
error = { response: { data: { code: 'USER_INVALID_CREDENTIALS' }, status: 401 } };
|
|
break;
|
|
case 'validation':
|
|
error = { response: { data: { code: 'USER_INVALID_PASSWORD' }, status: 400 } };
|
|
break;
|
|
case 'network':
|
|
error = { response: { data: { code: 'SOURCE_CONNECTION_FAILED' }, status: 503 } };
|
|
break;
|
|
case 'server':
|
|
error = { response: { data: { message: 'Server error' }, status: 500 } };
|
|
break;
|
|
default:
|
|
error = new Error('Unknown error');
|
|
}
|
|
|
|
expect(ErrorHelper.getErrorIcon(error)).toBe(expectedIcon);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('formatErrorForDisplay', () => {
|
|
test('should format error with actions included', () => {
|
|
const error = {
|
|
response: {
|
|
data: {
|
|
error: 'Username already exists',
|
|
code: 'USER_DUPLICATE_USERNAME',
|
|
status: 409
|
|
}
|
|
}
|
|
};
|
|
|
|
const result = ErrorHelper.formatErrorForDisplay(error, true);
|
|
|
|
expect(result.message).toBe('Username already exists');
|
|
expect(result.code).toBe('USER_DUPLICATE_USERNAME');
|
|
expect(result.status).toBe(409);
|
|
expect(result.suggestedAction).toBe('Please choose a different username');
|
|
expect(result.category).toBe('validation');
|
|
expect(result.icon).toBe('⚠️');
|
|
expect(result.severity).toBe('warning');
|
|
expect(typeof result.shouldShowRetry).toBe('boolean');
|
|
});
|
|
|
|
test('should format error without actions', () => {
|
|
const error = {
|
|
response: {
|
|
data: {
|
|
error: 'Username already exists',
|
|
code: 'USER_DUPLICATE_USERNAME',
|
|
status: 409
|
|
}
|
|
}
|
|
};
|
|
|
|
const result = ErrorHelper.formatErrorForDisplay(error, false);
|
|
|
|
expect(result.message).toBe('Username already exists');
|
|
expect(result.suggestedAction).toBe(null);
|
|
expect(result.shouldShowRetry).toBe(false);
|
|
});
|
|
|
|
test('should set correct severity based on category', () => {
|
|
const severityTests = [
|
|
{ code: 'USER_INVALID_PASSWORD', expectedSeverity: 'warning' }, // validation
|
|
{ code: 'USER_INVALID_CREDENTIALS', expectedSeverity: 'info' }, // auth
|
|
{ code: 'SOURCE_CONNECTION_FAILED', expectedSeverity: 'error' } // network
|
|
];
|
|
|
|
severityTests.forEach(({ code, expectedSeverity }) => {
|
|
const error = {
|
|
response: {
|
|
data: { error: 'Test', code, status: 400 }
|
|
}
|
|
};
|
|
|
|
const result = ErrorHelper.formatErrorForDisplay(error, true);
|
|
expect(result.severity).toBe(expectedSeverity);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('handleSpecificError', () => {
|
|
test('should handle session expired errors with login callback', () => {
|
|
const error = {
|
|
response: {
|
|
data: {
|
|
error: 'Session expired',
|
|
code: 'USER_SESSION_EXPIRED',
|
|
status: 401
|
|
}
|
|
}
|
|
};
|
|
|
|
const onLogin = vi.fn();
|
|
const result = ErrorHelper.handleSpecificError(error, undefined, onLogin);
|
|
|
|
expect(result).toBe(true);
|
|
expect(onLogin).toHaveBeenCalled();
|
|
});
|
|
|
|
test('should handle retryable errors with retry callback', async () => {
|
|
const error = {
|
|
response: {
|
|
data: {
|
|
error: 'Connection failed',
|
|
code: 'SOURCE_CONNECTION_FAILED',
|
|
status: 503
|
|
}
|
|
}
|
|
};
|
|
|
|
const onRetry = vi.fn();
|
|
const result = ErrorHelper.handleSpecificError(error, onRetry);
|
|
|
|
expect(result).toBe(true);
|
|
|
|
// Wait for the timeout to trigger
|
|
await new Promise(resolve => setTimeout(resolve, 2100));
|
|
expect(onRetry).toHaveBeenCalled();
|
|
});
|
|
|
|
test('should not handle non-specific errors', () => {
|
|
const error = {
|
|
response: {
|
|
data: {
|
|
error: 'Generic error',
|
|
code: 'SOME_OTHER_ERROR',
|
|
status: 400
|
|
}
|
|
}
|
|
};
|
|
|
|
const result = ErrorHelper.handleSpecificError(error);
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
test('should not handle session expired without login callback', () => {
|
|
const error = {
|
|
response: {
|
|
data: {
|
|
error: 'Session expired',
|
|
code: 'USER_SESSION_EXPIRED',
|
|
status: 401
|
|
}
|
|
}
|
|
};
|
|
|
|
const result = ErrorHelper.handleSpecificError(error);
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('ErrorCodes constants', () => {
|
|
test('should have all required user error codes', () => {
|
|
expect(ErrorCodes.USER_NOT_FOUND).toBe('USER_NOT_FOUND');
|
|
expect(ErrorCodes.USER_DUPLICATE_USERNAME).toBe('USER_DUPLICATE_USERNAME');
|
|
expect(ErrorCodes.USER_DUPLICATE_EMAIL).toBe('USER_DUPLICATE_EMAIL');
|
|
expect(ErrorCodes.USER_INVALID_CREDENTIALS).toBe('USER_INVALID_CREDENTIALS');
|
|
expect(ErrorCodes.USER_SESSION_EXPIRED).toBe('USER_SESSION_EXPIRED');
|
|
expect(ErrorCodes.USER_TOKEN_EXPIRED).toBe('USER_TOKEN_EXPIRED');
|
|
expect(ErrorCodes.USER_PERMISSION_DENIED).toBe('USER_PERMISSION_DENIED');
|
|
expect(ErrorCodes.USER_ACCOUNT_DISABLED).toBe('USER_ACCOUNT_DISABLED');
|
|
});
|
|
|
|
test('should have all required source error codes', () => {
|
|
expect(ErrorCodes.SOURCE_NOT_FOUND).toBe('SOURCE_NOT_FOUND');
|
|
expect(ErrorCodes.SOURCE_CONNECTION_FAILED).toBe('SOURCE_CONNECTION_FAILED');
|
|
expect(ErrorCodes.SOURCE_AUTH_FAILED).toBe('SOURCE_AUTH_FAILED');
|
|
expect(ErrorCodes.SOURCE_CONFIG_INVALID).toBe('SOURCE_CONFIG_INVALID');
|
|
expect(ErrorCodes.SOURCE_SYNC_IN_PROGRESS).toBe('SOURCE_SYNC_IN_PROGRESS');
|
|
});
|
|
|
|
test('should have all required label error codes', () => {
|
|
expect(ErrorCodes.LABEL_NOT_FOUND).toBe('LABEL_NOT_FOUND');
|
|
expect(ErrorCodes.LABEL_DUPLICATE_NAME).toBe('LABEL_DUPLICATE_NAME');
|
|
expect(ErrorCodes.LABEL_INVALID_NAME).toBe('LABEL_INVALID_NAME');
|
|
expect(ErrorCodes.LABEL_INVALID_COLOR).toBe('LABEL_INVALID_COLOR');
|
|
expect(ErrorCodes.LABEL_IN_USE).toBe('LABEL_IN_USE');
|
|
expect(ErrorCodes.LABEL_SYSTEM_MODIFICATION).toBe('LABEL_SYSTEM_MODIFICATION');
|
|
expect(ErrorCodes.LABEL_MAX_LABELS_REACHED).toBe('LABEL_MAX_LABELS_REACHED');
|
|
});
|
|
});
|
|
|
|
describe('Edge cases and robustness', () => {
|
|
test('should handle malformed error objects', () => {
|
|
const malformedErrors = [
|
|
{ response: null },
|
|
{ response: { data: null } },
|
|
{ response: { data: { error: null, code: null } } },
|
|
{ response: { status: 'not-a-number' } },
|
|
{ code: 123 }, // numeric code instead of string
|
|
{ message: { nested: 'object' } } // object instead of string
|
|
];
|
|
|
|
malformedErrors.forEach(error => {
|
|
expect(() => ErrorHelper.getErrorInfo(error)).not.toThrow();
|
|
expect(() => ErrorHelper.formatErrorForDisplay(error, true)).not.toThrow();
|
|
expect(() => ErrorHelper.isErrorCode(error, ErrorCodes.USER_NOT_FOUND)).not.toThrow();
|
|
expect(() => ErrorHelper.getErrorCategory(error)).not.toThrow();
|
|
expect(() => ErrorHelper.getErrorIcon(error)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
test('should handle very long error messages', () => {
|
|
const longMessage = 'x'.repeat(10000);
|
|
const error = {
|
|
response: {
|
|
data: {
|
|
error: longMessage,
|
|
code: 'USER_NOT_FOUND',
|
|
status: 404
|
|
}
|
|
}
|
|
};
|
|
|
|
const result = ErrorHelper.getErrorInfo(error);
|
|
expect(result.message).toBe(longMessage);
|
|
expect(result.code).toBe('USER_NOT_FOUND');
|
|
});
|
|
|
|
test('should handle non-string error codes gracefully', () => {
|
|
const error = {
|
|
response: {
|
|
data: {
|
|
error: 'Test error',
|
|
code: 12345, // numeric code
|
|
status: 400
|
|
}
|
|
}
|
|
};
|
|
|
|
// Should not crash and should handle it as if no code was provided
|
|
expect(() => ErrorHelper.getErrorInfo(error)).not.toThrow();
|
|
expect(() => ErrorHelper.isErrorCode(error, ErrorCodes.USER_NOT_FOUND)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('Type safety', () => {
|
|
test('should handle ApiErrorResponse interface correctly', () => {
|
|
const apiError: ApiErrorResponse = {
|
|
error: 'Test error message',
|
|
code: 'USER_NOT_FOUND',
|
|
status: 404
|
|
};
|
|
|
|
const error = {
|
|
response: {
|
|
data: apiError,
|
|
status: 404
|
|
}
|
|
};
|
|
|
|
const result = ErrorHelper.getErrorInfo(error);
|
|
expect(result.message).toBe('Test error message');
|
|
expect(result.code).toBe('USER_NOT_FOUND');
|
|
expect(result.status).toBe(404);
|
|
});
|
|
|
|
test('should handle AxiosErrorWithCode interface correctly', () => {
|
|
const axiosError: AxiosErrorWithCode = {
|
|
response: {
|
|
data: {
|
|
error: 'Axios error',
|
|
code: 'SOURCE_CONNECTION_FAILED',
|
|
status: 503
|
|
},
|
|
status: 503,
|
|
statusText: 'Service Unavailable',
|
|
headers: {}
|
|
},
|
|
message: 'Request failed',
|
|
name: 'AxiosError'
|
|
};
|
|
|
|
const result = ErrorHelper.getErrorInfo(axiosError);
|
|
expect(result.message).toBe('Axios error');
|
|
expect(result.code).toBe('SOURCE_CONNECTION_FAILED');
|
|
expect(result.status).toBe(503);
|
|
});
|
|
});
|
|
}); |