import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { nextTick } from 'vue' import { useIm } from '../../../src/im/useIm' import { init, setToken, setUserId } from '../../../src/core/sdk' 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) if (data.startsWith('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/useIm', () => { let originalWebSocket: typeof WebSocket let composable: ReturnType 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_token') setUserId('user_a') vi.useFakeTimers({ shouldAdvanceTime: true }) composable = useIm() }) afterEach(() => { composable.disconnect() globalThis.WebSocket = originalWebSocket vi.useRealTimers() }) function openWs(index = 0) { const ws = MockWebSocket.instances[index] ws.readyState = 1 ws.onopen?.(new Event('open')) } function emitStompMessage(ws: MockWebSocket, payload: object) { ws.onmessage?.( new MessageEvent('message', { data: `MESSAGE\ndestination:/user/queue/messages\n\n${JSON.stringify(payload)}\x00`, }) ) } it('should initialize with disconnected state', () => { expect(composable.connected.value).toBe(false) expect(composable.messages.value).toEqual([]) expect(composable.conversations.value).toEqual([]) expect(composable.error.value).toBeNull() }) it('should set connected to true after connect', async () => { globalThis.fetch = vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ code: 200, data: [], message: 'ok' }), } as unknown as Response) composable.connect() openWs() await vi.advanceTimersByTimeAsync(20) await nextTick() expect(composable.connected.value).toBe(true) }) it('should add message to messages list on send', async () => { composable.connect() openWs() await vi.advanceTimersByTimeAsync(20) const msg = composable.send({ toId: 'user_b', chatType: 'SINGLE', msgType: 'TEXT', content: 'hello', }) await nextTick() expect(composable.messages.value).toHaveLength(1) expect(composable.messages.value[0].content).toBe('hello') expect(composable.messages.value[0].id).toBe(msg.id) }) it('should upsert message when receiving same id', async () => { composable.connect() openWs() await vi.advanceTimersByTimeAsync(20) const msg = composable.send({ toId: 'user_b', chatType: 'SINGLE', msgType: 'TEXT', content: 'hello', }) await nextTick() const ws = MockWebSocket.instances[0] emitStompMessage(ws, { id: msg.id, appId: 'ak_demo_chat', fromUserId: 'user_b', toId: 'user_a', chatType: 'SINGLE', msgType: 'TEXT', content: 'updated', status: 'READ', createdAt: new Date().toISOString(), }) await nextTick() expect(composable.messages.value).toHaveLength(1) expect(composable.messages.value[0].content).toBe('updated') expect(composable.messages.value[0].status).toBe('READ') }) it('should mark message as revoked', async () => { composable.connect() openWs() await vi.advanceTimersByTimeAsync(20) const msg = composable.send({ toId: 'user_b', chatType: 'SINGLE', msgType: 'TEXT', content: 'hello', }) await nextTick() const ws = MockWebSocket.instances[0] emitStompMessage(ws, { id: msg.id, appId: 'ak_demo_chat', fromUserId: 'user_b', toId: 'user_a', chatType: 'SINGLE', msgType: 'REVOKED', content: '', status: 'REVOKED', createdAt: new Date().toISOString(), }) await nextTick() expect(composable.messages.value[0].status).toBe('REVOKED') expect(composable.messages.value[0].msgType).toBe('REVOKED') expect(composable.messages.value[0].revoked).toBe(true) expect(composable.messages.value[0].content).toBe('') }) it('should mark conversation read locally', async () => { composable.conversations.value = [ { targetId: 'user_b', chatType: 'SINGLE', lastMsgTime: Date.now(), unreadCount: 5, isMuted: false, isPinned: false, }, ] composable.setConversationRead('user_b', 'SINGLE') await nextTick() expect(composable.conversations.value[0].unreadCount).toBe(0) }) it('should sort conversations by pinned then lastMsgTime', async () => { globalThis.fetch = vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ code: 200, data: [ { targetId: 'a', chatType: 'SINGLE', lastMsgTime: 1000, unreadCount: 0, isMuted: false, isPinned: false }, { targetId: 'b', chatType: 'SINGLE', lastMsgTime: 500, unreadCount: 0, isMuted: false, isPinned: true }, { targetId: 'c', chatType: 'SINGLE', lastMsgTime: 2000, unreadCount: 0, isMuted: false, isPinned: false }, ], message: 'ok', }), } as unknown as Response) composable.connect() openWs() await vi.advanceTimersByTimeAsync(20) await nextTick() expect(composable.conversations.value[0].targetId).toBe('b') // pinned first expect(composable.conversations.value[1].targetId).toBe('c') // newer expect(composable.conversations.value[2].targetId).toBe('a') // older }) it('should throw when sending without connection', () => { expect(() => composable.send({ toId: 'user_b', chatType: 'SINGLE', msgType: 'TEXT', content: 'hello', }) ).toThrow('IM client not connected') }) it('should disconnect and reset state', async () => { composable.connect() openWs() await vi.advanceTimersByTimeAsync(20) await nextTick() expect(composable.connected.value).toBe(true) composable.disconnect() await nextTick() expect(composable.connected.value).toBe(false) expect(composable.messages.value).toEqual([]) }) })