- 新增 BUG_TRACKER.md 记录已修复和开放的bug - 新增 TEST_EXECUTION_2026-04-30.md 自动化测试执行报告 - 新增 TEST_PROGRESS.md 测试进度跟踪文档 - 修复 Android SDK connectedCheck 内存不足问题 - 修复 Android sample-app CAMERA 权限 lint 失败 - 修复 Android UpdateSDK longVersionCode minSdk lint 失败 - 修复 RN Chat Demo Jest 无法解析本地 SDK 源码包 - 修复 Python Server SDK 回调消息解析与顶层导出错误 - 修复 Vue3 SDK package exports 条件顺序警告 - 修复 im-service 群消息不进入会话聚合列表问题 - 修复 im-service 对外时间字段单位不一致问题 - 修复 RN SDK 历史消息 upsert 丢失回推状态问题 - 修复 Android 黑名单操作静默失败问题 - 修复 AppSecret 调用无鉴权安全问题 - 修复 IM Token 无过期信息问题 - 修复 RN SDK 草稿同步服务端数据污染问题 - 修复 Vue3 SDK 撤回编辑后依赖 WS 刷新延迟问题 - 修复 im-service 消息摘要不支持媒体类型问题
352 行
10 KiB
TypeScript
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')
|
|
})
|
|
})
|