Files
gridpilot.gg/apps/website/tests/services/health/HealthRouteService.test.ts
Marc Mintel fb1221701d
Some checks failed
Contract Testing / contract-tests (push) Failing after 6m7s
Contract Testing / contract-snapshot (push) Failing after 4m46s
add tests
2026-01-22 11:52:42 +01:00

705 lines
22 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { HealthRouteService } from '@/lib/services/health/HealthRouteService';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { isProductionEnvironment } from '@/lib/config/env';
// Mock the dependencies
vi.mock('@/lib/config/apiBaseUrl', () => ({
getWebsiteApiBaseUrl: () => 'https://api.example.com',
}));
vi.mock('@/lib/config/env', () => ({
isProductionEnvironment: () => false,
}));
describe('HealthRouteService', () => {
let service: HealthRouteService;
let originalFetch: typeof global.fetch;
let mockFetch: any;
beforeEach(() => {
vi.clearAllMocks();
service = new HealthRouteService();
originalFetch = global.fetch;
mockFetch = vi.fn();
global.fetch = mockFetch as any;
});
afterEach(() => {
global.fetch = originalFetch;
});
describe('happy paths', () => {
it('should return ok status with timestamp when all dependencies are healthy', async () => {
// Mock successful responses for all dependencies
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
// Mock database and external service to be healthy
vi.spyOn(service as any, 'checkDatabaseHealth').mockResolvedValue({
status: 'healthy',
latency: 50,
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'healthy',
latency: 100,
});
const result = await service.getHealth();
expect(result.isOk()).toBe(true);
const health = result.unwrap();
expect(health.status).toBe('healthy');
expect(health.timestamp).toBeDefined();
expect(health.dependencies.api.status).toBe('healthy');
expect(health.dependencies.database.status).toBe('healthy');
expect(health.dependencies.externalService.status).toBe('healthy');
});
it('should return degraded status when external service is slow', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
vi.spyOn(service as any, 'checkDatabaseHealth').mockResolvedValue({
status: 'healthy',
latency: 50,
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'degraded',
latency: 1500,
error: 'High latency',
});
const result = await service.getHealth();
expect(result.isOk()).toBe(true);
const health = result.unwrap();
expect(health.status).toBe('degraded');
expect(health.dependencies.externalService.status).toBe('degraded');
});
});
describe('failure modes', () => {
it('should handle API server errors gracefully', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
});
vi.spyOn(service as any, 'checkDatabaseHealth').mockResolvedValue({
status: 'healthy',
latency: 50,
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'healthy',
latency: 100,
});
const result = await service.getHealth();
expect(result.isOk()).toBe(true);
const health = result.unwrap();
expect(health.status).toBe('unhealthy');
expect(health.dependencies.api.status).toBe('unhealthy');
expect(health.dependencies.api.error).toContain('500');
});
it('should handle network errors gracefully', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network connection failed'));
vi.spyOn(service as any, 'checkDatabaseHealth').mockResolvedValue({
status: 'healthy',
latency: 50,
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'healthy',
latency: 100,
});
const result = await service.getHealth();
expect(result.isOk()).toBe(true);
const health = result.unwrap();
expect(health.status).toBe('unhealthy');
expect(health.dependencies.api.status).toBe('unhealthy');
expect(health.dependencies.api.error).toContain('Network connection failed');
});
it('should handle database connection failures', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
vi.spyOn(service as any, 'checkDatabaseHealth').mockResolvedValue({
status: 'unhealthy',
latency: 100,
error: 'Connection timeout',
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'healthy',
latency: 100,
});
const result = await service.getHealth();
expect(result.isOk()).toBe(true);
const health = result.unwrap();
expect(health.status).toBe('unhealthy');
expect(health.dependencies.database.status).toBe('unhealthy');
});
it('should handle external service failures gracefully', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
vi.spyOn(service as any, 'checkDatabaseHealth').mockResolvedValue({
status: 'healthy',
latency: 50,
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'degraded',
latency: 200,
error: 'Service unavailable',
});
const result = await service.getHealth();
expect(result.isOk()).toBe(true);
const health = result.unwrap();
expect(health.status).toBe('degraded');
expect(health.dependencies.externalService.status).toBe('degraded');
});
it('should handle all dependencies failing', async () => {
mockFetch.mockRejectedValueOnce(new Error('API unavailable'));
vi.spyOn(service as any, 'checkDatabaseHealth').mockResolvedValue({
status: 'unhealthy',
latency: 100,
error: 'DB connection failed',
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'degraded',
latency: 150,
error: 'External service timeout',
});
const result = await service.getHealth();
expect(result.isOk()).toBe(true);
const health = result.unwrap();
expect(health.status).toBe('unhealthy');
expect(health.dependencies.api.status).toBe('unhealthy');
expect(health.dependencies.database.status).toBe('unhealthy');
expect(health.dependencies.externalService.status).toBe('degraded');
});
});
describe('retries', () => {
it('should retry on transient API failures', async () => {
// First call fails, second succeeds
mockFetch
.mockRejectedValueOnce(new Error('Network timeout'))
.mockResolvedValueOnce({
ok: true,
status: 200,
});
vi.spyOn(service as any, 'checkDatabaseHealth').mockResolvedValue({
status: 'healthy',
latency: 50,
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'healthy',
latency: 100,
});
const result = await service.getHealth();
expect(result.isOk()).toBe(true);
const health = result.unwrap();
expect(health.status).toBe('healthy');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('should retry database health check on transient failures', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
// Mock database to fail first, then succeed
const checkDatabaseHealthSpy = vi.spyOn(service as any, 'checkDatabaseHealth');
checkDatabaseHealthSpy
.mockRejectedValueOnce(new Error('Connection timeout'))
.mockResolvedValueOnce({
status: 'healthy',
latency: 50,
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'healthy',
latency: 100,
});
const result = await service.getHealth();
expect(result.isOk()).toBe(true);
const health = result.unwrap();
expect(health.status).toBe('healthy');
expect(health.dependencies.database.status).toBe('healthy');
});
it('should exhaust retries and return unhealthy after max attempts', async () => {
// Mock all retries to fail
mockFetch.mockRejectedValue(new Error('Persistent network error'));
vi.spyOn(service as any, 'checkDatabaseHealth').mockResolvedValue({
status: 'healthy',
latency: 50,
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'healthy',
latency: 100,
});
const result = await service.getHealth();
expect(result.isOk()).toBe(true);
const health = result.unwrap();
expect(health.status).toBe('unhealthy');
expect(mockFetch).toHaveBeenCalledTimes(3); // Max retries
});
it('should handle mixed retry scenarios', async () => {
// API succeeds on second attempt
mockFetch
.mockRejectedValueOnce(new Error('Timeout'))
.mockResolvedValueOnce({
ok: true,
status: 200,
});
// Database fails all attempts
const checkDatabaseHealthSpy = vi.spyOn(service as any, 'checkDatabaseHealth');
checkDatabaseHealthSpy.mockRejectedValue(new Error('DB connection failed'));
// External service succeeds
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'healthy',
latency: 100,
});
const result = await service.getHealth();
expect(result.isOk()).toBe(true);
const health = result.unwrap();
expect(health.status).toBe('unhealthy'); // Database failure makes overall unhealthy
expect(mockFetch).toHaveBeenCalledTimes(2); // API retried once
expect(checkDatabaseHealthSpy).toHaveBeenCalledTimes(3); // Database retried max times
});
});
describe('fallback logic', () => {
it('should continue with degraded status when external service fails', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
vi.spyOn(service as any, 'checkDatabaseHealth').mockResolvedValue({
status: 'healthy',
latency: 50,
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'degraded',
latency: 2000,
error: 'External service timeout',
});
const result = await service.getHealth();
expect(result.isOk()).toBe(true);
const health = result.unwrap();
expect(health.status).toBe('degraded');
expect(health.dependencies.externalService.status).toBe('degraded');
expect(health.dependencies.api.status).toBe('healthy');
expect(health.dependencies.database.status).toBe('healthy');
});
it('should handle partial failures without complete system failure', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
vi.spyOn(service as any, 'checkDatabaseHealth').mockResolvedValue({
status: 'healthy',
latency: 50,
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'degraded',
latency: 1500,
error: 'High latency',
});
const result = await service.getHealth();
expect(result.isOk()).toBe(true);
const health = result.unwrap();
// System should be degraded but not completely down
expect(health.status).toBe('degraded');
expect(health.dependencies.api.status).toBe('healthy');
expect(health.dependencies.database.status).toBe('healthy');
});
it('should provide fallback information in details', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
vi.spyOn(service as any, 'checkDatabaseHealth').mockResolvedValue({
status: 'healthy',
latency: 50,
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'degraded',
latency: 1200,
error: 'External service degraded',
});
const result = await service.getHealth();
expect(result.isOk()).toBe(true);
const health = result.unwrap();
expect(health.dependencies.externalService.error).toBe('External service degraded');
});
});
describe('aggregation logic', () => {
it('should aggregate health status from multiple dependencies correctly', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
vi.spyOn(service as any, 'checkDatabaseHealth').mockResolvedValue({
status: 'healthy',
latency: 45,
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'healthy',
latency: 95,
});
const result = await service.getHealth();
expect(result.isOk()).toBe(true);
const health = result.unwrap();
// Verify all dependencies are checked
expect(health.dependencies.api.status).toBe('healthy');
expect(health.dependencies.database.status).toBe('healthy');
expect(health.dependencies.externalService.status).toBe('healthy');
// Verify latency aggregation (max of all latencies)
expect(health.dependencies.api.latency).toBeGreaterThan(0);
expect(health.dependencies.database.latency).toBeGreaterThan(0);
expect(health.dependencies.externalService.latency).toBeGreaterThan(0);
});
it('should correctly aggregate when one dependency is degraded', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
vi.spyOn(service as any, 'checkDatabaseHealth').mockResolvedValue({
status: 'healthy',
latency: 50,
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'degraded',
latency: 1500,
error: 'Slow response',
});
const result = await service.getHealth();
expect(result.isOk()).toBe(true);
const health = result.unwrap();
// Aggregation should result in degraded status
expect(health.status).toBe('degraded');
expect(health.dependencies.api.status).toBe('healthy');
expect(health.dependencies.database.status).toBe('healthy');
expect(health.dependencies.externalService.status).toBe('degraded');
});
it('should handle critical dependency failures in aggregation', async () => {
mockFetch.mockRejectedValueOnce(new Error('API down'));
vi.spyOn(service as any, 'checkDatabaseHealth').mockResolvedValue({
status: 'healthy',
latency: 50,
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'healthy',
latency: 100,
});
const result = await service.getHealth();
expect(result.isOk()).toBe(true);
const health = result.unwrap();
// API failure should make overall status unhealthy
expect(health.status).toBe('unhealthy');
expect(health.dependencies.api.status).toBe('unhealthy');
});
it('should aggregate latency values correctly', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
vi.spyOn(service as any, 'checkDatabaseHealth').mockResolvedValue({
status: 'healthy',
latency: 150,
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'healthy',
latency: 200,
});
const result = await service.getHealth();
expect(result.isOk()).toBe(true);
const health = result.unwrap();
// Should take the maximum latency
expect(health.dependencies.api.latency).toBeGreaterThan(0);
expect(health.dependencies.database.latency).toBe(150);
expect(health.dependencies.externalService.latency).toBe(200);
});
});
describe('decision branches', () => {
it('should return healthy when all dependencies are healthy and fast', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
vi.spyOn(service as any, 'checkDatabaseHealth').mockResolvedValue({
status: 'healthy',
latency: 50,
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'healthy',
latency: 100,
});
const result = await service.getHealth();
expect(result.isOk()).toBe(true);
expect(result.unwrap().status).toBe('healthy');
});
it('should return degraded when dependencies are healthy but slow', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
vi.spyOn(service as any, 'checkDatabaseHealth').mockResolvedValue({
status: 'healthy',
latency: 50,
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'healthy',
latency: 1200, // Exceeds threshold
});
const result = await service.getHealth();
expect(result.isOk()).toBe(true);
expect(result.unwrap().status).toBe('degraded');
});
it('should return unhealthy when critical dependencies fail', async () => {
mockFetch.mockRejectedValueOnce(new Error('API unavailable'));
vi.spyOn(service as any, 'checkDatabaseHealth').mockResolvedValue({
status: 'healthy',
latency: 50,
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'healthy',
latency: 100,
});
const result = await service.getHealth();
expect(result.isOk()).toBe(true);
expect(result.unwrap().status).toBe('unhealthy');
});
it('should handle different error types based on retryability', async () => {
// Test retryable error (timeout)
mockFetch.mockRejectedValueOnce(new Error('Connection timeout'));
vi.spyOn(service as any, 'checkDatabaseHealth').mockResolvedValue({
status: 'healthy',
latency: 50,
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'healthy',
latency: 100,
});
const result1 = await service.getHealth();
expect(result1.isOk()).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2); // Should retry
// Reset mocks
mockFetch.mockClear();
vi.clearAllMocks();
// Test non-retryable error (400)
mockFetch.mockResolvedValueOnce({
ok: false,
status: 400,
});
vi.spyOn(service as any, 'checkDatabaseHealth').mockResolvedValue({
status: 'healthy',
latency: 50,
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'healthy',
latency: 100,
});
const result2 = await service.getHealth();
expect(result2.isOk()).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(1); // Should not retry
});
it('should handle mixed dependency states correctly', async () => {
// API: healthy, Database: unhealthy, External: degraded
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
vi.spyOn(service as any, 'checkDatabaseHealth').mockResolvedValue({
status: 'unhealthy',
latency: 100,
error: 'DB connection failed',
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'degraded',
latency: 1500,
error: 'Slow response',
});
const result = await service.getHealth();
expect(result.isOk()).toBe(true);
const health = result.unwrap();
// Database failure should make overall unhealthy
expect(health.status).toBe('unhealthy');
expect(health.dependencies.api.status).toBe('healthy');
expect(health.dependencies.database.status).toBe('unhealthy');
expect(health.dependencies.externalService.status).toBe('degraded');
});
it('should handle edge case where all dependencies are degraded', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
});
vi.spyOn(service as any, 'checkDatabaseHealth').mockResolvedValue({
status: 'degraded',
latency: 800,
error: 'Slow query',
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'degraded',
latency: 1200,
error: 'External timeout',
});
const result = await service.getHealth();
expect(result.isOk()).toBe(true);
const health = result.unwrap();
// All degraded should result in degraded overall
expect(health.status).toBe('degraded');
expect(health.dependencies.api.status).toBe('healthy');
expect(health.dependencies.database.status).toBe('degraded');
expect(health.dependencies.externalService.status).toBe('degraded');
});
it('should handle timeout aborts correctly', async () => {
// Mock fetch to simulate timeout
const abortError = new Error('The operation was aborted.');
abortError.name = 'AbortError';
mockFetch.mockRejectedValueOnce(abortError);
vi.spyOn(service as any, 'checkDatabaseHealth').mockResolvedValue({
status: 'healthy',
latency: 50,
});
vi.spyOn(service as any, 'checkExternalServiceHealth').mockResolvedValue({
status: 'healthy',
latency: 100,
});
const result = await service.getHealth();
expect(result.isOk()).toBe(true);
const health = result.unwrap();
expect(health.status).toBe('unhealthy');
expect(health.dependencies.api.status).toBe('unhealthy');
});
});
});