qapinterface
Version:
Comprehensive API utilities for Node.js applications including authentication, security, request processing, and response handling with zero external dependencies
562 lines (455 loc) • 18.1 kB
JavaScript
/**
* Comprehensive unit tests for services modules
* Tests service health checks, retry logic, and error classification with mocked dependencies
*/
const { expect } = require('chai');
const sinon = require('sinon');
// Import modules to test
const { checkServiceHealth } = require('../services/health-check');
const { validateServiceConnection } = require('../services/retry-handler');
const { validateServiceAsync } = require('../services/async-validator');
const { validateMultipleServices } = require('../services/multi-validator');
const { validateServiceWithErrorClassification } = require('../services/error-classifier');
const { connectWithRetry } = require('../service');
describe('Services Module Tests', () => {
describe('Service Health Check', () => {
it('should check healthy service', async () => {
const mockHealthyService = {
url: 'https://api.example.com/health',
timeout: 5000,
expectedStatus: 200
};
// Mock successful health check
const originalFetch = global.fetch;
global.fetch = sinon.stub().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ status: 'healthy' })
});
const result = await checkServiceHealth(mockHealthyService);
expect(result.healthy).to.equal(true);
expect(typeof result.responseTime).to.equal('number');
expect(result.responseTime >= 0).to.equal(true);
global.fetch = originalFetch;
});
it('should detect unhealthy service', async () => {
const mockUnhealthyService = {
url: 'https://api.example.com/health',
timeout: 5000,
expectedStatus: 200
};
// Mock failed health check
const originalFetch = global.fetch;
global.fetch = sinon.stub().mockResolvedValue({
ok: false,
status: 503,
statusText: 'Service Unavailable'
});
const result = await checkServiceHealth(mockUnhealthyService);
expect(result.healthy).to.equal(false);
expect(typeof result.error).to.equal('string');
expect(result.error.includes('503')).to.equal(true);
global.fetch = originalFetch;
});
it('should handle network errors', async () => {
const mockService = {
url: 'https://nonexistent.example.com/health',
timeout: 1000
};
// Mock network error
const originalFetch = global.fetch;
global.fetch = sinon.stub().mockRejectedValue(new Error('ECONNREFUSED'));
const result = await checkServiceHealth(mockService);
expect(result.healthy).to.equal(false);
expect(typeof result.error).to.equal('string');
expect(result.error.includes('ECONNREFUSED')).to.equal(true);
global.fetch = originalFetch;
});
it('should handle timeout errors', async () => {
const mockService = {
url: 'https://slow.example.com/health',
timeout: 100 // Very short timeout
};
// Mock slow response
const originalFetch = global.fetch;
global.fetch = sinon.stub().mockImplementation(() =>
new Promise(resolve => setTimeout(() => resolve({ ok: true }), 200))
);
const result = await checkServiceHealth(mockService);
expect(result.healthy).to.equal(false);
expect(typeof result.error).to.equal('string');
global.fetch = originalFetch;
});
it('should validate service configuration', async () => {
try {
await checkServiceHealth(null);
expect(false).to.equal(true); // Should not reach here
} catch (error) {
expect(error instanceof Error).to.equal(true);
expect(error.message.includes('Service configuration')).to.equal(true);
}
try {
await checkServiceHealth({ timeout: 5000 }); // Missing URL
expect(false).to.equal(true); // Should not reach here
} catch (error) {
expect(error instanceof Error).to.equal(true);
}
});
});
describe('Service Retry Handler', () => {
it('should retry on transient failures', async () => {
let attemptCount = 0;
const mockServiceCall = sinon.stub().mockImplementation(() => {
attemptCount++;
if (attemptCount < 3) {
throw new Error('Service temporarily unavailable');
}
return Promise.resolve({ success: true, data: 'Service restored' });
});
const result = await validateServiceConnection(mockServiceCall, {
maxRetries: 3,
baseDelay: 100
});
expect(result.success).to.equal(true);
expect(result.data).to.equal('Service restored');
expect(mockServiceCall.callCount).to.equal(3);
});
it('should fail after max retries', async () => {
const mockServiceCall = sinon.stub().mockRejectedValue(new Error('Persistent failure'));
try {
await validateServiceConnection(mockServiceCall, {
maxRetries: 2,
baseDelay: 50
});
expect(false).to.equal(true); // Should not reach here
} catch (error) {
expect(error.message).to.equal('Persistent failure');
expect(mockServiceCall.callCount).to.equal(2);
}
});
it('should apply exponential backoff', async () => {
const delays = [];
const originalSetTimeout = global.setTimeout;
global.setTimeout = jest.fn((callback, delay) => {
delays.push(delay);
callback();
});
const mockServiceCall = sinon.stub().mockRejectedValue(new Error('Always fails'));
try {
await validateServiceConnection(mockServiceCall, {
maxRetries: 3,
baseDelay: 100
});
} catch (error) {
// Expected to fail
}
global.setTimeout = originalSetTimeout;
expect(delays.length).to.equal(2); // 3 attempts = 2 delays
expect(delays[0]).to.equal(100); // First delay
expect(delays[1]).to.equal(200); // Second delay (exponential)
});
it('should not retry on non-retryable errors', async () => {
const mockServiceCall = sinon.stub().mockRejectedValue(new Error('Authentication failed'));
// Mock error classifier to mark as non-retryable
const originalRequire = require;
require = jest.fn((path) => {
if (path.includes('error-classifier')) {
return {
classifyError: () => ({ retryable: false, category: 'auth' })
};
}
return originalRequire(path);
});
try {
await validateServiceConnection(mockServiceCall, { maxRetries: 3 });
expect(false).to.equal(true); // Should not reach here
} catch (error) {
expect(mockServiceCall.callCount).to.equal(1); // No retries
}
require = originalRequire;
});
});
describe('Async Service Validator', () => {
it('should validate service asynchronously', async () => {
const mockService = {
name: 'test-service',
healthCheck: () => Promise.resolve({ healthy: true })
};
const result = await validateServiceAsync(mockService);
expect(result.valid).to.equal(true);
expect(result.serviceName).to.equal('test-service');
expect(typeof result.validatedAt).to.equal('string');
});
it('should handle async validation failures', async () => {
const mockService = {
name: 'failing-service',
healthCheck: () => Promise.reject(new Error('Service down'))
};
const result = await validateServiceAsync(mockService);
expect(result.valid).to.equal(false);
expect(result.serviceName).to.equal('failing-service');
expect(typeof result.error).to.equal('string');
});
it('should apply timeout to async validation', async () => {
const mockService = {
name: 'slow-service',
healthCheck: () => new Promise(resolve => setTimeout(resolve, 2000))
};
const result = await validateServiceAsync(mockService, { timeout: 500 });
expect(result.valid).to.equal(false);
expect(result.error.includes('timeout')).to.equal(true);
});
it('should validate multiple services concurrently', async () => {
const mockServices = [
{
name: 'service-1',
healthCheck: () => Promise.resolve({ healthy: true })
},
{
name: 'service-2',
healthCheck: () => Promise.resolve({ healthy: true })
},
{
name: 'service-3',
healthCheck: () => Promise.reject(new Error('Service 3 down'))
}
];
const results = await Promise.all(
mockServices.map(service => validateServiceAsync(service))
);
expect(results.length).to.equal(3);
expect(results[0].valid).to.equal(true);
expect(results[1].valid).to.equal(true);
expect(results[2].valid).to.equal(false);
});
});
describe('Multi-Service Validator', () => {
it('should validate multiple services', async () => {
const mockServices = [
{
name: 'database',
url: 'https://db.example.com/health'
},
{
name: 'cache',
url: 'https://cache.example.com/health'
}
];
// Mock successful health checks
const originalFetch = global.fetch;
global.fetch = sinon.stub().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ status: 'healthy' })
});
const result = await validateMultipleServices(mockServices);
expect(result.overallHealth).to.equal('healthy');
expect(Object.keys(result.services).length).to.equal(2);
expect(result.services.database.healthy).to.equal(true);
expect(result.services.cache.healthy).to.equal(true);
expect(result.unhealthyCount).to.equal(0);
global.fetch = originalFetch;
});
it('should detect degraded system health', async () => {
const mockServices = [
{
name: 'database',
url: 'https://db.example.com/health'
},
{
name: 'cache',
url: 'https://cache.example.com/health'
}
];
// Mock mixed health responses
const originalFetch = global.fetch;
global.fetch = sinon.stub()
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ status: 'healthy' })
})
.mockResolvedValueOnce({
ok: false,
status: 503,
statusText: 'Service Unavailable'
});
const result = await validateMultipleServices(mockServices);
expect(result.overallHealth).to.equal('degraded');
expect(result.services.database.healthy).to.equal(true);
expect(result.services.cache.healthy).to.equal(false);
expect(result.unhealthyCount).to.equal(1);
global.fetch = originalFetch;
});
it('should detect unhealthy system', async () => {
const mockServices = [
{
name: 'database',
url: 'https://db.example.com/health'
},
{
name: 'cache',
url: 'https://cache.example.com/health'
}
];
// Mock all services down
const originalFetch = global.fetch;
global.fetch = sinon.stub().mockRejectedValue(new Error('Connection refused'));
const result = await validateMultipleServices(mockServices);
expect(result.overallHealth).to.equal('unhealthy');
expect(result.services.database.healthy).to.equal(false);
expect(result.services.cache.healthy).to.equal(false);
expect(result.unhealthyCount).to.equal(2);
global.fetch = originalFetch;
});
it('should handle empty service list', async () => {
const result = await validateMultipleServices([]);
expect(result.overallHealth).to.equal('healthy');
expect(Object.keys(result.services).length).to.equal(0);
expect(result.unhealthyCount).to.equal(0);
});
});
describe('Error Classification Service', () => {
it('should classify network errors as retryable', () => {
const networkError = new Error('ECONNREFUSED');
networkError.code = 'ECONNREFUSED';
const result = validateServiceWithErrorClassification(networkError);
expect(result.retryable).to.equal(true);
expect(result.category).to.equal('network');
expect(typeof result.recommendedDelay).to.equal('number');
});
it('should classify server errors as retryable', () => {
const serverError = new Error('Internal Server Error');
serverError.status = 500;
const result = validateServiceWithErrorClassification(serverError);
expect(result.retryable).to.equal(true);
expect(result.category).to.equal('server');
});
it('should classify client errors as non-retryable', () => {
const clientError = new Error('Bad Request');
clientError.status = 400;
const result = validateServiceWithErrorClassification(clientError);
expect(result.retryable).to.equal(false);
expect(result.category).to.equal('client');
});
it('should classify authentication errors as non-retryable', () => {
const authError = new Error('Unauthorized');
authError.status = 401;
const result = validateServiceWithErrorClassification(authError);
expect(result.retryable).to.equal(false);
expect(result.category).to.equal('auth');
});
it('should handle rate limiting with longer delay', () => {
const rateLimitError = new Error('Too Many Requests');
rateLimitError.status = 429;
const result = validateServiceWithErrorClassification(rateLimitError);
expect(result.retryable).to.equal(true);
expect(result.category).to.equal('rate_limit');
expect(result.recommendedDelay > 5000).to.equal(true); // Longer delay for rate limiting
});
it('should handle unknown errors', () => {
const unknownError = new Error('Unknown error');
const result = validateServiceWithErrorClassification(unknownError);
expect(result.category).to.equal('unknown');
expect(typeof result.retryable).to.equal('boolean');
});
});
describe('Service Connection with Retry', () => {
it('should connect successfully on first attempt', async () => {
const mockConnectionFn = sinon.stub().mockResolvedValue({
connected: true,
data: 'Connection established'
});
const result = await connectWithRetry(mockConnectionFn);
expect(result.connected).to.equal(true);
expect(result.data).to.equal('Connection established');
expect(mockConnectionFn.callCount).to.equal(1);
});
it('should retry with exponential backoff', async () => {
let attemptCount = 0;
const mockConnectionFn = sinon.stub().mockImplementation(() => {
attemptCount++;
if (attemptCount < 3) {
throw new Error('Connection failed');
}
return Promise.resolve({ connected: true });
});
const result = await connectWithRetry(mockConnectionFn, { retries: 3 });
expect(result.connected).to.equal(true);
expect(mockConnectionFn.callCount).to.equal(3);
});
it('should fail after exhausting retries', async () => {
const mockConnectionFn = sinon.stub().mockRejectedValue(new Error('Persistent connection failure'));
try {
await connectWithRetry(mockConnectionFn, { retries: 2 });
expect(false).to.equal(true); // Should not reach here
} catch (error) {
expect(error.message).to.equal('Persistent connection failure');
expect(mockConnectionFn.callCount).to.equal(2);
}
});
it('should use custom retry options', async () => {
const delays = [];
const originalSetTimeout = global.setTimeout;
global.setTimeout = jest.fn((callback, delay) => {
delays.push(delay);
callback();
});
const mockConnectionFn = sinon.stub().mockRejectedValue(new Error('Custom retry test'));
try {
await connectWithRetry(mockConnectionFn, { retries: 2 });
} catch (error) {
// Expected to fail
}
global.setTimeout = originalSetTimeout;
expect(delays.length).to.equal(1); // 2 attempts = 1 delay
expect(delays[0]).to.equal(1000); // First delay is 1 second
});
});
describe('Service Integration Tests', () => {
it('should integrate health check with retry logic', async () => {
let healthCheckCount = 0;
const mockService = {
name: 'integration-test-service',
healthCheck: () => {
healthCheckCount++;
if (healthCheckCount < 2) {
throw new Error('Service starting up');
}
return Promise.resolve({ healthy: true, status: 'operational' });
}
};
const result = await validateServiceConnection(
() => mockService.healthCheck(),
{ maxRetries: 3, baseDelay: 50 }
);
expect(result.healthy).to.equal(true);
expect(result.status).to.equal('operational');
expect(healthCheckCount).to.equal(2);
});
it('should handle service dependency chain', async () => {
const serviceChain = [
{ name: 'database', dependency: null },
{ name: 'cache', dependency: 'database' },
{ name: 'api', dependency: 'cache' }
];
// Mock health checks for dependency chain
const healthChecks = {
database: () => Promise.resolve({ healthy: true }),
cache: () => Promise.resolve({ healthy: true }),
api: () => Promise.resolve({ healthy: true })
};
const results = [];
for (const service of serviceChain) {
const result = await validateServiceAsync({
name: service.name,
healthCheck: healthChecks[service.name]
});
results.push(result);
}
expect(results.every(r => r.valid)).to.equal(true);
expect(results.length).to.equal(3);
});
});
});
module.exports = { runServicesTests: () => expect().to.be.undefined };