import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { ImClient } from '../../../src/im/ImClient' import { init, setToken, setUserId } from '../../../src/core/sdk' function parseStompFrame(data: string): { command: string; headers: Record; body: string } | null { const terminator = data.indexOf('\x00') if (terminator < 0) return null const frame = data.substring(0, terminator) const splitIndex = frame.indexOf('\n\n') if (splitIndex < 0) return null const headerPart = frame.substring(0, splitIndex) const body = frame.substring(splitIndex + 2) const lines = headerPart.split('\n').filter((l) => l.trim() !== '') const command = lines[0]?.trim() || '' const headers: Record = {} for (let i = 1; i < lines.length; i++) { const line = lines[i] const idx = line.indexOf(':') if (idx > 0) headers[line.substring(0, idx).trim()] = line.substring(idx + 1).trim() } return { command, headers, body } } class MockWebSocket { static OPEN = 1 static CONNECTING = 0 static CLOSING = 2 static CLOSED = 3 static instances: MockWebSocket[] = [] url = '' readyState = 0 onopen: ((ev: Event) => void) | null = null onmessage: ((ev: MessageEvent) => void) | null = null onclose: ((ev: CloseEvent) => void) | null = null onerror: ((ev: Event) => void) | null = null sent: string[] = [] constructor(url: string) { this.url = url MockWebSocket.instances.push(this) } send(data: string) { this.sent.push(data) const frame = parseStompFrame(data) if (frame?.command === 'CONNECT') { setTimeout(() => { this.onmessage?.( new MessageEvent('message', { data: 'CONNECTED\nversion:1.2\nheart-beat:0,0\nuser-name:test_user\n\n\x00', }) ) }, 10) } } close() { this.readyState = 3 this.onclose?.(new CloseEvent('close', { code: 1000, reason: 'test' })) } } describe('im/ImClient', () => { let originalWebSocket: typeof WebSocket beforeEach(() => { originalWebSocket = globalThis.WebSocket globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket MockWebSocket.instances = [] init({ appKey: 'ak_demo_chat', appSecret: 'secret', debug: false, baseUrl: 'http://test', wsUrl: 'ws://test/ws' }) setToken('test_im_token') setUserId('user_a') vi.useFakeTimers({ shouldAdvanceTime: true }) }) afterEach(() => { globalThis.WebSocket = originalWebSocket vi.useRealTimers() }) function openWs(index = 0) { const ws = MockWebSocket.instances[index] ws.readyState = 1 ws.onopen?.(new Event('open')) } it('should connect with token in URL and Authorization header', () => { const client = new ImClient() client.connect() expect(MockWebSocket.instances.length).toBe(1) expect(MockWebSocket.instances[0].url).toContain('token=test_im_token') openWs() const ws = MockWebSocket.instances[0] expect(ws.sent.length).toBe(1) const frame = parseStompFrame(ws.sent[0]) expect(frame?.command).toBe('CONNECT') expect(frame?.headers['accept-version']).toBe('1.2') expect(frame?.headers['Authorization']).toBe('Bearer test_im_token') }) it('should emit connected event on STOMP CONNECTED', async () => { const client = new ImClient() const handler = vi.fn() client.on('connected', handler) client.connect() openWs() await vi.advanceTimersByTimeAsync(20) expect(handler).toHaveBeenCalled() }) it('should auto-subscribe /user/queue/messages after CONNECTED', async () => { const client = new ImClient() client.connect() openWs() await vi.advanceTimersByTimeAsync(20) const ws = MockWebSocket.instances[0] // First frame is CONNECT, second should be SUBSCRIBE expect(ws.sent.length).toBeGreaterThanOrEqual(2) const subFrame = parseStompFrame(ws.sent[1]) expect(subFrame?.command).toBe('SUBSCRIBE') expect(subFrame?.headers['destination']).toBe('/user/queue/messages') }) it('should send STOMP SEND frame via WebSocket', async () => { const client = new ImClient() client.connect() openWs() await vi.advanceTimersByTimeAsync(20) const msg = client.send({ toId: 'user_b', chatType: 'SINGLE', msgType: 'TEXT', content: 'hello', }) expect(msg.status).toBe('SENDING') expect(msg.content).toBe('hello') expect(msg.fromId).toBe('user_a') const ws = MockWebSocket.instances[0] const sendFrame = parseStompFrame(ws.sent[ws.sent.length - 1]) expect(sendFrame?.command).toBe('SEND') expect(sendFrame?.headers['destination']).toBe('/app/chat.send') const body = JSON.parse(sendFrame?.body || '{}') expect(body.content).toBe('hello') expect(body.toId).toBe('user_b') expect(body.appId).toBe('ak_demo_chat') }) it('should return FAILED when WebSocket is not open', () => { const client = new ImClient() // Do NOT call connect(), so ws is null const msg = client.send({ toId: 'user_b', chatType: 'SINGLE', msgType: 'TEXT', content: 'hello', }) expect(msg.status).toBe('FAILED') }) it('should emit message event on STOMP MESSAGE frame', async () => { const client = new ImClient() const handler = vi.fn() client.on('message', handler) client.connect() openWs() await vi.advanceTimersByTimeAsync(20) const ws = MockWebSocket.instances[0] const payload = JSON.stringify({ id: 'msg_1', appId: 'ak_demo_chat', fromUserId: 'user_b', toId: 'user_a', chatType: 'SINGLE', msgType: 'TEXT', content: 'hi', status: 'SENT', createdAt: new Date().toISOString(), }) ws.onmessage?.( new MessageEvent('message', { data: `MESSAGE\ndestination:/user/queue/messages\ncontent-type:application/json\n\n${payload}\x00`, }) ) expect(handler).toHaveBeenCalledOnce() const received = handler.mock.calls[0][0] expect(received.content).toBe('hi') expect(received.fromId).toBe('user_b') }) it('should emit read event when message status is READ', async () => { const client = new ImClient() const readHandler = vi.fn() const msgHandler = vi.fn() client.on('read', readHandler) client.on('message', msgHandler) client.connect() openWs() await vi.advanceTimersByTimeAsync(20) const ws = MockWebSocket.instances[0] const payload = JSON.stringify({ id: 'msg_1', appId: 'ak_demo_chat', fromUserId: 'user_b', toId: 'user_a', chatType: 'SINGLE', msgType: 'TEXT', content: 'hi', status: 'READ', createdAt: new Date().toISOString(), }) ws.onmessage?.( new MessageEvent('message', { data: `MESSAGE\ndestination:/user/queue/messages\n\n${payload}\x00`, }) ) expect(readHandler).toHaveBeenCalledOnce() expect(msgHandler).toHaveBeenCalledOnce() }) it('should emit revoke event on REVOKE payload', async () => { const client = new ImClient() const handler = vi.fn() client.on('revoke', handler) client.connect() openWs() await vi.advanceTimersByTimeAsync(20) const ws = MockWebSocket.instances[0] const payload = JSON.stringify({ id: 'msg_1', appId: 'ak_demo_chat', fromUserId: 'user_b', toId: 'user_a', chatType: 'SINGLE', msgType: 'REVOKED', content: '', status: 'REVOKED', createdAt: new Date().toISOString(), }) ws.onmessage?.( new MessageEvent('message', { data: `MESSAGE\ndestination:/user/queue/messages\n\n${payload}\x00`, }) ) expect(handler).toHaveBeenCalledOnce() }) it('should schedule reconnect on close', async () => { const client = new ImClient() client.connect() openWs() await vi.advanceTimersByTimeAsync(20) MockWebSocket.instances[0].close() // Should schedule reconnect after 3s expect(MockWebSocket.instances.length).toBe(1) await vi.advanceTimersByTimeAsync(3100) expect(MockWebSocket.instances.length).toBe(2) }) it('should stop reconnect after disconnect', async () => { const client = new ImClient() client.connect() openWs() await vi.advanceTimersByTimeAsync(20) client.disconnect() await vi.advanceTimersByTimeAsync(10000) // No new WebSocket instances should be created after disconnect expect(MockWebSocket.instances.length).toBe(1) }) it('should support off() to remove listener', async () => { const client = new ImClient() const handler = vi.fn() client.on('connected', handler) client.off('connected', handler) client.connect() openWs() await vi.advanceTimersByTimeAsync(20) expect(handler).not.toHaveBeenCalled() }) it('should generate messageId when not provided', async () => { const client = new ImClient() client.connect() openWs() await vi.advanceTimersByTimeAsync(20) const msg = client.send({ toId: 'user_b', chatType: 'SINGLE', msgType: 'TEXT', content: 'hello', }) expect(msg.id).toBeTruthy() expect(typeof msg.id).toBe('string') }) it('should use provided messageId', async () => { const client = new ImClient() client.connect() openWs() await vi.advanceTimersByTimeAsync(20) const msg = client.send({ messageId: 'custom_id_123', toId: 'user_b', chatType: 'SINGLE', msgType: 'TEXT', content: 'hello', }) expect(msg.id).toBe('custom_id_123') }) it('should throw when revoking while disconnected', () => { const client = new ImClient() expect(() => client.revoke('msg_1')).toThrow('WebSocket not connected') }) it('should send revoke STOMP frame when connected', async () => { const client = new ImClient() client.connect() openWs() await vi.advanceTimersByTimeAsync(20) client.revoke('msg_1') const ws = MockWebSocket.instances[0] const revokeFrame = parseStompFrame(ws.sent[ws.sent.length - 1]) expect(revokeFrame?.command).toBe('SEND') expect(revokeFrame?.headers['destination']).toBe('/app/chat.revoke') const body = JSON.parse(revokeFrame?.body || '{}') expect(body.messageId).toBe('msg_1') }) })