388 lines
13 KiB
JavaScript
388 lines
13 KiB
JavaScript
/**
|
|
* 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();
|
|
});
|
|
});
|
|
}); |