367 lines
11 KiB
TypeScript
367 lines
11 KiB
TypeScript
|
import { tap, expect } from '@push.rocks/tapbundle';
|
||
|
import * as errors from '../ts/errors/index.js';
|
||
|
import {
|
||
|
PlatformError,
|
||
|
ValidationError,
|
||
|
NetworkError,
|
||
|
ResourceError,
|
||
|
OperationError
|
||
|
} from '../ts/errors/base.errors.js';
|
||
|
import {
|
||
|
ErrorSeverity,
|
||
|
ErrorCategory,
|
||
|
ErrorRecoverability
|
||
|
} from '../ts/errors/error.codes.js';
|
||
|
import {
|
||
|
EmailServiceError,
|
||
|
EmailTemplateError,
|
||
|
EmailValidationError,
|
||
|
EmailSendError,
|
||
|
EmailReceiveError
|
||
|
} from '../ts/errors/email.errors.js';
|
||
|
import {
|
||
|
MtaConnectionError,
|
||
|
MtaAuthenticationError,
|
||
|
MtaDeliveryError,
|
||
|
MtaConfigurationError
|
||
|
} from '../ts/errors/mta.errors.js';
|
||
|
import {
|
||
|
ErrorHandler
|
||
|
} from '../ts/errors/error-handler.js';
|
||
|
|
||
|
// Test base error classes
|
||
|
tap.test('Base error classes should set properties correctly', async () => {
|
||
|
const message = 'Test error message';
|
||
|
const code = 'TEST_ERROR_CODE';
|
||
|
const context = {
|
||
|
component: 'TestComponent',
|
||
|
operation: 'testOperation',
|
||
|
data: { foo: 'bar' }
|
||
|
};
|
||
|
|
||
|
// Test PlatformError
|
||
|
const platformError = new PlatformError(
|
||
|
message,
|
||
|
code,
|
||
|
ErrorSeverity.MEDIUM,
|
||
|
ErrorCategory.OPERATION,
|
||
|
ErrorRecoverability.MAYBE_RECOVERABLE,
|
||
|
context
|
||
|
);
|
||
|
|
||
|
expect(platformError.message).toEqual(message);
|
||
|
expect(platformError.code).toEqual(code);
|
||
|
expect(platformError.severity).toEqual(ErrorSeverity.MEDIUM);
|
||
|
expect(platformError.category).toEqual(ErrorCategory.OPERATION);
|
||
|
expect(platformError.recoverability).toEqual(ErrorRecoverability.MAYBE_RECOVERABLE);
|
||
|
expect(platformError.context.component).toEqual(context.component);
|
||
|
expect(platformError.context.operation).toEqual(context.operation);
|
||
|
expect(platformError.context.data.foo).toEqual('bar');
|
||
|
expect(platformError.name).toEqual('PlatformError');
|
||
|
|
||
|
// Test ValidationError
|
||
|
const validationError = new ValidationError(message, code, context);
|
||
|
expect(validationError.category).toEqual(ErrorCategory.VALIDATION);
|
||
|
expect(validationError.severity).toEqual(ErrorSeverity.LOW);
|
||
|
|
||
|
// Test NetworkError
|
||
|
const networkError = new NetworkError(message, code, context);
|
||
|
expect(networkError.category).toEqual(ErrorCategory.CONNECTIVITY);
|
||
|
expect(networkError.severity).toEqual(ErrorSeverity.MEDIUM);
|
||
|
expect(networkError.recoverability).toEqual(ErrorRecoverability.MAYBE_RECOVERABLE);
|
||
|
|
||
|
// Test ResourceError
|
||
|
const resourceError = new ResourceError(message, code, context);
|
||
|
expect(resourceError.category).toEqual(ErrorCategory.RESOURCE);
|
||
|
});
|
||
|
|
||
|
// Test email error classes
|
||
|
tap.test('Email error classes should be properly constructed', async () => {
|
||
|
// Test EmailServiceError
|
||
|
const emailServiceError = new EmailServiceError('Email service error', {
|
||
|
component: 'EmailService',
|
||
|
operation: 'sendEmail'
|
||
|
});
|
||
|
expect(emailServiceError.code).toEqual('EMAIL_SERVICE_ERROR');
|
||
|
expect(emailServiceError.name).toEqual('EmailServiceError');
|
||
|
|
||
|
// Test EmailTemplateError
|
||
|
const templateError = new EmailTemplateError('Template not found: welcome_email', {
|
||
|
data: { templateId: 'welcome_email' }
|
||
|
});
|
||
|
expect(templateError.code).toEqual('EMAIL_TEMPLATE_ERROR');
|
||
|
expect(templateError.context.data.templateId).toEqual('welcome_email');
|
||
|
|
||
|
// Test EmailSendError with permanent flag
|
||
|
const permanentError = EmailSendError.permanent(
|
||
|
'Invalid recipient',
|
||
|
'user@example.com',
|
||
|
{ data: { details: 'DNS not found' } }
|
||
|
);
|
||
|
expect(permanentError.code).toEqual('EMAIL_SEND_ERROR');
|
||
|
expect(permanentError.isPermanent()).toEqual(true);
|
||
|
expect(permanentError.context.data.permanent).toEqual(true);
|
||
|
|
||
|
// Test EmailSendError with temporary flag and retry
|
||
|
const tempError = EmailSendError.temporary(
|
||
|
'Server busy',
|
||
|
3,
|
||
|
0,
|
||
|
1000,
|
||
|
{ data: { server: 'smtp.example.com' } }
|
||
|
);
|
||
|
expect(tempError.isPermanent()).toEqual(false);
|
||
|
expect(tempError.context.data.permanent).toEqual(false);
|
||
|
expect(tempError.context.retry.maxRetries).toEqual(3);
|
||
|
expect(tempError.shouldRetry()).toEqual(true);
|
||
|
});
|
||
|
|
||
|
// Test MTA error classes
|
||
|
tap.test('MTA error classes should be properly constructed', async () => {
|
||
|
// Test MtaConnectionError
|
||
|
const dnsError = MtaConnectionError.dnsError('mail.example.com', new Error('DNS lookup failed'));
|
||
|
expect(dnsError.code).toEqual('MTA_CONNECTION_ERROR');
|
||
|
expect(dnsError.category).toEqual(ErrorCategory.CONNECTIVITY);
|
||
|
expect(dnsError.context.data.hostname).toEqual('mail.example.com');
|
||
|
|
||
|
// Test MtaTimeoutError via MtaConnectionError.timeout
|
||
|
const timeoutError = MtaConnectionError.timeout('mail.example.com', 25, 30000);
|
||
|
expect(timeoutError.code).toEqual('MTA_CONNECTION_ERROR');
|
||
|
expect(timeoutError.context.data.timeout).toEqual(30000);
|
||
|
|
||
|
// Test MtaAuthenticationError
|
||
|
const authError = MtaAuthenticationError.invalidCredentials('mail.example.com', 'user@example.com');
|
||
|
expect(authError.code).toEqual('MTA_AUTHENTICATION_ERROR');
|
||
|
expect(authError.category).toEqual(ErrorCategory.AUTHENTICATION);
|
||
|
expect(authError.context.data.username).toEqual('user@example.com');
|
||
|
|
||
|
// Test MtaDeliveryError
|
||
|
const permDeliveryError = MtaDeliveryError.permanent(
|
||
|
'User unknown',
|
||
|
'nonexistent@example.com',
|
||
|
'550',
|
||
|
'550 5.1.1 User unknown'
|
||
|
);
|
||
|
expect(permDeliveryError.code).toEqual('MTA_DELIVERY_ERROR');
|
||
|
expect(permDeliveryError.isPermanent()).toEqual(true);
|
||
|
expect(permDeliveryError.getRecipientAddress()).toEqual('nonexistent@example.com');
|
||
|
expect(permDeliveryError.getStatusCode()).toEqual('550');
|
||
|
|
||
|
// Test temporary delivery error with retry
|
||
|
const tempDeliveryError = MtaDeliveryError.temporary(
|
||
|
'Mailbox temporarily unavailable',
|
||
|
'user@example.com',
|
||
|
'450',
|
||
|
'450 4.2.1 Mailbox temporarily unavailable',
|
||
|
3,
|
||
|
1,
|
||
|
5000
|
||
|
);
|
||
|
expect(tempDeliveryError.isPermanent()).toEqual(false);
|
||
|
expect(tempDeliveryError.shouldRetry()).toEqual(true);
|
||
|
expect(tempDeliveryError.context.retry.currentRetry).toEqual(1);
|
||
|
expect(tempDeliveryError.context.retry.maxRetries).toEqual(3);
|
||
|
});
|
||
|
|
||
|
// Test error handler utility
|
||
|
tap.test('ErrorHandler should properly handle and format errors', async () => {
|
||
|
// Configure error handler
|
||
|
ErrorHandler.configure({
|
||
|
logErrors: false, // Disable for testing
|
||
|
includeStacksInProd: false,
|
||
|
retry: {
|
||
|
maxAttempts: 5,
|
||
|
baseDelay: 100,
|
||
|
maxDelay: 1000,
|
||
|
backoffFactor: 2
|
||
|
}
|
||
|
});
|
||
|
|
||
|
// Test converting regular Error to PlatformError
|
||
|
const regularError = new Error('Something went wrong');
|
||
|
const platformError = ErrorHandler.toPlatformError(
|
||
|
regularError,
|
||
|
'PLATFORM_OPERATION_ERROR',
|
||
|
{ component: 'TestHandler' }
|
||
|
);
|
||
|
|
||
|
expect(platformError).toBeInstanceOf(PlatformError);
|
||
|
expect(platformError.code).toEqual('PLATFORM_OPERATION_ERROR');
|
||
|
expect(platformError.context.component).toEqual('TestHandler');
|
||
|
|
||
|
// Test formatting error for API response
|
||
|
const formattedError = ErrorHandler.formatErrorForResponse(platformError, true);
|
||
|
expect(formattedError.code).toEqual('PLATFORM_OPERATION_ERROR');
|
||
|
expect(formattedError.message).toEqual('An unexpected error occurred.');
|
||
|
expect(formattedError.details.rawMessage).toEqual('Something went wrong');
|
||
|
|
||
|
// Test executing a function with error handling
|
||
|
let executed = false;
|
||
|
try {
|
||
|
await ErrorHandler.execute(async () => {
|
||
|
executed = true;
|
||
|
throw new Error('Execution failed');
|
||
|
}, 'TEST_EXECUTION_ERROR', { operation: 'testExecution' });
|
||
|
} catch (error) {
|
||
|
expect(error).toBeInstanceOf(PlatformError);
|
||
|
expect(error.code).toEqual('TEST_EXECUTION_ERROR');
|
||
|
expect(error.context.operation).toEqual('testExecution');
|
||
|
}
|
||
|
expect(executed).toEqual(true);
|
||
|
|
||
|
// Test executeWithRetry successful after retries
|
||
|
let attempts = 0;
|
||
|
const result = await ErrorHandler.executeWithRetry(
|
||
|
async () => {
|
||
|
attempts++;
|
||
|
if (attempts < 3) {
|
||
|
throw new Error('Temporary failure');
|
||
|
}
|
||
|
return 'success';
|
||
|
},
|
||
|
'TEST_RETRY_ERROR',
|
||
|
{
|
||
|
maxAttempts: 5,
|
||
|
baseDelay: 10, // Use small delay for tests
|
||
|
onRetry: (error, attempt, delay) => {
|
||
|
expect(error).toBeInstanceOf(PlatformError);
|
||
|
expect(attempt).toBeGreaterThan(0);
|
||
|
expect(delay).toBeGreaterThan(0);
|
||
|
}
|
||
|
}
|
||
|
);
|
||
|
|
||
|
expect(result).toEqual('success');
|
||
|
expect(attempts).toEqual(3);
|
||
|
|
||
|
// Test executeWithRetry that fails after max attempts
|
||
|
attempts = 0;
|
||
|
try {
|
||
|
await ErrorHandler.executeWithRetry(
|
||
|
async () => {
|
||
|
attempts++;
|
||
|
throw new Error('Persistent failure');
|
||
|
},
|
||
|
'TEST_RETRY_ERROR',
|
||
|
{
|
||
|
maxAttempts: 3,
|
||
|
baseDelay: 10
|
||
|
}
|
||
|
);
|
||
|
} catch (error) {
|
||
|
expect(error).toBeInstanceOf(PlatformError);
|
||
|
expect(attempts).toEqual(3);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
// Test retry utilities
|
||
|
tap.test('Error retry utilities should work correctly', async () => {
|
||
|
let attempts = 0;
|
||
|
const start = Date.now();
|
||
|
|
||
|
try {
|
||
|
await errors.retry(
|
||
|
async () => {
|
||
|
attempts++;
|
||
|
if (attempts < 3) {
|
||
|
throw new Error('Temporary error');
|
||
|
}
|
||
|
return 'success';
|
||
|
},
|
||
|
{
|
||
|
maxRetries: 5,
|
||
|
initialDelay: 20,
|
||
|
backoffFactor: 1.5,
|
||
|
retryableErrors: [/Temporary/]
|
||
|
}
|
||
|
);
|
||
|
} catch (e) {
|
||
|
// Should not reach here
|
||
|
expect(false).toEqual(true);
|
||
|
}
|
||
|
|
||
|
expect(attempts).toEqual(3);
|
||
|
|
||
|
// Test retry with non-retryable error
|
||
|
attempts = 0;
|
||
|
try {
|
||
|
await errors.retry(
|
||
|
async () => {
|
||
|
attempts++;
|
||
|
throw new Error('Critical error');
|
||
|
},
|
||
|
{
|
||
|
maxRetries: 3,
|
||
|
initialDelay: 10,
|
||
|
retryableErrors: [/Temporary/] // Won't match "Critical"
|
||
|
}
|
||
|
);
|
||
|
} catch (error) {
|
||
|
expect(error.message).toEqual('Critical error');
|
||
|
expect(attempts).toEqual(1); // Should only attempt once
|
||
|
}
|
||
|
});
|
||
|
|
||
|
// Helper function that will reject first n times, then resolve
|
||
|
async function flaky(failTimes: number, result: any = 'success'): Promise<any> {
|
||
|
if (flaky.counter < failTimes) {
|
||
|
flaky.counter++;
|
||
|
throw new Error(`Flaky failure ${flaky.counter}`);
|
||
|
}
|
||
|
return result;
|
||
|
}
|
||
|
flaky.counter = 0;
|
||
|
flaky.reset = () => { flaky.counter = 0; };
|
||
|
|
||
|
// Test error wrapping and retry combination
|
||
|
tap.test('Error handling can be combined with retry for robust operations', async () => {
|
||
|
// Reset counter for the test
|
||
|
flaky.reset();
|
||
|
|
||
|
// Create a wrapped version of the flaky function
|
||
|
const wrapped = errors.withErrorHandling(
|
||
|
() => flaky(2, 'wrapped success'),
|
||
|
'TEST_WRAPPED_ERROR',
|
||
|
{ component: 'TestComponent' }
|
||
|
);
|
||
|
|
||
|
// Execute with retry
|
||
|
try {
|
||
|
const result = await errors.retry(
|
||
|
wrapped,
|
||
|
{
|
||
|
maxRetries: 3,
|
||
|
initialDelay: 10,
|
||
|
}
|
||
|
);
|
||
|
expect(result).toEqual('wrapped success');
|
||
|
expect(flaky.counter).toEqual(2);
|
||
|
} catch (error) {
|
||
|
// Should not reach here
|
||
|
expect(false).toEqual(true);
|
||
|
}
|
||
|
|
||
|
// Reset and test failure case
|
||
|
flaky.reset();
|
||
|
|
||
|
try {
|
||
|
await errors.retry(
|
||
|
() => flaky(5, 'never reached'),
|
||
|
{
|
||
|
maxRetries: 2, // Only retry twice, but we need 5 attempts to succeed
|
||
|
initialDelay: 10,
|
||
|
}
|
||
|
);
|
||
|
// Should not reach here
|
||
|
expect(false).toEqual(true);
|
||
|
} catch (error) {
|
||
|
expect(error.message).toContain('Flaky failure');
|
||
|
expect(flaky.counter).toEqual(3); // Initial + 2 retries = 3 attempts
|
||
|
}
|
||
|
});
|
||
|
|
||
|
tap.test('stop', async () => {
|
||
|
// This is a placeholder test to ensure we call tap.stopForcefully()
|
||
|
});
|
||
|
|
||
|
export default tap.stopForcefully();
|