import { WsClient } from '@/core/ws/client'; import type { WsEvent, WsConnectionState } from '@/core/ws/types'; jest.mock('@/core/auth/token-manager', () => ({ tokenManager: { getAccessToken: jest.fn().mockResolvedValue('test-token'), }, })); jest.mock('@/core/config', () => ({ config: { wsBaseUrl: 'ws://localhost:8000/', ws: { reconnectMaxRetries: 3, reconnectBaseDelayMs: 10, reconnectMaxDelayMs: 100, heartbeatIntervalMs: 60000, }, }, })); // Mock WebSocket class MockWebSocket { static OPEN = 1; static CLOSED = 3; static instances: MockWebSocket[] = []; readyState = MockWebSocket.OPEN; onopen: (() => void) | null = null; onmessage: ((event: { data: string }) => void) | null = null; onclose: (() => void) | null = null; onerror: (() => void) | null = null; sentMessages: string[] = []; constructor(public url: string) { MockWebSocket.instances.push(this); setTimeout(() => this.onopen?.(), 0); } send(data: string) { this.sentMessages.push(data); } close() { this.readyState = MockWebSocket.CLOSED; } simulateMessage(data: Record) { this.onmessage?.({ data: JSON.stringify(data) }); } } (global as Record).WebSocket = MockWebSocket; describe('WsClient', () => { afterEach(() => { jest.clearAllMocks(); MockWebSocket.instances = []; }); test('connects with token and conversation id in URL', async () => { const client = new WsClient('conv-123'); const states: WsConnectionState[] = []; client.onStateChange((s) => states.push(s)); await client.connect(); await new Promise((r) => setTimeout(r, 10)); expect(states).toContain('connecting'); expect(states).toContain('connected'); expect(MockWebSocket.instances[0]?.url).toBe( 'ws://localhost:8000/ws/conversation/conv-123?token=test-token', ); client.dispose(); }); test('maps server messages to domain events', async () => { const client = new WsClient('conv-123'); const events: WsEvent[] = []; client.onEvent((e) => events.push(e)); await client.connect(); await new Promise((r) => setTimeout(r, 10)); const ws = (client as unknown as { ws: MockWebSocket }).ws; ws.simulateMessage({ type: 'connect', conversation_id: 'conv-123', data: { status: 'connected' }, timestamp: '2026-01-01T00:00:00Z', }); ws.simulateMessage({ type: 'agent_response', conversation_id: 'conv-123', data: { text: 'Hello!', index: 0, total: 1 }, timestamp: '2026-01-01T00:00:00Z', }); expect(events).toHaveLength(2); expect(events[0]).toEqual({ kind: 'connected', conversationId: 'conv-123', }); expect(events[1]).toEqual({ kind: 'agent_response', conversationId: 'conv-123', text: 'Hello!', index: 0, total: 1, isTransition: undefined, segmentIndex: undefined, }); client.dispose(); }); test('sends text messages', async () => { const client = new WsClient('conv-123'); await client.connect(); await new Promise((r) => setTimeout(r, 10)); client.sendText('Hi there'); const ws = (client as unknown as { ws: MockWebSocket }).ws; expect(ws.sentMessages).toHaveLength(1); const sent = JSON.parse(ws.sentMessages[0]); expect(sent).toEqual({ type: 'text', conversation_id: 'conv-123', data: { text: 'Hi there' }, }); client.dispose(); }); test('sends text with tts_this_turn when requested', async () => { const client = new WsClient('conv-123'); await client.connect(); await new Promise((r) => setTimeout(r, 10)); client.sendText('Hello', { ttsThisTurn: true }); const ws = (client as unknown as { ws: MockWebSocket }).ws; expect(ws.sentMessages).toHaveLength(1); const sent = JSON.parse(ws.sentMessages[0]); expect(sent).toEqual({ type: 'text', conversation_id: 'conv-123', data: { text: 'Hello', tts_this_turn: true }, }); client.dispose(); }); test('ignores unknown message types without crashing', async () => { const client = new WsClient('conv-123'); const events: WsEvent[] = []; client.onEvent((e) => events.push(e)); await client.connect(); await new Promise((r) => setTimeout(r, 10)); const ws = (client as unknown as { ws: MockWebSocket }).ws; ws.simulateMessage({ type: 'unknown_type' as string, conversation_id: 'conv-123', data: {}, timestamp: '2026-01-01T00:00:00Z', }); // Unknown types are silently ignored — no events emitted expect(events).toHaveLength(0); client.dispose(); }); });