410 lines
12 KiB
JavaScript
410 lines
12 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|
|
}); |