/** * Integration Test: API Connection Monitor Health Checks * * Tests the orchestration logic of API connection health monitoring: * - ApiConnectionMonitor: Tracks connection status, performs health checks, records metrics * - Validates that health monitoring correctly interacts with its Ports (API endpoints, event emitters) * - Uses In-Memory adapters for fast, deterministic testing * * Focus: Business logic orchestration, NOT UI rendering */ import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; import { InMemoryHealthCheckAdapter } from '../../../adapters/health/persistence/inmemory/InMemoryHealthCheckAdapter'; import { InMemoryHealthEventPublisher } from '../../../adapters/events/InMemoryHealthEventPublisher'; import { ApiConnectionMonitor } from '../../../apps/website/lib/api/base/ApiConnectionMonitor'; // Mock fetch to use our in-memory adapter const mockFetch = vi.fn(); global.fetch = mockFetch as any; describe('API Connection Monitor Health Orchestration', () => { let healthCheckAdapter: InMemoryHealthCheckAdapter; let eventPublisher: InMemoryHealthEventPublisher; let apiConnectionMonitor: ApiConnectionMonitor; beforeAll(() => { // Initialize In-Memory health check adapter and event publisher healthCheckAdapter = new InMemoryHealthCheckAdapter(); eventPublisher = new InMemoryHealthEventPublisher(); }); beforeEach(() => { // Reset the singleton instance (ApiConnectionMonitor as any).instance = undefined; // Create a new instance for each test apiConnectionMonitor = ApiConnectionMonitor.getInstance('/health'); // Clear all In-Memory repositories before each test healthCheckAdapter.clear(); eventPublisher.clear(); // Reset mock fetch mockFetch.mockReset(); // Mock fetch to use our in-memory adapter mockFetch.mockImplementation(async (url: string) => { // Simulate network delay await new Promise(resolve => setTimeout(resolve, 50)); // Check if we should fail if (healthCheckAdapter.shouldFail) { throw new Error(healthCheckAdapter.failError); } // Return successful response return { ok: true, status: 200, }; }); }); afterEach(() => { // Stop any ongoing monitoring apiConnectionMonitor.stopMonitoring(); }); describe('PerformHealthCheck - Success Path', () => { it('should perform successful health check and record metrics', async () => { // Scenario: API is healthy and responsive // Given: HealthCheckAdapter returns successful response // And: Response time is 50ms healthCheckAdapter.setResponseTime(50); // Mock fetch to return successful response mockFetch.mockResolvedValue({ ok: true, status: 200, }); // When: performHealthCheck() is called const result = await apiConnectionMonitor.performHealthCheck(); // Then: Health check result should show healthy=true expect(result.healthy).toBe(true); // And: Response time should be recorded expect(result.responseTime).toBeGreaterThanOrEqual(50); expect(result.timestamp).toBeInstanceOf(Date); // And: Connection status should be 'connected' expect(apiConnectionMonitor.getStatus()).toBe('connected'); // And: Metrics should be recorded const health = apiConnectionMonitor.getHealth(); expect(health.totalRequests).toBe(1); expect(health.successfulRequests).toBe(1); expect(health.failedRequests).toBe(0); expect(health.consecutiveFailures).toBe(0); }); it('should perform health check with slow response time', async () => { // Scenario: API is healthy but slow // Given: HealthCheckAdapter returns successful response // And: Response time is 500ms healthCheckAdapter.setResponseTime(500); // Mock fetch to return successful response mockFetch.mockResolvedValue({ ok: true, status: 200, }); // When: performHealthCheck() is called const result = await apiConnectionMonitor.performHealthCheck(); // Then: Health check result should show healthy=true expect(result.healthy).toBe(true); // And: Response time should be recorded as 500ms expect(result.responseTime).toBeGreaterThanOrEqual(500); expect(result.timestamp).toBeInstanceOf(Date); // And: Connection status should be 'connected' expect(apiConnectionMonitor.getStatus()).toBe('connected'); }); it('should handle multiple successful health checks', async () => { // Scenario: Multiple consecutive successful health checks // Given: HealthCheckAdapter returns successful responses healthCheckAdapter.setResponseTime(50); // Mock fetch to return successful responses mockFetch.mockResolvedValue({ ok: true, status: 200, }); // When: performHealthCheck() is called 3 times await apiConnectionMonitor.performHealthCheck(); await apiConnectionMonitor.performHealthCheck(); await apiConnectionMonitor.performHealthCheck(); // Then: All health checks should show healthy=true const health = apiConnectionMonitor.getHealth(); expect(health.totalRequests).toBe(3); expect(health.successfulRequests).toBe(3); expect(health.failedRequests).toBe(0); expect(health.consecutiveFailures).toBe(0); // And: Average response time should be calculated expect(health.averageResponseTime).toBeGreaterThanOrEqual(50); }); }); describe('PerformHealthCheck - Failure Path', () => { it('should handle failed health check and record failure', async () => { // Scenario: API is unreachable // Given: HealthCheckAdapter throws network error mockFetch.mockRejectedValue(new Error('ECONNREFUSED')); // When: performHealthCheck() is called const result = await apiConnectionMonitor.performHealthCheck(); // Then: Health check result should show healthy=false expect(result.healthy).toBe(false); expect(result.error).toBeDefined(); // And: Connection status should be 'disconnected' expect(apiConnectionMonitor.getStatus()).toBe('disconnected'); // And: Consecutive failures should be 1 const health = apiConnectionMonitor.getHealth(); expect(health.consecutiveFailures).toBe(1); expect(health.totalRequests).toBe(1); expect(health.failedRequests).toBe(1); expect(health.successfulRequests).toBe(0); }); it('should handle multiple consecutive failures', async () => { // Scenario: API is down for multiple checks // Given: HealthCheckAdapter throws errors 3 times mockFetch.mockRejectedValue(new Error('ECONNREFUSED')); // When: performHealthCheck() is called 3 times await apiConnectionMonitor.performHealthCheck(); await apiConnectionMonitor.performHealthCheck(); await apiConnectionMonitor.performHealthCheck(); // Then: All health checks should show healthy=false const health = apiConnectionMonitor.getHealth(); expect(health.totalRequests).toBe(3); expect(health.failedRequests).toBe(3); expect(health.successfulRequests).toBe(0); expect(health.consecutiveFailures).toBe(3); // And: Connection status should be 'disconnected' expect(apiConnectionMonitor.getStatus()).toBe('disconnected'); }); it('should handle timeout during health check', async () => { // Scenario: Health check times out // Given: HealthCheckAdapter times out after 30 seconds mockFetch.mockImplementation(() => { return new Promise((_, reject) => { setTimeout(() => reject(new Error('Timeout')), 3000); }); }); // When: performHealthCheck() is called const result = await apiConnectionMonitor.performHealthCheck(); // Then: Health check result should show healthy=false expect(result.healthy).toBe(false); expect(result.error).toContain('Timeout'); // And: Consecutive failures should increment const health = apiConnectionMonitor.getHealth(); expect(health.consecutiveFailures).toBe(1); }); }); describe('Connection Status Management', () => { it('should transition from disconnected to connected after recovery', async () => { // Scenario: API recovers from outage // Given: Initial state is disconnected with 3 consecutive failures mockFetch.mockRejectedValue(new Error('ECONNREFUSED')); // Perform 3 failed checks to get disconnected status await apiConnectionMonitor.performHealthCheck(); await apiConnectionMonitor.performHealthCheck(); await apiConnectionMonitor.performHealthCheck(); expect(apiConnectionMonitor.getStatus()).toBe('disconnected'); // And: HealthCheckAdapter starts returning success mockFetch.mockResolvedValue({ ok: true, status: 200, }); // When: performHealthCheck() is called await apiConnectionMonitor.performHealthCheck(); // Then: Connection status should transition to 'connected' expect(apiConnectionMonitor.getStatus()).toBe('connected'); // And: Consecutive failures should reset to 0 const health = apiConnectionMonitor.getHealth(); expect(health.consecutiveFailures).toBe(0); }); it('should degrade status when reliability drops below threshold', async () => { // Scenario: API has intermittent failures // Given: 5 successful requests followed by 3 failures mockFetch.mockResolvedValue({ ok: true, status: 200, }); // Perform 5 successful checks for (let i = 0; i < 5; i++) { await apiConnectionMonitor.performHealthCheck(); } // Now start failing mockFetch.mockRejectedValue(new Error('ECONNREFUSED')); // Perform 3 failed checks for (let i = 0; i < 3; i++) { await apiConnectionMonitor.performHealthCheck(); } // Then: Connection status should be 'degraded' expect(apiConnectionMonitor.getStatus()).toBe('degraded'); // And: Reliability should be calculated correctly (5/8 = 62.5%) const health = apiConnectionMonitor.getHealth(); expect(health.totalRequests).toBe(8); expect(health.successfulRequests).toBe(5); expect(health.failedRequests).toBe(3); expect(apiConnectionMonitor.getReliability()).toBeCloseTo(62.5, 1); }); it('should handle checking status when no requests yet', async () => { // Scenario: Monitor just started // Given: No health checks performed yet // When: getStatus() is called const status = apiConnectionMonitor.getStatus(); // Then: Status should be 'checking' expect(status).toBe('checking'); // And: isAvailable() should return false expect(apiConnectionMonitor.isAvailable()).toBe(false); }); }); describe('Health Metrics Calculation', () => { it('should correctly calculate reliability percentage', async () => { // Scenario: Calculate reliability from mixed results // Given: 7 successful requests and 3 failed requests mockFetch.mockResolvedValue({ ok: true, status: 200, }); // Perform 7 successful checks for (let i = 0; i < 7; i++) { await apiConnectionMonitor.performHealthCheck(); } // Now start failing mockFetch.mockRejectedValue(new Error('ECONNREFUSED')); // Perform 3 failed checks for (let i = 0; i < 3; i++) { await apiConnectionMonitor.performHealthCheck(); } // When: getReliability() is called const reliability = apiConnectionMonitor.getReliability(); // Then: Reliability should be 70% expect(reliability).toBeCloseTo(70, 1); }); it('should correctly calculate average response time', async () => { // Scenario: Calculate average from varying response times // Given: Response times of 50ms, 100ms, 150ms const responseTimes = [50, 100, 150]; // Mock fetch with different response times mockFetch.mockImplementation(() => { const time = responseTimes.shift() || 50; return new Promise(resolve => { setTimeout(() => { resolve({ ok: true, status: 200, }); }, time); }); }); // Perform 3 health checks await apiConnectionMonitor.performHealthCheck(); await apiConnectionMonitor.performHealthCheck(); await apiConnectionMonitor.performHealthCheck(); // When: getHealth() is called const health = apiConnectionMonitor.getHealth(); // Then: Average response time should be 100ms expect(health.averageResponseTime).toBeCloseTo(100, 1); }); it('should handle zero requests for reliability calculation', async () => { // Scenario: No requests made yet // Given: No health checks performed // When: getReliability() is called const reliability = apiConnectionMonitor.getReliability(); // Then: Reliability should be 0 expect(reliability).toBe(0); }); }); describe('Health Check Endpoint Selection', () => { it('should try multiple endpoints when primary fails', async () => { // Scenario: Primary endpoint fails, fallback succeeds // Given: /health endpoint fails // And: /api/health endpoint succeeds let callCount = 0; mockFetch.mockImplementation(() => { callCount++; if (callCount === 1) { // First call to /health fails return Promise.reject(new Error('ECONNREFUSED')); } else { // Second call to /api/health succeeds return Promise.resolve({ ok: true, status: 200, }); } }); // When: performHealthCheck() is called const result = await apiConnectionMonitor.performHealthCheck(); // Then: Health check should be successful expect(result.healthy).toBe(true); // And: Connection status should be 'connected' expect(apiConnectionMonitor.getStatus()).toBe('connected'); }); it('should handle all endpoints being unavailable', async () => { // Scenario: All health endpoints are down // Given: /health, /api/health, and /status all fail mockFetch.mockRejectedValue(new Error('ECONNREFUSED')); // When: performHealthCheck() is called const result = await apiConnectionMonitor.performHealthCheck(); // Then: Health check should show healthy=false expect(result.healthy).toBe(false); // And: Connection status should be 'disconnected' expect(apiConnectionMonitor.getStatus()).toBe('disconnected'); }); }); describe('Event Emission Patterns', () => { it('should emit connected event when transitioning to connected', async () => { // Scenario: Successful health check after disconnection // Given: Current status is disconnected mockFetch.mockRejectedValue(new Error('ECONNREFUSED')); // Perform 3 failed checks to get disconnected status await apiConnectionMonitor.performHealthCheck(); await apiConnectionMonitor.performHealthCheck(); await apiConnectionMonitor.performHealthCheck(); expect(apiConnectionMonitor.getStatus()).toBe('disconnected'); // And: HealthCheckAdapter returns success mockFetch.mockResolvedValue({ ok: true, status: 200, }); // When: performHealthCheck() is called await apiConnectionMonitor.performHealthCheck(); // Then: EventPublisher should emit ConnectedEvent // Note: ApiConnectionMonitor emits events directly, not through InMemoryHealthEventPublisher // We can verify by checking the status transition expect(apiConnectionMonitor.getStatus()).toBe('connected'); }); it('should emit disconnected event when threshold exceeded', async () => { // Scenario: Consecutive failures reach threshold // Given: 2 consecutive failures mockFetch.mockRejectedValue(new Error('ECONNREFUSED')); await apiConnectionMonitor.performHealthCheck(); await apiConnectionMonitor.performHealthCheck(); // And: Third failure occurs // When: performHealthCheck() is called await apiConnectionMonitor.performHealthCheck(); // Then: Connection status should be 'disconnected' expect(apiConnectionMonitor.getStatus()).toBe('disconnected'); // And: Consecutive failures should be 3 const health = apiConnectionMonitor.getHealth(); expect(health.consecutiveFailures).toBe(3); }); it('should emit degraded event when reliability drops', async () => { // Scenario: Reliability drops below threshold // Given: 5 successful, 3 failed requests (62.5% reliability) mockFetch.mockResolvedValue({ ok: true, status: 200, }); // Perform 5 successful checks for (let i = 0; i < 5; i++) { await apiConnectionMonitor.performHealthCheck(); } // Now start failing mockFetch.mockRejectedValue(new Error('ECONNREFUSED')); // Perform 3 failed checks for (let i = 0; i < 3; i++) { await apiConnectionMonitor.performHealthCheck(); } // When: performHealthCheck() is called // Then: Connection status should be 'degraded' expect(apiConnectionMonitor.getStatus()).toBe('degraded'); // And: Reliability should be 62.5% expect(apiConnectionMonitor.getReliability()).toBeCloseTo(62.5, 1); }); }); describe('Error Handling', () => { it('should handle network errors gracefully', async () => { // Scenario: Network error during health check // Given: HealthCheckAdapter throws ECONNREFUSED mockFetch.mockRejectedValue(new Error('ECONNREFUSED')); // When: performHealthCheck() is called const result = await apiConnectionMonitor.performHealthCheck(); // Then: Should not throw unhandled error expect(result).toBeDefined(); // And: Should record failure expect(result.healthy).toBe(false); expect(result.error).toBeDefined(); // And: Should maintain connection status expect(apiConnectionMonitor.getStatus()).toBe('disconnected'); }); it('should handle malformed response from health endpoint', async () => { // Scenario: Health endpoint returns invalid JSON // Given: HealthCheckAdapter returns malformed response mockFetch.mockResolvedValue({ ok: true, status: 200, }); // When: performHealthCheck() is called const result = await apiConnectionMonitor.performHealthCheck(); // Then: Should handle parsing error // Note: ApiConnectionMonitor doesn't parse JSON, it just checks response.ok // So this should succeed expect(result.healthy).toBe(true); // And: Should record as successful check const health = apiConnectionMonitor.getHealth(); expect(health.successfulRequests).toBe(1); }); it('should handle concurrent health check calls', async () => { // Scenario: Multiple simultaneous health checks // Given: performHealthCheck() is already running let resolveFirst: (value: Response) => void; const firstPromise = new Promise((resolve) => { resolveFirst = resolve; }); mockFetch.mockImplementation(() => firstPromise); // Start first health check const firstCheck = apiConnectionMonitor.performHealthCheck(); // When: performHealthCheck() is called again const secondCheck = apiConnectionMonitor.performHealthCheck(); // Resolve the first check resolveFirst!({ ok: true, status: 200, } as Response); // Wait for both checks to complete const [result1, result2] = await Promise.all([firstCheck, secondCheck]); // Then: Should return existing check result // Note: The second check should return immediately with an error // because isChecking is true expect(result2.healthy).toBe(false); expect(result2.error).toContain('Check already in progress'); }); }); });