chore: import upstream snapshot with attribution
Component Security Validation / Security Audit (push) Has been cancelled
Deploy to Cloudflare Pages / deploy (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:58 +08:00
commit bb5c75ce05
8824 changed files with 1946442 additions and 0 deletions
+319
View File
@@ -0,0 +1,319 @@
/**
* Unit Tests for DataCache
* Tests multi-level caching functionality
*/
const DataCache = require('../../src/analytics/data/DataCache');
const fs = require('fs-extra');
// Mock fs-extra
jest.mock('fs-extra');
describe('DataCache', () => {
let dataCache;
beforeEach(() => {
dataCache = new DataCache();
jest.clearAllMocks();
});
describe('constructor', () => {
it('should initialize with default configuration', () => {
expect(dataCache.caches.fileContent).toBeInstanceOf(Map);
expect(dataCache.caches.parsedData).toBeInstanceOf(Map);
expect(dataCache.caches.computationResults).toBeInstanceOf(Map);
expect(dataCache.metrics.hits).toBe(0);
expect(dataCache.metrics.misses).toBe(0);
});
it('should accept custom options', () => {
const customCache = new DataCache({
fileContentTTL: 10000,
maxFileSize: 2000000
});
expect(customCache.options.fileContentTTL).toBe(10000);
expect(customCache.options.maxFileSize).toBe(2000000);
});
});
describe('getFileContent', () => {
const mockFilePath = '/test/file.jsonl';
const mockContent = '{"test": "data"}';
const mockStats = {
mtime: new Date(Date.now() - 1000),
size: 1024
};
beforeEach(() => {
fs.stat.mockResolvedValue(mockStats);
fs.readFile.mockResolvedValue(mockContent);
});
it('should read file when not cached', async () => {
const content = await dataCache.getFileContent(mockFilePath);
expect(content).toBe(mockContent);
expect(fs.stat).toHaveBeenCalledWith(mockFilePath);
expect(fs.readFile).toHaveBeenCalledWith(mockFilePath, 'utf8');
expect(dataCache.metrics.misses).toBe(1);
expect(dataCache.metrics.hits).toBe(0);
});
it('should return cached content when file unchanged', async () => {
// First call - cache miss
await dataCache.getFileContent(mockFilePath);
// Second call - cache hit
const content = await dataCache.getFileContent(mockFilePath);
expect(content).toBe(mockContent);
expect(fs.readFile).toHaveBeenCalledTimes(1); // Only called once
expect(dataCache.metrics.hits).toBe(1);
expect(dataCache.metrics.misses).toBe(1);
});
it('should re-read file when modified', async () => {
// First call
await dataCache.getFileContent(mockFilePath);
// Simulate file modification
const newerStats = {
mtime: new Date(Date.now() + 1000),
size: 1024
};
fs.stat.mockResolvedValue(newerStats);
fs.readFile.mockResolvedValue('{"updated": "data"}');
// Second call
const content = await dataCache.getFileContent(mockFilePath);
expect(content).toBe('{"updated": "data"}');
expect(fs.readFile).toHaveBeenCalledTimes(2);
expect(dataCache.metrics.misses).toBe(2);
});
it('should handle file read errors', async () => {
fs.stat.mockRejectedValue(new Error('File not found'));
await expect(dataCache.getFileContent(mockFilePath)).rejects.toThrow('File not found');
});
it('should skip large files', async () => {
const largeFileStats = {
mtime: new Date(),
size: 11 * 1024 * 1024 // 11MB - exceeds default 10MB limit
};
fs.stat.mockResolvedValue(largeFileStats);
await expect(dataCache.getFileContent(mockFilePath)).rejects.toThrow('File too large');
});
});
describe('getParsedConversation', () => {
const mockFilePath = '/test/conversation.jsonl';
const mockContent = '{"message": {"role": "user", "content": "hello"}}\n{"message": {"role": "assistant", "content": "hi"}}';
beforeEach(() => {
// Mock getFileContent to return our test data
jest.spyOn(dataCache, 'getFileContent').mockResolvedValue(mockContent);
});
it('should parse conversation content correctly', async () => {
const parsed = await dataCache.getParsedConversation(mockFilePath);
expect(parsed).toHaveLength(2);
expect(parsed[0]).toMatchObject({
role: 'user',
content: 'hello'
});
expect(parsed[1]).toMatchObject({
role: 'assistant',
content: 'hi'
});
});
it('should cache parsed results', async () => {
// First call
await dataCache.getParsedConversation(mockFilePath);
// Second call
await dataCache.getParsedConversation(mockFilePath);
// getFileContent should only be called once due to caching
expect(dataCache.getFileContent).toHaveBeenCalledTimes(1);
});
it('should handle malformed JSON lines', async () => {
const badContent = '{"valid": "json"}\n{invalid json}\n{"another": "valid"}';
dataCache.getFileContent.mockResolvedValue(badContent);
const parsed = await dataCache.getParsedConversation(mockFilePath);
expect(parsed).toHaveLength(2); // Should skip the invalid line
});
it('should handle empty files', async () => {
dataCache.getFileContent.mockResolvedValue('');
const parsed = await dataCache.getParsedConversation(mockFilePath);
expect(parsed).toHaveLength(0);
});
});
describe('getCachedTokenUsage', () => {
const mockFilePath = '/test/file.jsonl';
const mockComputeFunction = jest.fn().mockReturnValue({
total: 1500,
input: 800,
output: 700
});
it('should compute and cache token usage', async () => {
const result = await dataCache.getCachedTokenUsage(mockFilePath, mockComputeFunction);
expect(result).toEqual({
total: 1500,
input: 800,
output: 700
});
expect(mockComputeFunction).toHaveBeenCalledTimes(1);
});
it('should return cached result on subsequent calls', async () => {
// First call
await dataCache.getCachedTokenUsage(mockFilePath, mockComputeFunction);
// Second call
const result = await dataCache.getCachedTokenUsage(mockFilePath, mockComputeFunction);
expect(result).toEqual({
total: 1500,
input: 800,
output: 700
});
expect(mockComputeFunction).toHaveBeenCalledTimes(1); // Only called once
});
it('should handle computation errors', async () => {
const errorFunction = jest.fn().mockImplementation(() => {
throw new Error('Computation failed');
});
await expect(dataCache.getCachedTokenUsage(mockFilePath, errorFunction)).rejects.toThrow('Computation failed');
});
});
describe('invalidateFile', () => {
const mockFilePath = '/test/file.jsonl';
it('should remove all cache entries for a file', async () => {
// Populate caches
dataCache.caches.fileContent.set(mockFilePath, { content: 'test', timestamp: Date.now() });
dataCache.caches.parsedData.set(mockFilePath, { data: 'test', timestamp: Date.now() });
dataCache.caches.computationResults.set(`tokens_${mockFilePath}`, { result: 'test', timestamp: Date.now() });
dataCache.invalidateFile(mockFilePath);
expect(dataCache.caches.fileContent.has(mockFilePath)).toBe(false);
expect(dataCache.caches.parsedData.has(mockFilePath)).toBe(false);
expect(dataCache.caches.computationResults.has(`tokens_${mockFilePath}`)).toBe(false);
});
});
describe('clearExpired', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should remove expired cache entries', () => {
const now = Date.now();
jest.setSystemTime(now);
// Add expired entry
dataCache.caches.fileContent.set('/expired/file', {
content: 'old',
timestamp: now - 100000 // Older than TTL
});
// Add fresh entry
dataCache.caches.fileContent.set('/fresh/file', {
content: 'new',
timestamp: now - 1000 // Within TTL
});
// Fast forward time
jest.setSystemTime(now + 50000);
dataCache.clearExpired();
expect(dataCache.caches.fileContent.has('/expired/file')).toBe(false);
expect(dataCache.caches.fileContent.has('/fresh/file')).toBe(true);
});
});
describe('getStats', () => {
it('should return cache statistics', async () => {
// Add some test data
await dataCache.getFileContent('/test/file1.jsonl');
await dataCache.getFileContent('/test/file2.jsonl');
const stats = dataCache.getStats();
expect(stats).toMatchObject({
fileContent: expect.objectContaining({
size: expect.any(Number),
hitRate: expect.any(Number)
}),
parsedData: expect.objectContaining({
size: expect.any(Number)
}),
computationResults: expect.objectContaining({
size: expect.any(Number)
}),
metrics: expect.objectContaining({
hits: expect.any(Number),
misses: expect.any(Number),
hitRate: expect.any(Number)
})
});
});
it('should calculate hit rate correctly', async () => {
// Generate some hits and misses
await dataCache.getFileContent('/test/file.jsonl'); // miss
await dataCache.getFileContent('/test/file.jsonl'); // hit
await dataCache.getFileContent('/test/file.jsonl'); // hit
const stats = dataCache.getStats();
expect(stats.metrics.hits).toBe(2);
expect(stats.metrics.misses).toBe(1);
expect(stats.metrics.hitRate).toBeCloseTo(66.67, 1); // 2/3 * 100
});
});
describe('cleanup', () => {
it('should clear all caches and reset metrics', () => {
// Populate caches
dataCache.caches.fileContent.set('test', { data: 'test' });
dataCache.caches.parsedData.set('test', { data: 'test' });
dataCache.caches.computationResults.set('test', { data: 'test' });
dataCache.metrics.hits = 10;
dataCache.metrics.misses = 5;
dataCache.cleanup();
expect(dataCache.caches.fileContent.size).toBe(0);
expect(dataCache.caches.parsedData.size).toBe(0);
expect(dataCache.caches.computationResults.size).toBe(0);
expect(dataCache.metrics.hits).toBe(0);
expect(dataCache.metrics.misses).toBe(0);
});
});
});
+388
View File
@@ -0,0 +1,388 @@
/**
* Unit Tests for DataService
* Tests API communication and caching functionality
*/
// Load the DataService class
const fs = require('fs');
const path = require('path');
// Load DataService from the actual file
const DataServicePath = path.join(__dirname, '../../src/analytics-web/services/DataService.js');
const DataServiceCode = fs.readFileSync(DataServicePath, 'utf8');
// Create a module-like environment
const moduleExports = {};
const module = { exports: moduleExports };
// Execute the DataService code in our test environment
eval(DataServiceCode);
const DataService = moduleExports.DataService || global.DataService;
describe('DataService', () => {
let dataService;
let mockWebSocketService;
let mockFetch;
beforeEach(() => {
// Mock WebSocket service
mockWebSocketService = {
on: jest.fn(),
subscribe: jest.fn().mockResolvedValue(true),
isConnected: true,
requestRefresh: jest.fn().mockResolvedValue(true),
getStatus: jest.fn().mockReturnValue({ isConnected: true })
};
// Mock fetch
mockFetch = jest.fn();
global.fetch = mockFetch;
dataService = new DataService(mockWebSocketService);
});
afterEach(() => {
jest.clearAllMocks();
delete global.fetch;
});
describe('constructor', () => {
it('should initialize with default values', () => {
const service = new DataService();
expect(service.cache).toBeInstanceOf(Map);
expect(service.eventListeners).toBeInstanceOf(Set);
expect(service.baseURL).toBe('');
expect(service.realTimeEnabled).toBe(false);
});
it('should setup WebSocket integration when provided', () => {
expect(mockWebSocketService.on).toHaveBeenCalledWith('data_refresh', expect.any(Function));
expect(mockWebSocketService.on).toHaveBeenCalledWith('conversation_state_change', expect.any(Function));
expect(mockWebSocketService.on).toHaveBeenCalledWith('connected', expect.any(Function));
expect(mockWebSocketService.on).toHaveBeenCalledWith('disconnected', expect.any(Function));
});
});
describe('cachedFetch', () => {
const mockEndpoint = '/api/test';
const mockResponse = { data: 'test' };
beforeEach(() => {
mockFetch.mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue(mockResponse)
});
});
it('should fetch data and cache it', async () => {
const result = await dataService.cachedFetch(mockEndpoint);
expect(result).toEqual(mockResponse);
expect(mockFetch).toHaveBeenCalledWith(mockEndpoint, expect.objectContaining({
method: 'GET',
headers: { 'Content-Type': 'application/json' }
}));
expect(dataService.cache.has(`${mockEndpoint}_${JSON.stringify({})}`)).toBe(true);
});
it('should return cached data when available and fresh', async () => {
// First call - cache miss
await dataService.cachedFetch(mockEndpoint);
// Second call - cache hit
const result = await dataService.cachedFetch(mockEndpoint);
expect(result).toEqual(mockResponse);
expect(mockFetch).toHaveBeenCalledTimes(1); // Only called once
});
it('should refetch when cache is expired', async () => {
// First call
await dataService.cachedFetch(mockEndpoint, { cacheDuration: 100 });
// Wait for cache to expire
await testUtils.waitFor(150);
// Second call - should refetch
await dataService.cachedFetch(mockEndpoint, { cacheDuration: 100 });
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('should handle fetch errors gracefully', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));
await expect(dataService.cachedFetch(mockEndpoint)).rejects.toThrow('Network error');
});
it('should return stale cache on fetch error', async () => {
// First successful call
await dataService.cachedFetch(mockEndpoint);
// Simulate network error on second call
mockFetch.mockRejectedValue(new Error('Network error'));
const result = await dataService.cachedFetch(mockEndpoint);
expect(result).toEqual(mockResponse); // Should return cached data
});
it('should handle HTTP errors', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 404
});
await expect(dataService.cachedFetch(mockEndpoint)).rejects.toThrow('HTTP error! status: 404');
});
});
describe('API methods', () => {
beforeEach(() => {
jest.spyOn(dataService, 'cachedFetch').mockResolvedValue({ data: 'mock' });
});
it('should call correct endpoint for getConversations', async () => {
await dataService.getConversations();
expect(dataService.cachedFetch).toHaveBeenCalledWith('/api/data');
});
it('should call correct endpoint for getConversationStates', async () => {
await dataService.getConversationStates();
expect(dataService.cachedFetch).toHaveBeenCalledWith('/api/conversation-state',
expect.objectContaining({ cacheDuration: expect.any(Number) })
);
});
it('should adjust cache duration based on real-time status', async () => {
// With real-time enabled
dataService.realTimeEnabled = true;
await dataService.getConversationStates();
expect(dataService.cachedFetch).toHaveBeenCalledWith('/api/conversation-state',
expect.objectContaining({ cacheDuration: 30000 })
);
// With real-time disabled
dataService.realTimeEnabled = false;
await dataService.getConversationStates();
expect(dataService.cachedFetch).toHaveBeenCalledWith('/api/conversation-state',
expect.objectContaining({ cacheDuration: 5000 })
);
});
it('should call correct endpoints for other methods', async () => {
await dataService.getChartData();
expect(dataService.cachedFetch).toHaveBeenCalledWith('/api/charts');
await dataService.getSessionData();
expect(dataService.cachedFetch).toHaveBeenCalledWith('/api/session/data');
await dataService.getProjectStats();
expect(dataService.cachedFetch).toHaveBeenCalledWith('/api/session/projects');
await dataService.getSystemHealth();
expect(dataService.cachedFetch).toHaveBeenCalledWith('/api/system/health');
});
});
describe('WebSocket integration', () => {
it('should enable real-time when WebSocket connects', () => {
expect(dataService.realTimeEnabled).toBe(false);
// Simulate WebSocket connection
const connectedHandler = mockWebSocketService.on.mock.calls
.find(call => call[0] === 'connected')[1];
connectedHandler();
expect(dataService.realTimeEnabled).toBe(true);
expect(mockWebSocketService.subscribe).toHaveBeenCalledWith('data_updates');
expect(mockWebSocketService.subscribe).toHaveBeenCalledWith('conversation_updates');
expect(mockWebSocketService.subscribe).toHaveBeenCalledWith('system_updates');
});
it('should disable real-time when WebSocket disconnects', () => {
dataService.realTimeEnabled = true;
jest.spyOn(dataService, 'startFallbackPolling');
// Simulate WebSocket disconnection
const disconnectedHandler = mockWebSocketService.on.mock.calls
.find(call => call[0] === 'disconnected')[1];
disconnectedHandler();
expect(dataService.realTimeEnabled).toBe(false);
expect(dataService.startFallbackPolling).toHaveBeenCalled();
});
it('should handle real-time data refresh', () => {
jest.spyOn(dataService, 'clearCacheEntry');
jest.spyOn(dataService, 'notifyListeners');
const testData = { conversations: [], summary: {} };
// Simulate data refresh event
const dataRefreshHandler = mockWebSocketService.on.mock.calls
.find(call => call[0] === 'data_refresh')[1];
dataRefreshHandler(testData);
expect(dataService.clearCacheEntry).toHaveBeenCalledWith('/api/data');
expect(dataService.clearCacheEntry).toHaveBeenCalledWith('/api/conversation-state');
expect(dataService.notifyListeners).toHaveBeenCalledWith('data_refresh', testData);
});
it('should handle real-time state changes', () => {
jest.spyOn(dataService, 'clearCacheEntry');
jest.spyOn(dataService, 'notifyListeners');
const stateData = { conversationId: 'conv_123', newState: 'active' };
// Simulate state change event
const stateChangeHandler = mockWebSocketService.on.mock.calls
.find(call => call[0] === 'conversation_state_change')[1];
stateChangeHandler(stateData);
expect(dataService.clearCacheEntry).toHaveBeenCalledWith('/api/conversation-state');
expect(dataService.notifyListeners).toHaveBeenCalledWith('conversation_state_change', stateData);
});
});
describe('requestRefresh', () => {
it('should use WebSocket refresh when available', async () => {
dataService.realTimeEnabled = true;
const result = await dataService.requestRefresh();
expect(mockWebSocketService.requestRefresh).toHaveBeenCalled();
expect(result).toBe(true);
});
it('should fallback to cache clearing when WebSocket unavailable', async () => {
dataService.realTimeEnabled = false;
jest.spyOn(dataService, 'clearCache');
const result = await dataService.requestRefresh();
expect(dataService.clearCache).toHaveBeenCalled();
expect(result).toBe(false);
});
it('should handle WebSocket request errors', async () => {
dataService.realTimeEnabled = true;
mockWebSocketService.requestRefresh.mockRejectedValue(new Error('WebSocket error'));
jest.spyOn(dataService, 'clearCache');
const result = await dataService.requestRefresh();
expect(dataService.clearCache).toHaveBeenCalled();
expect(result).toBe(false);
});
});
describe('event listeners', () => {
it('should add and notify event listeners', () => {
const mockCallback = jest.fn();
dataService.addEventListener(mockCallback);
expect(dataService.eventListeners.has(mockCallback)).toBe(true);
dataService.notifyListeners('test_event', 'test_data');
expect(mockCallback).toHaveBeenCalledWith('test_event', 'test_data');
});
it('should remove event listeners', () => {
const mockCallback = jest.fn();
dataService.addEventListener(mockCallback);
dataService.removeEventListener(mockCallback);
expect(dataService.eventListeners.has(mockCallback)).toBe(false);
});
it('should handle listener errors gracefully', () => {
const errorCallback = jest.fn().mockImplementation(() => {
throw new Error('Listener error');
});
dataService.addEventListener(errorCallback);
expect(() => {
dataService.notifyListeners('test_event', 'test_data');
}).not.toThrow();
});
});
describe('cache management', () => {
it('should clear entire cache', () => {
dataService.cache.set('test_key', { data: 'test' });
dataService.clearCache();
expect(dataService.cache.size).toBe(0);
});
it('should clear specific cache entries', () => {
dataService.cache.set('/api/data_{}', { data: 'test1' });
dataService.cache.set('/api/other_{}', { data: 'test2' });
dataService.clearCacheEntry('/api/data');
expect(dataService.cache.has('/api/data_{}')).toBe(false);
expect(dataService.cache.has('/api/other_{}')).toBe(true);
});
});
describe('getCacheStats', () => {
it('should return cache statistics', () => {
dataService.cache.set('test1', { data: 'test' });
dataService.cache.set('test2', { data: 'test' });
const stats = dataService.getCacheStats();
expect(stats).toMatchObject({
size: 2,
keys: expect.arrayContaining(['test1', 'test2']),
listeners: 0,
realTimeEnabled: expect.any(Boolean),
webSocketConnected: true
});
});
});
describe('startPeriodicRefresh', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should skip polling when real-time is enabled', () => {
dataService.realTimeEnabled = true;
dataService.startPeriodicRefresh();
expect(dataService.refreshInterval).toBeUndefined();
});
it('should start polling when real-time is disabled', () => {
dataService.realTimeEnabled = false;
jest.spyOn(dataService, 'getConversations').mockResolvedValue({});
jest.spyOn(dataService, 'getConversationStates').mockResolvedValue({});
dataService.startPeriodicRefresh(1000);
expect(dataService.refreshInterval).toBeDefined();
// Fast forward time
jest.advanceTimersByTime(1000);
expect(dataService.getConversations).toHaveBeenCalled();
expect(dataService.getConversationStates).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,393 @@
/**
* Unit Tests for PerformanceMonitor
* Tests performance monitoring and metrics collection
*/
const PerformanceMonitor = require('../../src/analytics/utils/PerformanceMonitor');
describe('PerformanceMonitor', () => {
let performanceMonitor;
beforeEach(() => {
performanceMonitor = new PerformanceMonitor({
enabled: true,
logInterval: 1000,
memoryThreshold: 50 * 1024 * 1024 // 50MB for testing
});
});
afterEach(() => {
if (performanceMonitor) {
performanceMonitor.stopMonitoring();
}
});
describe('constructor', () => {
it('should initialize with default options', () => {
const monitor = new PerformanceMonitor();
expect(monitor.options.enabled).toBe(true);
expect(monitor.options.logInterval).toBe(60000);
expect(monitor.options.memoryThreshold).toBe(100 * 1024 * 1024);
expect(monitor.metrics).toBeDefined();
expect(monitor.timers).toEqual({});
expect(monitor.counters).toEqual({});
});
it('should merge custom options', () => {
const customOptions = {
enabled: false,
logInterval: 5000,
memoryThreshold: 200 * 1024 * 1024
};
const monitor = new PerformanceMonitor(customOptions);
expect(monitor.options.enabled).toBe(false);
expect(monitor.options.logInterval).toBe(5000);
expect(monitor.options.memoryThreshold).toBe(200 * 1024 * 1024);
});
});
describe('timer operations', () => {
it('should start and end timers correctly', () => {
const timerName = 'test_operation';
performanceMonitor.startTimer(timerName);
expect(performanceMonitor.timers[timerName]).toBeDefined();
expect(performanceMonitor.timers[timerName].start).toBeDefined();
// Wait a small amount of time
setTimeout(() => {
const duration = performanceMonitor.endTimer(timerName);
expect(duration).toBeGreaterThan(0);
expect(performanceMonitor.timers[timerName]).toBeUndefined();
expect(performanceMonitor.metrics.performance).toBeDefined();
}, 10);
});
it('should handle ending non-existent timer', () => {
const duration = performanceMonitor.endTimer('non_existent');
expect(duration).toBe(0);
});
it('should record timer metadata', () => {
const timerName = 'test_with_metadata';
const metadata = { userId: '123', action: 'save' };
performanceMonitor.startTimer(timerName);
performanceMonitor.endTimer(timerName, metadata);
const performanceMetrics = performanceMonitor.metrics.performance;
expect(performanceMetrics).toBeDefined();
expect(performanceMetrics.length).toBeGreaterThan(0);
const lastMetric = performanceMetrics[performanceMetrics.length - 1];
expect(lastMetric.operation).toBe(timerName);
expect(lastMetric.userId).toBe('123');
expect(lastMetric.action).toBe('save');
});
});
describe('counter operations', () => {
it('should increment counters', () => {
const counterName = 'test_counter';
performanceMonitor.incrementCounter(counterName);
expect(performanceMonitor.counters[counterName]).toBe(1);
performanceMonitor.incrementCounter(counterName, 5);
expect(performanceMonitor.counters[counterName]).toBe(6);
});
it('should handle initial counter value', () => {
const counterName = 'new_counter';
performanceMonitor.incrementCounter(counterName, 10);
expect(performanceMonitor.counters[counterName]).toBe(10);
});
});
describe('metric recording', () => {
it('should record metrics in categories', () => {
const category = 'test_category';
const data = { value: 42, timestamp: Date.now() };
performanceMonitor.recordMetric(category, data);
expect(performanceMonitor.metrics[category]).toBeDefined();
expect(performanceMonitor.metrics[category]).toHaveLength(1);
expect(performanceMonitor.metrics[category][0]).toMatchObject(data);
});
it('should add timestamp if not provided', () => {
const category = 'test_category';
const data = { value: 42 };
performanceMonitor.recordMetric(category, data);
const metric = performanceMonitor.metrics[category][0];
expect(metric.timestamp).toBeDefined();
expect(typeof metric.timestamp).toBe('number');
});
it('should limit metric array size', () => {
const category = 'test_category';
// Add more than 1000 metrics
for (let i = 0; i < 1005; i++) {
performanceMonitor.recordMetric(category, { value: i });
}
expect(performanceMonitor.metrics[category]).toHaveLength(1000);
});
it('should skip recording when disabled', () => {
performanceMonitor.options.enabled = false;
performanceMonitor.recordMetric('test', { value: 1 });
expect(performanceMonitor.metrics.test).toBeUndefined();
});
});
describe('error recording', () => {
it('should record errors', () => {
const errorType = 'validation_error';
const errorMessage = 'Invalid input data';
const metadata = { field: 'email' };
performanceMonitor.recordError(errorType, errorMessage, metadata);
expect(performanceMonitor.metrics.errors).toBeDefined();
expect(performanceMonitor.metrics.errors).toHaveLength(1);
const error = performanceMonitor.metrics.errors[0];
expect(error.type).toBe(errorType);
expect(error.message).toBe(errorMessage);
expect(error.field).toBe('email');
});
});
describe('request recording', () => {
it('should record successful requests', () => {
const endpoint = '/api/data';
const duration = 150;
const statusCode = 200;
performanceMonitor.recordRequest(endpoint, duration, statusCode);
expect(performanceMonitor.metrics.requests).toHaveLength(1);
expect(performanceMonitor.counters.total_requests).toBe(1);
expect(performanceMonitor.counters.error_requests).toBeUndefined();
const request = performanceMonitor.metrics.requests[0];
expect(request.endpoint).toBe(endpoint);
expect(request.duration).toBe(duration);
expect(request.statusCode).toBe(statusCode);
expect(request.success).toBe(true);
});
it('should record error requests', () => {
const endpoint = '/api/data';
const duration = 500;
const statusCode = 500;
performanceMonitor.recordRequest(endpoint, duration, statusCode);
expect(performanceMonitor.counters.total_requests).toBe(1);
expect(performanceMonitor.counters.error_requests).toBe(1);
const request = performanceMonitor.metrics.requests[0];
expect(request.success).toBe(false);
});
});
describe('cache recording', () => {
it('should record cache operations', () => {
const operation = 'hit';
const key = 'user:123';
const duration = 5;
performanceMonitor.recordCache(operation, key, duration);
expect(performanceMonitor.metrics.cache).toHaveLength(1);
expect(performanceMonitor.counters[`cache_${operation}`]).toBe(1);
const cacheMetric = performanceMonitor.metrics.cache[0];
expect(cacheMetric.operation).toBe(operation);
expect(cacheMetric.key).toBe(key);
expect(cacheMetric.duration).toBe(duration);
});
});
describe('WebSocket recording', () => {
it('should record WebSocket events', () => {
const event = 'connection';
const data = { clientId: 'client_123' };
performanceMonitor.recordWebSocket(event, data);
expect(performanceMonitor.metrics.websocket).toHaveLength(1);
expect(performanceMonitor.counters[`websocket_${event}`]).toBe(1);
const wsMetric = performanceMonitor.metrics.websocket[0];
expect(wsMetric.event).toBe(event);
expect(wsMetric.clientId).toBe('client_123');
});
});
describe('system metrics collection', () => {
it('should collect memory and CPU metrics', () => {
performanceMonitor.collectSystemMetrics();
expect(performanceMonitor.metrics.memory).toHaveLength(1);
expect(performanceMonitor.metrics.cpu).toHaveLength(1);
const memMetric = performanceMonitor.metrics.memory[0];
expect(memMetric.rss).toBeDefined();
expect(memMetric.heapUsed).toBeDefined();
expect(memMetric.heapTotal).toBeDefined();
const cpuMetric = performanceMonitor.metrics.cpu[0];
expect(cpuMetric.user).toBeDefined();
expect(cpuMetric.system).toBeDefined();
});
});
describe('statistics calculation', () => {
beforeEach(() => {
// Add some test data
performanceMonitor.recordRequest('/api/test1', 100, 200);
performanceMonitor.recordRequest('/api/test2', 200, 404);
performanceMonitor.recordRequest('/api/test1', 150, 200);
performanceMonitor.recordCache('hit', 'key1', 5);
performanceMonitor.recordCache('miss', 'key2', 10);
performanceMonitor.recordCache('hit', 'key3', 3);
performanceMonitor.recordError('test_error', 'Test message');
performanceMonitor.collectSystemMetrics();
});
it('should calculate request statistics', () => {
const stats = performanceMonitor.getStats();
expect(stats.requests.total).toBe(3);
expect(stats.requests.successful).toBe(2);
expect(stats.requests.errors).toBe(1);
expect(stats.requests.averageResponseTime).toBe(150); // (100 + 200 + 150) / 3
});
it('should calculate cache hit rate', () => {
const stats = performanceMonitor.getStats();
expect(stats.cache.operations).toBe(3);
expect(stats.cache.hitRate).toBe(66.67); // 2 hits out of 3 operations
});
it('should include error count', () => {
const stats = performanceMonitor.getStats();
expect(stats.errors.total).toBe(1);
});
it('should include memory statistics', () => {
const stats = performanceMonitor.getStats();
expect(stats.memory.current).toBeDefined();
expect(stats.memory.average).toBeDefined();
expect(stats.memory.peak).toBeDefined();
});
it('should filter by timeframe', () => {
// Add old data (simulate by manually setting old timestamp)
const oldTimestamp = Date.now() - 10 * 60 * 1000; // 10 minutes ago
performanceMonitor.metrics.requests.push({
endpoint: '/api/old',
duration: 999,
timestamp: oldTimestamp
});
// Get stats for last 5 minutes
const stats = performanceMonitor.getStats(5 * 60 * 1000);
// Should not include the old request
expect(stats.requests.total).toBe(3);
});
});
describe('Express middleware', () => {
it('should create Express middleware function', () => {
const middleware = performanceMonitor.createExpressMiddleware();
expect(typeof middleware).toBe('function');
expect(middleware.length).toBe(3); // (req, res, next)
});
it('should track request performance', (done) => {
const middleware = performanceMonitor.createExpressMiddleware();
// Mock Express req, res, next
const req = {
method: 'GET',
url: '/api/test',
route: { path: '/api/test' },
get: jest.fn().mockReturnValue('test-agent'),
ip: '127.0.0.1'
};
const res = {
on: jest.fn((event, callback) => {
if (event === 'finish') {
// Simulate response finishing
setTimeout(() => {
res.statusCode = 200;
callback();
// Check that request was recorded
expect(performanceMonitor.metrics.requests).toHaveLength(1);
expect(performanceMonitor.counters.total_requests).toBe(1);
done();
}, 10);
}
}),
statusCode: 200
};
const next = jest.fn();
middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
});
describe('cleanup and memory management', () => {
it('should clean up old metrics', () => {
// Add metrics with old timestamps
const oldTimestamp = Date.now() - 2 * 60 * 60 * 1000; // 2 hours ago
performanceMonitor.metrics.requests = [
{ endpoint: '/api/old', timestamp: oldTimestamp },
{ endpoint: '/api/new', timestamp: Date.now() }
];
performanceMonitor.cleanupOldMetrics();
expect(performanceMonitor.metrics.requests).toHaveLength(1);
expect(performanceMonitor.metrics.requests[0].endpoint).toBe('/api/new');
});
});
});
// Test utilities
const testUtils = {
async waitFor(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
};
// Make testUtils available for cachedFetch test
global.testUtils = testUtils;
+253
View File
@@ -0,0 +1,253 @@
/**
* Unit Tests for StateCalculator
* Tests conversation state detection logic
*/
const StateCalculator = require('../../src/analytics/core/StateCalculator');
describe('StateCalculator', () => {
let stateCalculator;
beforeEach(() => {
stateCalculator = new StateCalculator();
});
describe('determineConversationStatus', () => {
it('should return "active" for recent messages with user input', () => {
const messages = [
{
role: 'user',
timestamp: new Date(Date.now() - 30000), // 30 seconds ago
content: 'Hello'
},
{
role: 'assistant',
timestamp: new Date(Date.now() - 20000), // 20 seconds ago
content: 'Hi there!'
}
];
const lastModified = new Date(Date.now() - 10000); // 10 seconds ago
const status = stateCalculator.determineConversationStatus(messages, lastModified);
expect(status).toBe('active');
});
it('should return "waiting" for conversation with recent user message', () => {
const messages = [
{
role: 'user',
timestamp: new Date(Date.now() - 30000),
content: 'Can you help me?'
}
];
const lastModified = new Date(Date.now() - 25000);
const status = stateCalculator.determineConversationStatus(messages, lastModified);
expect(status).toBe('waiting');
});
it('should return "idle" for old conversations', () => {
const messages = [
{
role: 'user',
timestamp: new Date(Date.now() - 3700000), // Over 1 hour ago
content: 'Old message'
}
];
const lastModified = new Date(Date.now() - 3600000); // 1 hour ago
const status = stateCalculator.determineConversationStatus(messages, lastModified);
expect(status).toBe('idle');
});
it('should return "completed" for conversation ending with assistant', () => {
const messages = [
{
role: 'user',
timestamp: new Date(Date.now() - 600000), // 10 minutes ago
content: 'Thanks for the help!'
},
{
role: 'assistant',
timestamp: new Date(Date.now() - 590000), // 9:50 minutes ago
content: 'You\'re welcome! Let me know if you need anything else.'
}
];
const lastModified = new Date(Date.now() - 580000); // 9:40 minutes ago
const status = stateCalculator.determineConversationStatus(messages, lastModified);
expect(status).toBe('completed');
});
it('should handle empty messages array', () => {
const status = stateCalculator.determineConversationStatus([], new Date());
expect(status).toBe('idle');
});
it('should handle null/undefined inputs gracefully', () => {
expect(stateCalculator.determineConversationStatus(null, new Date())).toBe('idle');
expect(stateCalculator.determineConversationStatus([], null)).toBe('idle');
});
});
describe('determineConversationState', () => {
it('should return detailed state with running process', () => {
const messages = [
{
role: 'user',
timestamp: new Date(Date.now() - 30000),
content: 'Start coding'
}
];
const lastModified = new Date(Date.now() - 20000);
const runningProcess = { pid: 12345, command: 'node app.js' };
const state = stateCalculator.determineConversationState(messages, lastModified, runningProcess);
expect(state).toMatchObject({
status: 'active',
hasRunningProcess: true,
lastActivity: expect.any(Date),
messageCount: 1,
lastUserMessage: expect.any(Date),
lastAssistantMessage: null,
timeSinceLastActivity: expect.any(Number),
isRecent: true
});
});
it('should detect conversation patterns correctly', () => {
const messages = [
{
role: 'user',
timestamp: new Date(Date.now() - 120000), // 2 minutes ago
content: 'Can you help me debug this?'
},
{
role: 'assistant',
timestamp: new Date(Date.now() - 110000), // 1:50 minutes ago
content: 'Sure! Let me take a look.'
},
{
role: 'user',
timestamp: new Date(Date.now() - 100000), // 1:40 minutes ago
content: 'Here\'s the error message...'
}
];
const lastModified = new Date(Date.now() - 90000);
const state = stateCalculator.determineConversationState(messages, lastModified);
expect(state.status).toBe('waiting');
expect(state.messageCount).toBe(3);
expect(state.lastUserMessage).toBeInstanceOf(Date);
expect(state.lastAssistantMessage).toBeInstanceOf(Date);
expect(state.isRecent).toBe(true);
});
});
describe('quickStateCalculation', () => {
const mockConversation = {
id: 'conv_123',
filePath: '/path/to/conv.jsonl',
lastModified: new Date(Date.now() - 60000).toISOString(),
status: 'idle'
};
it('should update status when running process matches conversation', () => {
const runningProcesses = [
{ pid: 123, command: 'node app.js', cwd: '/path/to' }
];
const result = stateCalculator.quickStateCalculation(mockConversation, runningProcesses);
expect(result.status).toBe('active');
expect(result.hasRunningProcess).toBe(true);
expect(result.runningProcess).toMatchObject({
pid: 123,
command: 'node app.js'
});
});
it('should keep original status when no matching process', () => {
const runningProcesses = [
{ pid: 456, command: 'other-app', cwd: '/other/path' }
];
const result = stateCalculator.quickStateCalculation(mockConversation, runningProcesses);
expect(result.status).toBe('idle');
expect(result.hasRunningProcess).toBe(false);
});
it('should handle empty processes array', () => {
const result = stateCalculator.quickStateCalculation(mockConversation, []);
expect(result.status).toBe('idle');
expect(result.hasRunningProcess).toBe(false);
});
});
describe('isRecentActivity', () => {
it('should return true for recent timestamps', () => {
const recentTime = new Date(Date.now() - 30000); // 30 seconds ago
expect(stateCalculator.isRecentActivity(recentTime)).toBe(true);
});
it('should return false for old timestamps', () => {
const oldTime = new Date(Date.now() - 3700000); // Over 1 hour ago
expect(stateCalculator.isRecentActivity(oldTime)).toBe(false);
});
it('should handle string timestamps', () => {
const recentTimeStr = new Date(Date.now() - 30000).toISOString();
expect(stateCalculator.isRecentActivity(recentTimeStr)).toBe(true);
});
it('should handle invalid timestamps', () => {
expect(stateCalculator.isRecentActivity(null)).toBe(false);
expect(stateCalculator.isRecentActivity('invalid')).toBe(false);
});
});
describe('getTimeSinceLastActivity', () => {
it('should calculate time difference correctly', () => {
const timestamp = new Date(Date.now() - 60000); // 1 minute ago
const timeSince = stateCalculator.getTimeSinceLastActivity(timestamp);
expect(timeSince).toBeGreaterThan(59000); // At least 59 seconds
expect(timeSince).toBeLessThan(65000); // Less than 65 seconds
});
it('should return 0 for invalid timestamps', () => {
expect(stateCalculator.getTimeSinceLastActivity(null)).toBe(0);
expect(stateCalculator.getTimeSinceLastActivity('invalid')).toBe(0);
});
});
describe('edge cases and error handling', () => {
it('should handle malformed message objects', () => {
const messages = [
{ role: 'user' }, // Missing timestamp
{ timestamp: new Date() }, // Missing role
null, // Null message
undefined // Undefined message
];
const status = stateCalculator.determineConversationStatus(messages, new Date());
expect(status).toBe('idle'); // Should fallback gracefully
});
it('should handle timezone differences', () => {
const utcTime = new Date().toISOString();
const localTime = new Date();
const status1 = stateCalculator.determineConversationStatus(
[{ role: 'user', timestamp: utcTime, content: 'test' }],
localTime
);
expect(['active', 'waiting', 'idle', 'completed']).toContain(status1);
});
});
});
+437
View File
@@ -0,0 +1,437 @@
/**
* Unit Tests for StateService
* Tests reactive state management functionality
*/
// Load the StateService class
const fs = require('fs');
const path = require('path');
// Load StateService from the actual file
const StateServicePath = path.join(__dirname, '../../src/analytics-web/services/StateService.js');
const StateServiceCode = fs.readFileSync(StateServicePath, 'utf8');
// Create a module-like environment
const moduleExports = {};
const module = { exports: moduleExports };
// Execute the StateService code in our test environment
eval(StateServiceCode);
const StateService = moduleExports.StateService || global.StateService;
describe('StateService', () => {
let stateService;
beforeEach(() => {
stateService = new StateService();
});
describe('constructor', () => {
it('should initialize with default state', () => {
expect(stateService.state).toMatchObject({
conversations: [],
summary: {},
chartData: {},
selectedConversation: null,
conversationStates: {},
systemHealth: {},
isLoading: false,
error: null,
lastUpdate: null
});
expect(stateService.subscribers).toBeInstanceOf(Set);
expect(stateService.stateHistory).toEqual([]);
expect(stateService.maxHistorySize).toBe(50);
});
});
describe('subscribe/unsubscribe', () => {
it('should add subscribers and return unsubscribe function', () => {
const mockCallback = jest.fn();
const unsubscribe = stateService.subscribe(mockCallback);
expect(stateService.subscribers.has(mockCallback)).toBe(true);
expect(typeof unsubscribe).toBe('function');
// Test unsubscribe
unsubscribe();
expect(stateService.subscribers.has(mockCallback)).toBe(false);
});
it('should notify subscribers when state changes', () => {
const mockCallback = jest.fn();
stateService.subscribe(mockCallback);
const newState = { isLoading: true };
stateService.setState(newState, 'test_action');
expect(mockCallback).toHaveBeenCalledWith(
expect.objectContaining(newState),
'test_action',
newState
);
});
it('should handle multiple subscribers', () => {
const callback1 = jest.fn();
const callback2 = jest.fn();
stateService.subscribe(callback1);
stateService.subscribe(callback2);
stateService.setState({ isLoading: true });
expect(callback1).toHaveBeenCalled();
expect(callback2).toHaveBeenCalled();
});
it('should handle subscriber errors gracefully', () => {
const errorCallback = jest.fn().mockImplementation(() => {
throw new Error('Subscriber error');
});
const normalCallback = jest.fn();
stateService.subscribe(errorCallback);
stateService.subscribe(normalCallback);
expect(() => {
stateService.setState({ test: true });
}).not.toThrow();
expect(normalCallback).toHaveBeenCalled();
});
});
describe('getState and getStateProperty', () => {
it('should return current state', () => {
const state = stateService.getState();
expect(state).toEqual(stateService.state);
expect(state).not.toBe(stateService.state); // Should be a copy
});
it('should return specific state properties', () => {
stateService.state.conversations = [{ id: 'test' }];
expect(stateService.getStateProperty('conversations')).toEqual([{ id: 'test' }]);
expect(stateService.getStateProperty('nonexistent')).toBeUndefined();
});
});
describe('setState and setStateProperty', () => {
it('should update state and preserve existing properties', () => {
const initialState = stateService.getState();
const newData = { isLoading: true, conversations: [{ id: 'test' }] };
stateService.setState(newData);
const updatedState = stateService.getState();
expect(updatedState.isLoading).toBe(true);
expect(updatedState.conversations).toEqual([{ id: 'test' }]);
expect(updatedState.summary).toEqual(initialState.summary); // Preserved
expect(updatedState.lastUpdate).toBeGreaterThan(0);
});
it('should update specific properties', () => {
stateService.setStateProperty('isLoading', true, 'start_loading');
expect(stateService.state.isLoading).toBe(true);
expect(stateService.stateHistory[0].action).toBe('start_loading');
});
it('should save state to history', () => {
const initialHistorySize = stateService.stateHistory.length;
stateService.setState({ test: true }, 'test_action');
expect(stateService.stateHistory).toHaveLength(initialHistorySize + 1);
expect(stateService.stateHistory[0]).toMatchObject({
action: 'test_action',
timestamp: expect.any(Number)
});
});
it('should limit history size', () => {
stateService.maxHistorySize = 3;
// Add more entries than max size
for (let i = 0; i < 5; i++) {
stateService.setState({ count: i }, `action_${i}`);
}
expect(stateService.stateHistory).toHaveLength(3);
expect(stateService.stateHistory[0].action).toBe('action_2'); // Oldest preserved
expect(stateService.stateHistory[2].action).toBe('action_4'); // Most recent
});
});
describe('specific update methods', () => {
it('should update conversations', () => {
const conversations = [{ id: 'conv1' }, { id: 'conv2' }];
const mockSubscriber = jest.fn();
stateService.subscribe(mockSubscriber);
stateService.updateConversations(conversations);
expect(stateService.state.conversations).toEqual(conversations);
expect(mockSubscriber).toHaveBeenCalledWith(
expect.objectContaining({ conversations }),
'update_conversations',
{ conversations }
);
});
it('should update conversation states', () => {
const states = { conv1: 'active', conv2: 'idle' };
stateService.updateConversationStates(states);
expect(stateService.state.conversationStates).toEqual(states);
});
it('should update summary', () => {
const summary = { totalConversations: 5, activeConversations: 2 };
stateService.updateSummary(summary);
expect(stateService.state.summary).toEqual(summary);
});
it('should update chart data', () => {
const chartData = { labels: ['A', 'B'], data: [1, 2] };
stateService.updateChartData(chartData);
expect(stateService.state.chartData).toEqual(chartData);
});
it('should set selected conversation', () => {
const conversation = { id: 'conv1', title: 'Test Conversation' };
stateService.setSelectedConversation(conversation);
expect(stateService.state.selectedConversation).toEqual(conversation);
});
it('should set loading state', () => {
stateService.setLoading(true);
expect(stateService.state.isLoading).toBe(true);
stateService.setLoading(false);
expect(stateService.state.isLoading).toBe(false);
});
it('should set and clear error state', () => {
const error = new Error('Test error');
stateService.setError(error);
expect(stateService.state.error).toBe(error);
stateService.clearError();
expect(stateService.state.error).toBeNull();
});
it('should handle string errors', () => {
stateService.setError('String error');
expect(stateService.state.error).toBe('String error');
});
it('should update system health', () => {
const health = { cpu: 45, memory: 60, status: 'healthy' };
stateService.updateSystemHealth(health);
expect(stateService.state.systemHealth).toEqual(health);
});
});
describe('notifyConversationStateChange', () => {
beforeEach(() => {
stateService.state.conversations = [
{ id: 'conv1', status: 'idle' },
{ id: 'conv2', status: 'active' }
];
stateService.state.conversationStates = {
conv1: 'idle',
conv2: 'active'
};
});
it('should update conversation state and conversation status', () => {
stateService.notifyConversationStateChange('conv1', 'active');
expect(stateService.state.conversationStates.conv1).toBe('active');
expect(stateService.state.conversations[0].status).toBe('active');
});
it('should handle non-existent conversation gracefully', () => {
expect(() => {
stateService.notifyConversationStateChange('nonexistent', 'active');
}).not.toThrow();
expect(stateService.state.conversationStates.nonexistent).toBe('active');
});
});
describe('conversation queries', () => {
beforeEach(() => {
stateService.state.conversations = [
{ id: 'conv1', status: 'active', title: 'Active Chat' },
{ id: 'conv2', status: 'idle', title: 'Idle Chat' },
{ id: 'conv3', status: 'active', title: 'Another Active' }
];
});
it('should get conversation by ID', () => {
const conversation = stateService.getConversationById('conv2');
expect(conversation).toEqual({
id: 'conv2',
status: 'idle',
title: 'Idle Chat'
});
});
it('should return null for non-existent conversation', () => {
const conversation = stateService.getConversationById('nonexistent');
expect(conversation).toBeNull();
});
it('should get conversations by status', () => {
const activeConversations = stateService.getConversationsByStatus('active');
expect(activeConversations).toHaveLength(2);
expect(activeConversations.every(conv => conv.status === 'active')).toBe(true);
});
it('should return empty array for non-matching status', () => {
const waitingConversations = stateService.getConversationsByStatus('waiting');
expect(waitingConversations).toEqual([]);
});
});
describe('state history management', () => {
it('should return state history', () => {
stateService.setState({ test1: true }, 'action1');
stateService.setState({ test2: true }, 'action2');
const history = stateService.getStateHistory();
expect(history).toHaveLength(2);
expect(history[0].action).toBe('action1');
expect(history[1].action).toBe('action2');
expect(history).not.toBe(stateService.stateHistory); // Should be a copy
});
it('should clear state history', () => {
stateService.setState({ test: true });
expect(stateService.stateHistory.length).toBeGreaterThan(0);
stateService.clearStateHistory();
expect(stateService.stateHistory).toHaveLength(0);
});
});
describe('resetState', () => {
it('should reset to initial state', () => {
// Modify state
stateService.setState({
conversations: [{ id: 'test' }],
isLoading: true,
error: 'Some error'
});
// Reset
stateService.resetState();
expect(stateService.state).toMatchObject({
conversations: [],
summary: {},
chartData: {},
selectedConversation: null,
conversationStates: {},
systemHealth: {},
isLoading: false,
error: null,
lastUpdate: null
});
});
});
describe('getStateStats', () => {
it('should return state statistics', () => {
const mockSubscriber = jest.fn();
stateService.subscribe(mockSubscriber);
stateService.setState({ conversations: [1, 2, 3] });
stateService.setError('Test error');
const stats = stateService.getStateStats();
expect(stats).toMatchObject({
subscribers: 1,
historySize: 1,
conversationsCount: 3,
lastUpdate: expect.any(Number),
hasError: true,
isLoading: false
});
});
it('should handle empty state', () => {
const stats = stateService.getStateStats();
expect(stats).toMatchObject({
subscribers: 0,
historySize: 0,
conversationsCount: 0,
lastUpdate: null,
hasError: false,
isLoading: false
});
});
});
describe('error handling and edge cases', () => {
it('should handle null/undefined state updates', () => {
expect(() => {
stateService.setState(null);
}).not.toThrow();
expect(() => {
stateService.setState(undefined);
}).not.toThrow();
});
it('should handle state updates with circular references', () => {
const circularObj = { test: true };
circularObj.self = circularObj;
expect(() => {
stateService.setState({ circular: circularObj });
}).not.toThrow();
});
it('should maintain state integrity during concurrent updates', () => {
const promises = [];
// Simulate concurrent state updates
for (let i = 0; i < 10; i++) {
promises.push(
Promise.resolve().then(() => {
stateService.setState({ counter: i });
})
);
}
return Promise.all(promises).then(() => {
expect(stateService.state.counter).toBeGreaterThanOrEqual(0);
expect(stateService.state.counter).toBeLessThan(10);
});
});
});
});
+410
View File
@@ -0,0 +1,410 @@
/**
* Unit Tests for WebSocketServer
* Tests real-time communication server
*/
const WebSocketServer = require('../../src/analytics/notifications/WebSocketServer');
const WebSocket = require('ws');
// Mock the WebSocket library
jest.mock('ws');
describe('WebSocketServer', () => {
let webSocketServer;
let mockHttpServer;
let mockWss;
let mockWs;
beforeEach(() => {
// Mock HTTP server
mockHttpServer = {
listen: jest.fn(),
close: jest.fn()
};
// Mock WebSocket server
mockWss = {
on: jest.fn(),
close: jest.fn(),
clients: new Set()
};
// Mock WebSocket connection
mockWs = {
on: jest.fn(),
send: jest.fn(),
close: jest.fn(),
terminate: jest.fn(),
ping: jest.fn(),
readyState: WebSocket.OPEN
};
// Mock WebSocket.Server constructor
WebSocket.Server.mockImplementation(() => mockWss);
webSocketServer = new WebSocketServer(mockHttpServer);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('constructor', () => {
it('should initialize with default options', () => {
expect(webSocketServer.httpServer).toBe(mockHttpServer);
expect(webSocketServer.options.port).toBe(3334);
expect(webSocketServer.options.path).toBe('/ws');
expect(webSocketServer.isRunning).toBe(false);
expect(webSocketServer.clients).toBeInstanceOf(Map);
});
it('should accept custom options', () => {
const customServer = new WebSocketServer(mockHttpServer, {
port: 4444,
path: '/websocket',
heartbeatInterval: 60000
});
expect(customServer.options.port).toBe(4444);
expect(customServer.options.path).toBe('/websocket');
expect(customServer.options.heartbeatInterval).toBe(60000);
});
});
describe('initialize', () => {
it('should create WebSocket server with correct options', async () => {
await webSocketServer.initialize();
expect(WebSocket.Server).toHaveBeenCalledWith({
server: mockHttpServer,
path: '/ws',
clientTracking: true
});
expect(webSocketServer.isRunning).toBe(true);
});
it('should setup event handlers', async () => {
await webSocketServer.initialize();
expect(mockWss.on).toHaveBeenCalledWith('connection', expect.any(Function));
expect(mockWss.on).toHaveBeenCalledWith('error', expect.any(Function));
expect(mockWss.on).toHaveBeenCalledWith('close', expect.any(Function));
});
it('should handle initialization errors', async () => {
WebSocket.Server.mockImplementation(() => {
throw new Error('Failed to create WebSocket server');
});
await expect(webSocketServer.initialize()).rejects.toThrow('Failed to create WebSocket server');
});
});
describe('handleConnection', () => {
beforeEach(async () => {
await webSocketServer.initialize();
});
it('should register new client and setup handlers', () => {
const mockRequest = {
socket: { remoteAddress: '127.0.0.1' },
headers: { 'user-agent': 'test-client' }
};
// Simulate connection event
const connectionHandler = mockWss.on.mock.calls.find(call => call[0] === 'connection')[1];
connectionHandler(mockWs, mockRequest);
expect(webSocketServer.clients.size).toBe(1);
expect(mockWs.on).toHaveBeenCalledWith('message', expect.any(Function));
expect(mockWs.on).toHaveBeenCalledWith('close', expect.any(Function));
expect(mockWs.on).toHaveBeenCalledWith('error', expect.any(Function));
expect(mockWs.on).toHaveBeenCalledWith('pong', expect.any(Function));
});
it('should send welcome message to new client', () => {
const mockRequest = {
socket: { remoteAddress: '127.0.0.1' },
headers: { 'user-agent': 'test-client' }
};
const connectionHandler = mockWss.on.mock.calls.find(call => call[0] === 'connection')[1];
connectionHandler(mockWs, mockRequest);
expect(mockWs.send).toHaveBeenCalledWith(
expect.stringContaining('"type":"connection"')
);
});
});
describe('handleClientMessage', () => {
let clientId;
beforeEach(async () => {
await webSocketServer.initialize();
// Add a mock client
clientId = 'test_client_123';
webSocketServer.clients.set(clientId, {
id: clientId,
ws: mockWs,
subscriptions: new Set(),
isAlive: true
});
});
it('should handle subscribe message', () => {
const subscribeMessage = JSON.stringify({
type: 'subscribe',
channel: 'conversation_updates'
});
webSocketServer.handleClientMessage(clientId, Buffer.from(subscribeMessage));
const client = webSocketServer.clients.get(clientId);
expect(client.subscriptions.has('conversation_updates')).toBe(true);
expect(mockWs.send).toHaveBeenCalledWith(
expect.stringContaining('"type":"subscription_confirmed"')
);
});
it('should handle unsubscribe message', () => {
// First subscribe
const client = webSocketServer.clients.get(clientId);
client.subscriptions.add('conversation_updates');
const unsubscribeMessage = JSON.stringify({
type: 'unsubscribe',
channel: 'conversation_updates'
});
webSocketServer.handleClientMessage(clientId, Buffer.from(unsubscribeMessage));
expect(client.subscriptions.has('conversation_updates')).toBe(false);
});
it('should handle ping message', () => {
const pingMessage = JSON.stringify({ type: 'ping' });
webSocketServer.handleClientMessage(clientId, Buffer.from(pingMessage));
expect(mockWs.send).toHaveBeenCalledWith(
expect.stringContaining('"type":"pong"')
);
});
it('should handle malformed JSON gracefully', () => {
const invalidMessage = 'invalid json{';
expect(() => {
webSocketServer.handleClientMessage(clientId, Buffer.from(invalidMessage));
}).not.toThrow();
});
});
describe('broadcast', () => {
beforeEach(async () => {
await webSocketServer.initialize();
// Add mock clients
webSocketServer.clients.set('client1', {
id: 'client1',
ws: { ...mockWs, readyState: WebSocket.OPEN },
subscriptions: new Set(['conversation_updates'])
});
webSocketServer.clients.set('client2', {
id: 'client2',
ws: { ...mockWs, readyState: WebSocket.OPEN, send: jest.fn() },
subscriptions: new Set(['data_updates'])
});
});
it('should broadcast to all clients when no channel specified', () => {
const message = { type: 'test_message', data: 'test' };
webSocketServer.broadcast(message);
expect(mockWs.send).toHaveBeenCalledTimes(2);
});
it('should broadcast only to subscribed clients when channel specified', () => {
const message = { type: 'conversation_state_change', data: 'test' };
webSocketServer.broadcast(message, 'conversation_updates');
// Only client1 should receive the message (subscribed to conversation_updates)
expect(mockWs.send).toHaveBeenCalledTimes(1);
});
it('should handle send errors gracefully', () => {
const errorWs = {
...mockWs,
send: jest.fn().mockImplementation(() => {
throw new Error('Send failed');
}),
readyState: WebSocket.OPEN
};
webSocketServer.clients.set('error_client', {
id: 'error_client',
ws: errorWs,
subscriptions: new Set()
});
const message = { type: 'test_message', data: 'test' };
expect(() => {
webSocketServer.broadcast(message);
}).not.toThrow();
// Error client should be removed
expect(webSocketServer.clients.has('error_client')).toBe(false);
});
});
describe('notification methods', () => {
beforeEach(async () => {
await webSocketServer.initialize();
jest.spyOn(webSocketServer, 'broadcast');
});
it('should notify conversation state change', () => {
webSocketServer.notifyConversationStateChange('conv_123', 'active', { project: 'test' });
expect(webSocketServer.broadcast).toHaveBeenCalledWith(
{
type: 'conversation_state_change',
data: {
conversationId: 'conv_123',
newState: 'active',
project: 'test'
}
},
'conversation_updates'
);
});
it('should notify data refresh', () => {
const testData = { conversations: [], summary: {} };
webSocketServer.notifyDataRefresh(testData);
expect(webSocketServer.broadcast).toHaveBeenCalledWith(
{
type: 'data_refresh',
data: testData
},
'data_updates'
);
});
it('should notify system status', () => {
const status = { message: 'System healthy', level: 'info' };
webSocketServer.notifySystemStatus(status);
expect(webSocketServer.broadcast).toHaveBeenCalledWith(
{
type: 'system_status',
data: status
},
'system_updates'
);
});
});
describe('heartbeat mechanism', () => {
beforeEach(async () => {
jest.useFakeTimers();
await webSocketServer.initialize();
});
afterEach(() => {
jest.useRealTimers();
});
it('should start heartbeat on initialization', () => {
expect(webSocketServer.heartbeatInterval).not.toBeNull();
});
it('should ping clients and remove unresponsive ones', () => {
// Add responsive client
const responsiveWs = { ...mockWs, ping: jest.fn() };
webSocketServer.clients.set('responsive', {
id: 'responsive',
ws: responsiveWs,
isAlive: true
});
// Add unresponsive client
const unresponsiveWs = { ...mockWs, terminate: jest.fn() };
webSocketServer.clients.set('unresponsive', {
id: 'unresponsive',
ws: unresponsiveWs,
isAlive: false
});
// Trigger heartbeat
jest.advanceTimersByTime(30000);
expect(responsiveWs.ping).toHaveBeenCalled();
expect(unresponsiveWs.terminate).toHaveBeenCalled();
expect(webSocketServer.clients.has('unresponsive')).toBe(false);
});
});
describe('getStats', () => {
beforeEach(async () => {
await webSocketServer.initialize();
});
it('should return server statistics', () => {
// Add some mock clients
webSocketServer.clients.set('client1', {
id: 'client1',
ip: '127.0.0.1',
connectedAt: new Date(),
subscriptions: new Set(['test']),
isAlive: true
});
const stats = webSocketServer.getStats();
expect(stats).toMatchObject({
isRunning: true,
clientCount: 1,
queuedMessages: 0,
clients: expect.arrayContaining([
expect.objectContaining({
id: 'client1',
ip: '127.0.0.1',
subscriptions: ['test'],
isAlive: true
})
])
});
});
});
describe('close', () => {
beforeEach(async () => {
await webSocketServer.initialize();
});
it('should close all connections and stop server', async () => {
// Add a mock client
webSocketServer.clients.set('client1', {
id: 'client1',
ws: mockWs
});
await webSocketServer.close();
expect(mockWs.close).toHaveBeenCalledWith(1000, 'Server shutting down');
expect(mockWss.close).toHaveBeenCalled();
expect(webSocketServer.isRunning).toBe(false);
expect(webSocketServer.clients.size).toBe(0);
});
});
});