Files
2026-07-13 13:20:22 +08:00

273 lines
7.4 KiB
TypeScript

import { WebSocketService } from '@/src/services/websocket';
// --- Mock WebSocket ---
type MockWSInstance = {
url: string;
protocols: string | string[];
readyState: number;
onopen: ((ev: any) => void) | null;
onclose: ((ev: any) => void) | null;
onmessage: ((ev: any) => void) | null;
onerror: ((ev: any) => void) | null;
send: jest.Mock;
close: jest.Mock;
};
let mockWSInstances: MockWSInstance[] = [];
class MockWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
url: string;
protocols: string | string[];
readyState = MockWebSocket.CONNECTING;
onopen: ((ev: any) => void) | null = null;
onclose: ((ev: any) => void) | null = null;
onmessage: ((ev: any) => void) | null = null;
onerror: ((ev: any) => void) | null = null;
send = jest.fn();
close = jest.fn(() => {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.({ code: 1000, reason: '' });
});
constructor(url: string, protocols?: string | string[]) {
this.url = url;
this.protocols = protocols ?? '';
mockWSInstances.push(this as unknown as MockWSInstance);
}
}
// Assign static constants to prototype for readyState comparisons
Object.assign(MockWebSocket.prototype, {
CONNECTING: 0,
OPEN: 1,
CLOSING: 2,
CLOSED: 3,
});
(globalThis as any).WebSocket = MockWebSocket;
function latestWS(): MockWSInstance {
return mockWSInstances[mockWSInstances.length - 1];
}
function simulateOpen(ws: MockWSInstance) {
ws.readyState = MockWebSocket.OPEN;
ws.onopen?.({});
}
function simulateMessage(ws: MockWSInstance, data: object) {
ws.onmessage?.({ data: JSON.stringify(data) });
}
function simulateClose(ws: MockWSInstance, code = 1000) {
ws.readyState = MockWebSocket.CLOSED;
ws.onclose?.({ code, reason: '' });
}
describe('WebSocketService', () => {
let service: WebSocketService;
beforeEach(() => {
jest.useFakeTimers();
mockWSInstances = [];
service = new WebSocketService();
service.configure('localhost', '8080', 'test-token');
});
afterEach(() => {
service.disconnect();
jest.useRealTimers();
});
describe('connect', () => {
it('creates WebSocket with correct URL and token protocol', () => {
service.connect();
const ws = latestWS();
expect(ws.url).toBe('ws://localhost:8080');
expect(ws.protocols).toEqual(['test-token']);
});
it('transitions to connecting then connected on open', () => {
const states: string[] = [];
service.onStateChange((s) => states.push(s));
service.connect();
expect(service.state).toBe('connecting');
simulateOpen(latestWS());
expect(service.state).toBe('connected');
expect(states).toEqual(['connecting', 'connected']);
});
it('does not create duplicate connection if already open', () => {
service.connect();
simulateOpen(latestWS());
const count = mockWSInstances.length;
service.connect();
expect(mockWSInstances.length).toBe(count);
});
});
describe('message handling', () => {
it('routes messages to handler', () => {
const handler = jest.fn();
service.onMessage(handler);
service.connect();
simulateOpen(latestWS());
simulateMessage(latestWS(), { name: 'chat', data: { text: 'hi' } });
expect(handler).toHaveBeenCalledWith('chat', { text: 'hi' });
});
it('responds to ping with pong', () => {
service.connect();
const ws = latestWS();
simulateOpen(ws);
simulateMessage(ws, { name: 'ping', data: {} });
expect(ws.send).toHaveBeenCalledWith(
expect.stringContaining('"name":"pong"'),
);
});
it('does not route ping to message handler', () => {
const handler = jest.fn();
service.onMessage(handler);
service.connect();
simulateOpen(latestWS());
simulateMessage(latestWS(), { name: 'ping', data: {} });
expect(handler).not.toHaveBeenCalled();
});
});
describe('auth failure', () => {
it('sets auth_failed on close code 1008 and stops reconnecting', () => {
service.connect();
simulateOpen(latestWS());
const count = mockWSInstances.length;
simulateClose(latestWS(), 1008);
expect(service.state).toBe('auth_failed');
// Should not attempt reconnect
jest.advanceTimersByTime(10000);
expect(mockWSInstances.length).toBe(count);
});
it('sets auth_failed on auth-expired message', () => {
service.connect();
const ws = latestWS();
simulateOpen(ws);
// Override close to not trigger onclose automatically
ws.close = jest.fn();
simulateMessage(ws, { name: 'auth-expired', data: {} });
expect(service.state).toBe('auth_failed');
});
});
describe('reconnection', () => {
it('attempts reconnect on unexpected close', () => {
service.connect();
simulateOpen(latestWS());
const initialCount = mockWSInstances.length;
// Override close to avoid triggering onclose recursively
const ws = latestWS();
ws.close = jest.fn();
simulateClose(ws, 1006);
// First reconnect after 500ms
jest.advanceTimersByTime(500);
expect(mockWSInstances.length).toBe(initialCount + 1);
});
it('applies exponential backoff up to 8s cap', () => {
service.connect();
const ws1 = latestWS();
simulateOpen(ws1);
ws1.close = jest.fn();
simulateClose(ws1, 1006);
// 500ms → first reconnect
jest.advanceTimersByTime(500);
const ws2 = latestWS();
expect(mockWSInstances.length).toBe(2);
// Fail again → delay doubles to 1000ms (500*2 after first scheduleReconnect)
ws2.close = jest.fn();
simulateClose(ws2, 1006);
jest.advanceTimersByTime(999);
expect(mockWSInstances.length).toBe(2);
jest.advanceTimersByTime(1);
expect(mockWSInstances.length).toBe(3);
});
it('resets backoff delay on successful connection', () => {
service.connect();
const ws1 = latestWS();
simulateOpen(ws1);
ws1.close = jest.fn();
simulateClose(ws1, 1006);
jest.advanceTimersByTime(500);
const ws2 = latestWS();
simulateOpen(ws2); // Successful reconnect → resets delay
ws2.close = jest.fn();
simulateClose(ws2, 1006);
// Should be back to 500ms
jest.advanceTimersByTime(500);
expect(mockWSInstances.length).toBe(3);
});
});
describe('message queue', () => {
it('queues messages when disconnected and flushes on connect', () => {
service.connect();
const ws = latestWS();
// Send before open (still CONNECTING)
service.send('test', { value: 1 });
expect(ws.send).not.toHaveBeenCalled();
simulateOpen(ws);
expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ name: 'test', data: { value: 1 } }));
});
});
describe('disconnect', () => {
it('clears state and stops reconnection', () => {
service.connect();
simulateOpen(latestWS());
service.disconnect();
expect(service.state).toBe('disconnected');
jest.advanceTimersByTime(10000);
// No new connections after disconnect
expect(mockWSInstances.length).toBe(1);
});
});
describe('reconnect()', () => {
it('disconnects and reconnects with reset delay', () => {
service.connect();
simulateOpen(latestWS());
service.reconnect();
expect(mockWSInstances.length).toBe(2);
expect(service.state).toBe('connecting');
});
});
});