2026-04-30 16:54:10 +08:00
|
|
|
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<typeof useIm>
|
|
|
|
|
|
|
|
|
|
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,
|
2026-05-07 19:39:45 +08:00
|
|
|
appKey: 'ak_demo_chat',
|
2026-04-30 16:54:10 +08:00
|
|
|
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,
|
2026-05-07 19:39:45 +08:00
|
|
|
appKey: 'ak_demo_chat',
|
2026-04-30 16:54:10 +08:00
|
|
|
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([])
|
|
|
|
|
})
|
|
|
|
|
})
|