XuqmGroup-Vue3SDK/__tests__/unit/im/ImClient.test.ts

352 行
10 KiB
TypeScript

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<string, string>; 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<string, string> = {}
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')
})
})