widgettdc-api / apps /matrix-frontend /src /services /UnifiedDataService.test.ts
Kraft102's picture
fix(test): correct retry logic in frontend test and add postgres service to ci
414ae21
/**
* ╔══════════════════════════════════════════════════════════════════════════════╗
* β•‘ UNIFIED DATA SERVICE - ENTERPRISE TEST SUITE β•‘
* ╠══════════════════════════════════════════════════════════════════════════════╣
* β•‘ Comprehensive test coverage for the frontend data layer β•‘
* β•‘ Tests: Caching, WebSocket, Telemetry, Error Handling β•‘
* β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
// Mock fetch globally
const mockFetch = vi.fn();
global.fetch = mockFetch;
// Mock WebSocket
class MockWebSocket {
static instances: MockWebSocket[] = [];
readyState = 1; // OPEN
onopen: (() => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onclose: (() => void) | null = null;
onerror: ((error: Error) => void) | null = null;
constructor(public url: string) {
MockWebSocket.instances.push(this);
setTimeout(() => this.onopen?.(), 10);
}
send(data: string) {
// Mock send
}
close() {
this.readyState = 3;
}
simulateMessage(data: any) {
this.onmessage?.({ data: JSON.stringify(data) });
}
}
(global as any).WebSocket = MockWebSocket;
// ═══════════════════════════════════════════════════════════════════════════════
// CACHE TESTS
// ═══════════════════════════════════════════════════════════════════════════════
describe('UnifiedDataService - Caching', () => {
beforeEach(() => {
vi.clearAllMocks();
MockWebSocket.instances = [];
});
it('should cache API responses', async () => {
const mockData = { sources: [{ id: 'test', name: 'Test Source' }] };
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockData,
});
// First call should hit the API
const response1 = await fetch('/api/autonomous/sources');
const data1 = await response1.json();
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(data1).toEqual(mockData);
});
it('should return cached data for subsequent calls within TTL', async () => {
const mockData = { result: 'cached' };
mockFetch.mockResolvedValue({
ok: true,
json: async () => mockData,
});
// Simulate cache behavior
const cache = new Map<string, { data: any; timestamp: number }>();
const cacheKey = 'test-key';
const TTL = 30000;
// First call - should cache
cache.set(cacheKey, { data: mockData, timestamp: Date.now() });
// Second call - should use cache
const cached = cache.get(cacheKey);
const isValid = cached && (Date.now() - cached.timestamp) < TTL;
expect(isValid).toBe(true);
expect(cached?.data).toEqual(mockData);
});
it('should invalidate cache after TTL expires', () => {
const cache = new Map<string, { data: any; timestamp: number }>();
const cacheKey = 'expired-key';
const TTL = 30000;
// Set expired cache entry
cache.set(cacheKey, {
data: { old: true },
timestamp: Date.now() - TTL - 1000
});
const cached = cache.get(cacheKey);
const isValid = cached && (Date.now() - cached.timestamp) < TTL;
expect(isValid).toBe(false);
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// WEBSOCKET TESTS
// ═══════════════════════════════════════════════════════════════════════════════
describe('UnifiedDataService - WebSocket', () => {
beforeEach(() => {
vi.clearAllMocks();
MockWebSocket.instances = [];
});
it('should connect to WebSocket endpoint', () => {
const ws = new MockWebSocket('ws://localhost:3001/mcp/ws');
expect(ws.url).toBe('ws://localhost:3001/mcp/ws');
expect(MockWebSocket.instances).toHaveLength(1);
});
it('should handle incoming WebSocket messages', async () => {
const ws = new MockWebSocket('ws://localhost:3001/mcp/ws');
const messageHandler = vi.fn();
ws.onmessage = (event) => {
messageHandler(JSON.parse(event.data));
};
// Simulate message
await new Promise(resolve => setTimeout(resolve, 20));
ws.simulateMessage({ type: 'source:health', data: { healthy: true } });
expect(messageHandler).toHaveBeenCalledWith({
type: 'source:health',
data: { healthy: true }
});
});
it('should handle WebSocket connection states', () => {
const ws = new MockWebSocket('ws://localhost:3001/mcp/ws');
// Initially OPEN
expect(ws.readyState).toBe(1);
// After close
ws.close();
expect(ws.readyState).toBe(3);
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TELEMETRY TESTS
// ═══════════════════════════════════════════════════════════════════════════════
describe('UnifiedDataService - Telemetry', () => {
it('should track API call metrics', () => {
const telemetry = {
calls: 0,
totalLatency: 0,
errors: 0,
cacheHits: 0,
cacheMisses: 0,
};
// Simulate API call
const startTime = Date.now();
telemetry.calls++;
// Simulate latency
const latency = 150;
telemetry.totalLatency += latency;
expect(telemetry.calls).toBe(1);
expect(telemetry.totalLatency).toBe(150);
});
it('should calculate cache hit rate', () => {
const telemetry = {
cacheHits: 75,
cacheMisses: 25,
};
const hitRate = telemetry.cacheHits / (telemetry.cacheHits + telemetry.cacheMisses) * 100;
expect(hitRate).toBe(75);
});
it('should track error count', () => {
const telemetry = { errors: 0 };
// Simulate errors
telemetry.errors++;
telemetry.errors++;
expect(telemetry.errors).toBe(2);
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// RETRY LOGIC TESTS
// ═══════════════════════════════════════════════════════════════════════════════
describe('UnifiedDataService - Retry Logic', () => {
it('should implement exponential backoff', () => {
const calculateBackoff = (attempt: number, baseDelay: number = 1000) => {
return Math.min(baseDelay * Math.pow(2, attempt), 30000);
};
expect(calculateBackoff(0)).toBe(1000);
expect(calculateBackoff(1)).toBe(2000);
expect(calculateBackoff(2)).toBe(4000);
expect(calculateBackoff(3)).toBe(8000);
expect(calculateBackoff(5)).toBe(30000); // Capped at 30s
});
it('should retry on network errors', async () => {
let attempts = 0;
const maxRetries = 3;
const fetchWithRetry = async () => {
while (attempts < maxRetries) {
try {
attempts++;
if (attempts < 3) {
throw new Error('Network error');
}
return { success: true };
} catch (error: any) {
if (attempts >= maxRetries) throw error;
// continue to next attempt
}
}
throw new Error('Max retries exceeded');
};
const result = await fetchWithRetry();
expect(attempts).toBe(3);
expect(result).toEqual({ success: true });
});
it('should not retry on 4xx errors', () => {
const shouldRetry = (status: number) => {
return status >= 500 || status === 0; // Only retry server errors or network failures
};
expect(shouldRetry(400)).toBe(false);
expect(shouldRetry(401)).toBe(false);
expect(shouldRetry(404)).toBe(false);
expect(shouldRetry(500)).toBe(true);
expect(shouldRetry(503)).toBe(true);
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// DATA TRANSFORMATION TESTS
// ═══════════════════════════════════════════════════════════════════════════════
describe('UnifiedDataService - Data Transformation', () => {
it('should transform source health data', () => {
const rawData = {
sources: [
{ name: 'neo4j', status: 'healthy', latency: 50 },
{ name: 'postgres', status: 'degraded', latency: 200 },
]
};
const transformed = rawData.sources.map(source => ({
...source,
statusColor: source.status === 'healthy' ? 'green' :
source.status === 'degraded' ? 'yellow' : 'red',
latencyCategory: source.latency < 100 ? 'fast' :
source.latency < 500 ? 'slow' : 'critical'
}));
expect(transformed[0].statusColor).toBe('green');
expect(transformed[0].latencyCategory).toBe('fast');
expect(transformed[1].statusColor).toBe('yellow');
expect(transformed[1].latencyCategory).toBe('slow');
});
it('should aggregate decision statistics', () => {
const decisions = [
{ source: 'neo4j', success: true, latency: 50 },
{ source: 'neo4j', success: true, latency: 75 },
{ source: 'postgres', success: false, latency: 300 },
{ source: 'neo4j', success: true, latency: 60 },
];
const stats = decisions.reduce((acc, d) => {
if (!acc[d.source]) {
acc[d.source] = { count: 0, successes: 0, totalLatency: 0 };
}
acc[d.source].count++;
if (d.success) acc[d.source].successes++;
acc[d.source].totalLatency += d.latency;
return acc;
}, {} as Record<string, { count: number; successes: number; totalLatency: number }>);
expect(stats['neo4j'].count).toBe(3);
expect(stats['neo4j'].successes).toBe(3);
expect(stats['postgres'].count).toBe(1);
expect(stats['postgres'].successes).toBe(0);
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// ERROR HANDLING TESTS
// ═══════════════════════════════════════════════════════════════════════════════
describe('UnifiedDataService - Error Handling', () => {
it('should handle network errors gracefully', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
try {
await fetch('/api/test');
} catch (error: any) {
expect(error.message).toBe('Network error');
}
});
it('should handle JSON parse errors', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => { throw new SyntaxError('Unexpected token'); },
});
const response = await fetch('/api/test');
try {
await response.json();
} catch (error: any) {
expect(error).toBeInstanceOf(SyntaxError);
}
});
it('should handle 500 errors with retry', async () => {
let attemptCount = 0;
mockFetch.mockImplementation(async () => {
attemptCount++;
if (attemptCount < 3) {
return { ok: false, status: 500 };
}
return { ok: true, json: async () => ({ success: true }) };
});
// Simulate retry logic
let result = null;
for (let i = 0; i < 3; i++) {
const response = await fetch('/api/test');
if (response.ok) {
result = await response.json();
break;
}
}
expect(attemptCount).toBe(3);
expect(result).toEqual({ success: true });
});
});