Files
Aniworld/tests/frontend/unit/websocket.test.js
2026-07-31 07:33:12 +02:00

778 lines
23 KiB
JavaScript

/**
* Unit tests for WebSocket client functionality
* Tests connection, reconnection, authentication, error handling, and message dispatch
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
// Mock WebSocket class
class MockWebSocket {
constructor(url) {
this.url = url;
this.readyState = MockWebSocket.CONNECTING;
this.CONNECTING = 0;
this.OPEN = 1;
this.CLOSING = 2;
this.CLOSED = 3;
// Event handlers
this.onopen = null;
this.onclose = null;
this.onerror = null;
this.onmessage = null;
// Store instance for testing
MockWebSocket._lastInstance = this;
// Auto-connect after a tick
setTimeout(() => {
if (this.readyState === MockWebSocket.CONNECTING) {
this.readyState = MockWebSocket.OPEN;
if (this.onopen) this.onopen({ type: 'open' });
}
}, 0);
}
send(data) {
if (this.readyState !== MockWebSocket.OPEN) {
throw new Error('WebSocket is not open');
}
this._lastSent = data;
}
close(code, reason) {
this.readyState = MockWebSocket.CLOSING;
setTimeout(() => {
this.readyState = MockWebSocket.CLOSED;
if (this.onclose) {
this.onclose({
type: 'close',
code: code || 1000,
reason: reason || '',
wasClean: code === 1000
});
}
}, 0);
}
// Test helper: simulate message received
_simulateMessage(data) {
if (this.onmessage) {
this.onmessage({
type: 'message',
data: typeof data === 'string' ? data : JSON.stringify(data)
});
}
}
// Test helper: simulate error
_simulateError(error) {
if (this.onerror) {
this.onerror({
type: 'error',
error: error || new Error('WebSocket error')
});
}
}
// Test helper: simulate connection close
_simulateClose(code = 1006, reason = 'Connection lost') {
this.readyState = MockWebSocket.CLOSED;
if (this.onclose) {
this.onclose({
type: 'close',
code,
reason,
wasClean: false
});
}
}
}
// Static properties
MockWebSocket.CONNECTING = 0;
MockWebSocket.OPEN = 1;
MockWebSocket.CLOSING = 2;
MockWebSocket.CLOSED = 3;
// Import WebSocket client code (we'll need to evaluate it)
// For testing, we'll load the actual file
let WebSocketClient;
// Load the WebSocket client source (used by multiple describe blocks)
function loadWebSocketClientSource() {
const fs = require('node:fs');
const path = require('node:path');
const src = fs.readFileSync(
path.resolve(__dirname, '../../../src/server/web/static/js/shared/websocket-client.js'),
'utf8'
);
// eslint-disable-next-line no-eval
(0, eval)(src);
// Provide a Socket.IO-like io() factory for tests that use it
if (typeof globalThis.io === 'undefined') {
globalThis.io = function (url) {
const client = new globalThis.WebSocketClient(url);
client.connect();
return client;
};
}
return globalThis.WebSocketClient;
}
describe('WebSocket Client - Initialization', () => {
beforeEach(() => {
// Mock global WebSocket
global.WebSocket = MockWebSocket;
// Clear any timers
vi.useFakeTimers();
// Load WebSocketClient class from the real source
WebSocketClient = loadWebSocketClientSource();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
delete globalThis.WebSocketClient;
delete globalThis.io;
});
it('should create WebSocket client with default options', () => {
const client = new WebSocketClient('/ws');
expect(client.url).toBe('/ws');
expect(client.maxReconnectAttempts).toBe(5);
expect(client.reconnectDelay).toBe(1000);
expect(client.autoReconnect).toBe(true);
expect(client.isConnected).toBe(false);
expect(client.reconnectAttempts).toBe(0);
});
it('should create WebSocket client with custom options', () => {
const client = new WebSocketClient('/ws', {
maxReconnectAttempts: 10,
reconnectDelay: 2000,
autoReconnect: false
});
expect(client.maxReconnectAttempts).toBe(10);
expect(client.reconnectDelay).toBe(2000);
expect(client.autoReconnect).toBe(false);
});
it('should initialize empty event handlers map', () => {
const client = new WebSocketClient('/ws');
expect(client.eventHandlers).toBeInstanceOf(Map);
expect(client.eventHandlers.size).toBe(0);
});
it('should initialize empty message queue', () => {
const client = new WebSocketClient('/ws');
expect(client.messageQueue).toEqual([]);
});
it('should initialize empty rooms set', () => {
const client = new WebSocketClient('/ws');
expect(client.rooms).toBeInstanceOf(Set);
expect(client.rooms.size).toBe(0);
});
});
describe('WebSocket Client - Connection', () => {
beforeEach(() => {
global.WebSocket = MockWebSocket;
vi.useFakeTimers();
// Mock window.location
global.window = {
location: {
protocol: 'http:',
host: 'localhost:8000'
}
};
// Load WebSocketClient class from the real source
WebSocketClient = loadWebSocketClientSource();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
delete global.window;
});
it('should generate correct WebSocket URL with http protocol', () => {
const client = new WebSocketClient('/ws/updates');
expect(client.getWebSocketUrl()).toBe('ws://localhost:8000/ws/updates');
});
it('should generate correct WebSocket URL with https protocol', () => {
global.window.location.protocol = 'https:';
const client = new WebSocketClient('/ws/updates');
expect(client.getWebSocketUrl()).toBe('wss://localhost:8000/ws/updates');
});
it('should create WebSocket connection on connect()', async () => {
const client = new WebSocketClient('/ws');
client.connect();
expect(MockWebSocket._lastInstance).toBeDefined();
expect(MockWebSocket._lastInstance.url).toBe('ws://localhost:8000/ws');
});
it('should emit connect event when connection opens', async () => {
const client = new WebSocketClient('/ws');
const connectHandler = vi.fn();
client.on('connect', connectHandler);
client.connect();
await vi.runAllTimersAsync();
expect(connectHandler).toHaveBeenCalledTimes(1);
expect(client.isConnected).toBe(true);
});
it('should reset reconnect attempts on successful connection', async () => {
const client = new WebSocketClient('/ws');
client.reconnectAttempts = 3;
client.connect();
await vi.runAllTimersAsync();
expect(client.reconnectAttempts).toBe(0);
});
});
describe('WebSocket Client - Reconnection Logic', () => {
beforeEach(() => {
global.WebSocket = MockWebSocket;
vi.useFakeTimers();
global.window = {
location: {
protocol: 'http:',
host: 'localhost:8000'
}
};
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
delete global.window;
});
it('should attempt reconnection after unclean close', async () => {
const client = new WebSocketClient('/ws');
client.connect();
await vi.runAllTimersAsync();
// Simulate connection loss
const ws = MockWebSocket._lastInstance;
ws._simulateClose(1006, 'Connection lost');
await vi.runAllTimersAsync();
// Should trigger reconnection after delay
expect(client.reconnectAttempts).toBe(1);
});
it('should use exponential backoff for reconnection delays', async () => {
const client = new WebSocketClient('/ws', { reconnectDelay: 1000 });
client.connect();
await vi.runAllTimersAsync();
// First reconnection attempt: 1000ms delay
let ws = MockWebSocket._lastInstance;
ws._simulateClose(1006);
vi.advanceTimersByTime(999);
expect(client.reconnectAttempts).toBe(1);
vi.advanceTimersByTime(1);
await vi.runAllTimersAsync();
// Second reconnection attempt: 2000ms delay
ws = MockWebSocket._lastInstance;
ws._simulateClose(1006);
vi.advanceTimersByTime(1999);
expect(client.reconnectAttempts).toBe(2);
vi.advanceTimersByTime(1);
await vi.runAllTimersAsync();
// Third reconnection attempt: 3000ms delay
expect(client.reconnectAttempts).toBe(3);
});
it('should stop reconnecting after max attempts', async () => {
const client = new WebSocketClient('/ws', {
maxReconnectAttempts: 3,
reconnectDelay: 100
});
const reconnectFailedHandler = vi.fn();
client.on('reconnect_failed', reconnectFailedHandler);
client.connect();
await vi.runAllTimersAsync();
// Simulate 3 connection failures
for (let i = 0; i < 3; i++) {
const ws = MockWebSocket._lastInstance;
ws._simulateClose(1006);
await vi.runAllTimersAsync();
}
expect(client.reconnectAttempts).toBe(3);
expect(reconnectFailedHandler).toHaveBeenCalledTimes(1);
// Should not attempt another reconnection
const attemptsBefore = client.reconnectAttempts;
await vi.advanceTimersByTimeAsync(5000);
expect(client.reconnectAttempts).toBe(attemptsBefore);
});
it('should not reconnect after clean disconnect', async () => {
const client = new WebSocketClient('/ws');
client.connect();
await vi.runAllTimersAsync();
const attemptsBefore = client.reconnectAttempts;
client.disconnect();
await vi.runAllTimersAsync();
expect(client.reconnectAttempts).toBe(attemptsBefore);
expect(client.autoReconnect).toBe(false);
});
it('should not reconnect when autoReconnect is disabled', async () => {
const client = new WebSocketClient('/ws', { autoReconnect: false });
client.connect();
await vi.runAllTimersAsync();
const ws = MockWebSocket._lastInstance;
ws._simulateClose(1006);
await vi.runAllTimersAsync();
expect(client.reconnectAttempts).toBe(0);
});
});
describe('WebSocket Client - Event Handling', () => {
beforeEach(() => {
global.WebSocket = MockWebSocket;
vi.useFakeTimers();
global.window = {
location: {
protocol: 'http:',
host: 'localhost:8000'
}
};
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it('should register event handlers', () => {
const client = new WebSocketClient('/ws');
const handler = vi.fn();
client.on('test_event', handler);
expect(client.eventHandlers.has('test_event')).toBe(true);
expect(client.eventHandlers.get('test_event')).toContain(handler);
});
it('should register multiple handlers for same event', () => {
const client = new WebSocketClient('/ws');
const handler1 = vi.fn();
const handler2 = vi.fn();
client.on('test_event', handler1);
client.on('test_event', handler2);
expect(client.eventHandlers.get('test_event').length).toBe(2);
});
it('should emit events to registered handlers', () => {
const client = new WebSocketClient('/ws');
const handler = vi.fn();
client.on('test_event', handler);
client.emit('test_event', { message: 'test' });
expect(handler).toHaveBeenCalledWith({ message: 'test' });
});
it('should remove event handlers with off()', () => {
const client = new WebSocketClient('/ws');
const handler = vi.fn();
client.on('test_event', handler);
client.off('test_event', handler);
client.emit('test_event', { message: 'test' });
expect(handler).not.toHaveBeenCalled();
});
it('should handle errors in event handlers gracefully', () => {
const client = new WebSocketClient('/ws');
const errorHandler = vi.fn(() => {
throw new Error('Handler error');
});
const normalHandler = vi.fn();
client.on('test_event', errorHandler);
client.on('test_event', normalHandler);
// Should not throw, should continue to next handler
expect(() => client.emit('test_event', {})).not.toThrow();
expect(errorHandler).toHaveBeenCalled();
expect(normalHandler).toHaveBeenCalled();
});
});
describe('WebSocket Client - Message Handling', () => {
beforeEach(() => {
global.WebSocket = MockWebSocket;
vi.useFakeTimers();
global.window = {
location: {
protocol: 'http:',
host: 'localhost:8000'
}
};
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it('should parse and emit JSON messages', async () => {
const client = new WebSocketClient('/ws');
const handler = vi.fn();
client.on('download_progress', handler);
client.connect();
await vi.runAllTimersAsync();
const ws = MockWebSocket._lastInstance;
ws._simulateMessage({
type: 'download_progress',
episode_id: '123',
progress: 50
});
expect(handler).toHaveBeenCalledWith({
episode_id: '123',
progress: 50
});
});
it('should handle malformed JSON messages', async () => {
const client = new WebSocketClient('/ws');
const errorHandler = vi.fn();
client.on('error', errorHandler);
client.connect();
await vi.runAllTimersAsync();
const ws = MockWebSocket._lastInstance;
ws._simulateMessage('not valid json{');
expect(errorHandler).toHaveBeenCalled();
});
it('should emit error for messages without type', async () => {
const client = new WebSocketClient('/ws');
const testHandler = vi.fn();
client.on('test', testHandler);
client.connect();
await vi.runAllTimersAsync();
const ws = MockWebSocket._lastInstance;
ws._simulateMessage({ data: 'no type field' });
// Should not emit to any handler without type
expect(testHandler).not.toHaveBeenCalled();
});
});
describe('WebSocket Client - Message Queueing', () => {
beforeEach(() => {
global.WebSocket = MockWebSocket;
vi.useFakeTimers();
global.window = {
location: {
protocol: 'http:',
host: 'localhost:8000'
}
};
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it('should queue messages when disconnected', () => {
const client = new WebSocketClient('/ws');
client.send('test_action', { data: 'test' });
expect(client.messageQueue.length).toBe(1);
expect(client.messageQueue[0]).toContain('test_action');
});
it('should send messages immediately when connected', async () => {
const client = new WebSocketClient('/ws');
client.connect();
await vi.runAllTimersAsync();
const ws = MockWebSocket._lastInstance;
client.send('test_action', { data: 'test' });
expect(ws._lastSent).toContain('test_action');
expect(client.messageQueue.length).toBe(0);
});
it('should process queued messages on reconnection', async () => {
const client = new WebSocketClient('/ws');
// Queue messages while disconnected
client.send('action1', { data: '1' });
client.send('action2', { data: '2' });
expect(client.messageQueue.length).toBe(2);
// Connect and process queue
client.connect();
await vi.runAllTimersAsync();
expect(client.messageQueue.length).toBe(0);
});
});
describe('WebSocket Client - Room Management', () => {
beforeEach(() => {
global.WebSocket = MockWebSocket;
vi.useFakeTimers();
global.window = {
location: {
protocol: 'http:',
host: 'localhost:8000'
}
};
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it('should add room to rooms set on join', () => {
const client = new WebSocketClient('/ws');
client.join('downloads');
expect(client.rooms.has('downloads')).toBe(true);
});
it('should send join message when connected', async () => {
const client = new WebSocketClient('/ws');
client.connect();
await vi.runAllTimersAsync();
const ws = MockWebSocket._lastInstance;
client.join('downloads');
expect(ws._lastSent).toContain('join');
expect(ws._lastSent).toContain('downloads');
});
it('should remove room from rooms set on leave', async () => {
const client = new WebSocketClient('/ws');
client.join('downloads');
client.leave('downloads');
expect(client.rooms.has('downloads')).toBe(false);
});
it('should rejoin all rooms on reconnection', async () => {
const client = new WebSocketClient('/ws', { reconnectDelay: 100 });
client.join('downloads');
client.join('progress');
client.connect();
await vi.runAllTimersAsync();
// Simulate disconnect and reconnect
let ws = MockWebSocket._lastInstance;
ws._simulateClose(1006);
await vi.runAllTimersAsync();
// Check that join messages were sent for both rooms
ws = MockWebSocket._lastInstance;
const sentMessages = [];
const originalSend = ws.send.bind(ws);
ws.send = (data) => {
sentMessages.push(data);
return originalSend(data);
};
// Trigger rejoin
client.rejoinRooms();
expect(sentMessages.some(msg => msg.includes('downloads'))).toBe(true);
expect(sentMessages.some(msg => msg.includes('progress'))).toBe(true);
});
});
describe('WebSocket Client - Error Handling', () => {
beforeEach(() => {
global.WebSocket = MockWebSocket;
vi.useFakeTimers();
global.window = {
location: {
protocol: 'http:',
host: 'localhost:8000'
}
};
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it('should emit error event on WebSocket error', async () => {
const client = new WebSocketClient('/ws');
const errorHandler = vi.fn();
client.on('error', errorHandler);
client.connect();
await vi.runAllTimersAsync();
const ws = MockWebSocket._lastInstance;
ws._simulateError(new Error('Connection failed'));
expect(errorHandler).toHaveBeenCalled();
});
it('should emit disconnect event on connection close', async () => {
const client = new WebSocketClient('/ws');
const disconnectHandler = vi.fn();
client.on('disconnect', disconnectHandler);
client.connect();
await vi.runAllTimersAsync();
const ws = MockWebSocket._lastInstance;
ws._simulateClose(1006, 'Connection lost');
expect(disconnectHandler).toHaveBeenCalledWith('Connection lost');
});
it('should set isConnected to false on close', async () => {
const client = new WebSocketClient('/ws');
client.connect();
await vi.runAllTimersAsync();
expect(client.isConnected).toBe(true);
const ws = MockWebSocket._lastInstance;
ws._simulateClose(1000);
await vi.runAllTimersAsync();
expect(client.isConnected).toBe(false);
});
});
describe('WebSocket Client - Connection State', () => {
beforeEach(() => {
global.WebSocket = MockWebSocket;
vi.useFakeTimers();
global.window = {
location: {
protocol: 'http:',
host: 'localhost:8000'
}
};
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it('should return false when not connected', () => {
const client = new WebSocketClient('/ws');
expect(client.connected()).toBe(false);
});
it('should return true when connected', async () => {
const client = new WebSocketClient('/ws');
client.connect();
await vi.runAllTimersAsync();
expect(client.connected()).toBe(true);
});
it('should return false after disconnection', async () => {
const client = new WebSocketClient('/ws');
client.connect();
await vi.runAllTimersAsync();
client.disconnect();
await vi.runAllTimersAsync();
expect(client.connected()).toBe(false);
});
});
describe('WebSocket Client - Socket.IO Compatibility', () => {
beforeEach(() => {
global.WebSocket = MockWebSocket;
vi.useFakeTimers();
global.window = {
location: {
protocol: 'http:',
host: 'localhost:8000'
}
};
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it('should create and connect client using io() function', async () => {
const client = globalThis.io('/ws');
await vi.runAllTimersAsync();
expect(client).toBeInstanceOf(WebSocketClient);
expect(client.isConnected).toBe(true);
});
it('should support Socket.IO-like event interface', async () => {
const client = globalThis.io('/ws');
const handler = vi.fn();
client.on('test', handler);
await vi.runAllTimersAsync();
client.emit('test', { data: 'value' });
expect(handler).toHaveBeenCalledWith({ data: 'value' });
});
});