chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user