@csntgao/uni-base 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prettierignore +4 -0
- package/.prettierrc.json +6 -0
- package/AGENTS.md +519 -0
- package/CLAUDE.md +512 -0
- package/README.md +734 -0
- package/docs//346/225/260/345/255/227/345/221/230/345/267/245/345/256/214/346/225/264/346/212/275/345/261/211/346/216/245/345/205/245/350/257/264/346/230/216.md +110 -0
- package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/215/225/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +79 -0
- package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/244/232/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +93 -0
- package/docs//350/264/246/346/210/267/344/270/216/347/231/273/345/275/225/347/273/204/344/273/266/345/212/250/346/200/201/346/216/245/345/205/245/350/257/264/346/230/216.md +201 -0
- package/eslint.config.js +30 -0
- package/examples/vanilla/index.html +33 -0
- package/examples/vanilla/main.js +247 -0
- package/examples/vanilla/package.json +15 -0
- package/examples/vanilla/public/runtime-config.js +10 -0
- package/examples/vanilla/style.css +99 -0
- package/package.json +31 -0
- package/packages/core/package.json +30 -0
- package/packages/core/src/client.ts +1132 -0
- package/packages/core/src/customer-support-client.ts +825 -0
- package/packages/core/src/customer-support-types.ts +123 -0
- package/packages/core/src/index.ts +79 -0
- package/packages/core/src/notification-center-client.ts +462 -0
- package/packages/core/src/notification-center-types.ts +154 -0
- package/packages/core/src/protocol.ts +237 -0
- package/packages/core/src/types.ts +170 -0
- package/packages/core/tests/client.test.ts +760 -0
- package/packages/core/tests/customer-support-client.test.ts +576 -0
- package/packages/core/tests/fake-websocket.ts +55 -0
- package/packages/core/tests/notification-center-client.test.ts +467 -0
- package/packages/core/tests/protocol.test.ts +124 -0
- package/packages/core/tsconfig.json +7 -0
- package/packages/core/tsup.config.ts +11 -0
- package/packages/web-components/package.json +35 -0
- package/packages/web-components/scripts/verify-runtime-build.mjs +111 -0
- package/packages/web-components/src/account-context.ts +1540 -0
- package/packages/web-components/src/application-switcher-transport.ts +243 -0
- package/packages/web-components/src/application-switcher.ts +1103 -0
- package/packages/web-components/src/chat.ts +1754 -0
- package/packages/web-components/src/customer-support-chat.ts +862 -0
- package/packages/web-components/src/customer-support-transport.ts +269 -0
- package/packages/web-components/src/index.ts +256 -0
- package/packages/web-components/src/notification-center.ts +1066 -0
- package/packages/web-components/src/notification-popover.ts +615 -0
- package/packages/web-components/src/organization-multi-employee-selector.ts +1564 -0
- package/packages/web-components/src/organization-onboarding-transport.ts +54 -0
- package/packages/web-components/src/organization-onboarding.ts +1216 -0
- package/packages/web-components/src/organization-selector.ts +534 -0
- package/packages/web-components/src/organization-single-employee-selector.ts +114 -0
- package/packages/web-components/src/prototype-icons.ts +242 -0
- package/packages/web-components/src/safe-image-url.ts +29 -0
- package/packages/web-components/src/safe-markdown.ts +192 -0
- package/packages/web-components/src/uniplat-base-account-context-transport.ts +625 -0
- package/packages/web-components/src/uniplat-base-application-switcher-transport.ts +418 -0
- package/packages/web-components/src/uniplat-base-application-ticket.ts +254 -0
- package/packages/web-components/src/uniplat-base-identity-ticket.ts +202 -0
- package/packages/web-components/src/uniplat-base-notification-center-transport.ts +378 -0
- package/packages/web-components/src/uniplat-base-organization-multi-employee-selector-transport.ts +394 -0
- package/packages/web-components/src/uniplat-base-organization-onboarding-transport.ts +183 -0
- package/packages/web-components/src/uniplat-base-organization-single-employee-selector-transport.ts +14 -0
- package/packages/web-components/src/user-login-transport.ts +563 -0
- package/packages/web-components/src/user-login.ts +2027 -0
- package/packages/web-components/src/user-menu.ts +880 -0
- package/packages/web-components/tests/account-context.test.ts +798 -0
- package/packages/web-components/tests/application-switcher.test.ts +562 -0
- package/packages/web-components/tests/chat.test.ts +1130 -0
- package/packages/web-components/tests/customer-support-chat.test.ts +428 -0
- package/packages/web-components/tests/customer-support-transport.test.ts +79 -0
- package/packages/web-components/tests/notification-center-transport.test.ts +271 -0
- package/packages/web-components/tests/notification-center.test.ts +429 -0
- package/packages/web-components/tests/notification-popover.test.ts +283 -0
- package/packages/web-components/tests/organization-multi-employee-selector-transport.test.ts +332 -0
- package/packages/web-components/tests/organization-multi-employee-selector.test.ts +225 -0
- package/packages/web-components/tests/organization-onboarding.test.ts +260 -0
- package/packages/web-components/tests/organization-selector.test.ts +127 -0
- package/packages/web-components/tests/organization-single-employee-selector.test.ts +145 -0
- package/packages/web-components/tests/uniplat-base-account-context-transport.test.ts +496 -0
- package/packages/web-components/tests/uniplat-base-application-switcher-transport.test.ts +598 -0
- package/packages/web-components/tests/uniplat-base-application-ticket.test.ts +233 -0
- package/packages/web-components/tests/uniplat-base-identity-ticket.test.ts +226 -0
- package/packages/web-components/tests/uniplat-base-organization-onboarding-transport.test.ts +227 -0
- package/packages/web-components/tests/user-login-transport.test.ts +443 -0
- package/packages/web-components/tests/user-login.test.ts +626 -0
- package/packages/web-components/tests/user-menu.test.ts +271 -0
- package/packages/web-components/tsconfig.json +8 -0
- package/packages/web-components/tsup.config.ts +12 -0
- package/packages/web-components/tsup.runtime.config.ts +37 -0
- package/pnpm-workspace.yaml +6 -0
- package/tsconfig.base.json +21 -0
- package/vitest.config.ts +20 -0
- package//344/272/244/346/216/245/346/226/207/346/241/243.md +254 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
CustomerSupportClient,
|
|
4
|
+
type CustomerSupportMessage,
|
|
5
|
+
type CustomerSupportSession,
|
|
6
|
+
type CustomerSupportTransport,
|
|
7
|
+
} from '../src'
|
|
8
|
+
import { FakeWebSocket } from './fake-websocket'
|
|
9
|
+
|
|
10
|
+
const session: CustomerSupportSession = {
|
|
11
|
+
id: 'support-1',
|
|
12
|
+
sessionNo: 'CS-1',
|
|
13
|
+
title: '企业服务顾问',
|
|
14
|
+
status: 'active',
|
|
15
|
+
statusText: '服务中',
|
|
16
|
+
primaryAgent: null,
|
|
17
|
+
lastMessage: '您好,我是人工顾问。',
|
|
18
|
+
unreadCount: 0,
|
|
19
|
+
openedAt: '2026-08-18T08:00:00.000Z',
|
|
20
|
+
updatedAt: '2026-08-18T08:00:00.000Z',
|
|
21
|
+
version: '1',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const history: CustomerSupportMessage[] = [
|
|
25
|
+
{
|
|
26
|
+
id: 'support-message-1',
|
|
27
|
+
sessionId: 'support-1',
|
|
28
|
+
sequence: '1',
|
|
29
|
+
clientMessageId: '',
|
|
30
|
+
senderType: 'agent',
|
|
31
|
+
senderName: '王悦',
|
|
32
|
+
content: '您好,我是人工顾问。',
|
|
33
|
+
createdAt: '2026-08-18T08:00:00.000Z',
|
|
34
|
+
},
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
function createTransport(): CustomerSupportTransport {
|
|
38
|
+
return {
|
|
39
|
+
getAccessToken: vi.fn(async () => 'customer.support.jwt'),
|
|
40
|
+
listSessions: vi.fn(async () => [session]),
|
|
41
|
+
createSession: vi.fn(async () => ({ session })),
|
|
42
|
+
listMessages: vi.fn(async () => ({
|
|
43
|
+
messages: history,
|
|
44
|
+
latestSequence: '1',
|
|
45
|
+
sessionVersion: '1',
|
|
46
|
+
})),
|
|
47
|
+
sendMessage: vi.fn(async (_sessionId, clientMessageId, content) => ({
|
|
48
|
+
id: 'support-message-2',
|
|
49
|
+
sessionId: 'support-1',
|
|
50
|
+
sequence: '2',
|
|
51
|
+
clientMessageId,
|
|
52
|
+
senderType: 'customer' as const,
|
|
53
|
+
senderName: '',
|
|
54
|
+
content,
|
|
55
|
+
createdAt: '2026-08-18T08:01:00.000Z',
|
|
56
|
+
})),
|
|
57
|
+
markRead: vi.fn(async () => undefined),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function deferred<T>() {
|
|
62
|
+
let resolve!: (value: T | PromiseLike<T>) => void
|
|
63
|
+
let reject!: (reason?: unknown) => void
|
|
64
|
+
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
|
65
|
+
resolve = promiseResolve
|
|
66
|
+
reject = promiseReject
|
|
67
|
+
})
|
|
68
|
+
return { promise, resolve, reject }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('CustomerSupportClient', () => {
|
|
72
|
+
beforeEach(() => vi.useFakeTimers())
|
|
73
|
+
afterEach(() => vi.useRealTimers())
|
|
74
|
+
|
|
75
|
+
it('loads the current support session and authenticates the dedicated realtime channel', async () => {
|
|
76
|
+
const sockets: FakeWebSocket[] = []
|
|
77
|
+
const transport = createTransport()
|
|
78
|
+
const client = new CustomerSupportClient({
|
|
79
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
80
|
+
transport,
|
|
81
|
+
requestIdFactory: () => 'b95e15b8-55df-4d84-9fbe-c00dc45ae3a7',
|
|
82
|
+
minimumHeartbeatMs: 1,
|
|
83
|
+
webSocketFactory: (url) => {
|
|
84
|
+
const socket = new FakeWebSocket(url)
|
|
85
|
+
sockets.push(socket)
|
|
86
|
+
return socket.asWebSocket()
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
await client.start()
|
|
91
|
+
expect(client.session?.id).toBe('support-1')
|
|
92
|
+
expect(client.messages).toEqual(history)
|
|
93
|
+
expect(transport.markRead).toHaveBeenCalledWith('support-1', '1')
|
|
94
|
+
sockets[0]?.open()
|
|
95
|
+
await Promise.resolve()
|
|
96
|
+
sockets[0]?.receive({
|
|
97
|
+
type: 'realtime_ready',
|
|
98
|
+
channels: { customer_support: { available: true } },
|
|
99
|
+
heartbeat_interval_seconds: 25,
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
expect(sockets[0]?.sent.map((frame) => JSON.parse(frame))).toEqual([
|
|
103
|
+
{
|
|
104
|
+
type: 'authenticate',
|
|
105
|
+
protocol_version: 1,
|
|
106
|
+
access_token: 'customer.support.jwt',
|
|
107
|
+
requested_channels: ['customer_support'],
|
|
108
|
+
cursors: {},
|
|
109
|
+
},
|
|
110
|
+
])
|
|
111
|
+
expect(client.connectionState).toBe('connected')
|
|
112
|
+
expect(sockets[0]?.url).not.toContain('customer.support.jwt')
|
|
113
|
+
await Promise.resolve()
|
|
114
|
+
expect(transport.markRead).toHaveBeenCalledTimes(1)
|
|
115
|
+
for (let index = 0; index < 5; index += 1) {
|
|
116
|
+
sockets[0]?.receive({
|
|
117
|
+
type: 'realtime_event',
|
|
118
|
+
channel: 'customer_support',
|
|
119
|
+
event: {
|
|
120
|
+
type: 'support_event',
|
|
121
|
+
event_type: 'support_read_state_changed',
|
|
122
|
+
session_id: 'support-1',
|
|
123
|
+
payload: { last_read_sequence: '1' },
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
await Promise.resolve()
|
|
128
|
+
expect(transport.listMessages).toHaveBeenCalledTimes(1)
|
|
129
|
+
expect(transport.markRead).toHaveBeenCalledTimes(1)
|
|
130
|
+
vi.mocked(transport.listMessages).mockResolvedValue({
|
|
131
|
+
messages: [
|
|
132
|
+
...history,
|
|
133
|
+
{
|
|
134
|
+
id: 'support-message-2',
|
|
135
|
+
sessionId: 'support-1',
|
|
136
|
+
sequence: '2',
|
|
137
|
+
clientMessageId: '',
|
|
138
|
+
senderType: 'agent',
|
|
139
|
+
senderName: '王悦',
|
|
140
|
+
content: '新的实时回复',
|
|
141
|
+
createdAt: '2026-08-18T08:01:00.000Z',
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
latestSequence: '2',
|
|
145
|
+
sessionVersion: '2',
|
|
146
|
+
})
|
|
147
|
+
sockets[0]?.receive({
|
|
148
|
+
type: 'realtime_event',
|
|
149
|
+
channel: 'customer_support',
|
|
150
|
+
event: {
|
|
151
|
+
type: 'support_event',
|
|
152
|
+
event_type: 'support_message_created',
|
|
153
|
+
session_id: 'support-1',
|
|
154
|
+
payload: {},
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
await vi.waitFor(() => expect(client.messages).toHaveLength(2))
|
|
158
|
+
await vi.waitFor(() => expect(transport.markRead).toHaveBeenCalledTimes(2))
|
|
159
|
+
expect(transport.markRead).toHaveBeenLastCalledWith('support-1', '2')
|
|
160
|
+
expect(client.messages[1]?.content).toBe('新的实时回复')
|
|
161
|
+
sockets[0]?.receive({ type: 'channel_resync_required', channel: 'customer_support' })
|
|
162
|
+
await vi.waitFor(() => expect(transport.listMessages).toHaveBeenCalledTimes(3))
|
|
163
|
+
expect(transport.markRead).toHaveBeenCalledTimes(2)
|
|
164
|
+
client.destroy()
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('refreshes session projection without reloading history for non-message events', async () => {
|
|
168
|
+
const sockets: FakeWebSocket[] = []
|
|
169
|
+
const transport = {
|
|
170
|
+
...createTransport(),
|
|
171
|
+
getSession: vi.fn(async () => ({ ...session, status: 'closed' as const, version: '2' })),
|
|
172
|
+
}
|
|
173
|
+
const client = new CustomerSupportClient({
|
|
174
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
175
|
+
transport,
|
|
176
|
+
webSocketFactory: (url) => {
|
|
177
|
+
const socket = new FakeWebSocket(url)
|
|
178
|
+
sockets.push(socket)
|
|
179
|
+
return socket.asWebSocket()
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
await client.start()
|
|
183
|
+
await Promise.resolve()
|
|
184
|
+
|
|
185
|
+
sockets[0]?.receive({
|
|
186
|
+
type: 'realtime_event',
|
|
187
|
+
channel: 'customer_support',
|
|
188
|
+
event: {
|
|
189
|
+
type: 'support_event',
|
|
190
|
+
event_type: 'support_session_closed',
|
|
191
|
+
session_id: 'support-1',
|
|
192
|
+
payload: {},
|
|
193
|
+
},
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
await vi.waitFor(() => expect(transport.getSession).toHaveBeenCalledTimes(1))
|
|
197
|
+
expect(client.session?.status).toBe('closed')
|
|
198
|
+
expect(transport.listMessages).toHaveBeenCalledTimes(1)
|
|
199
|
+
expect(transport.markRead).toHaveBeenCalledTimes(1)
|
|
200
|
+
client.destroy()
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('refreshes the session list for events from another support conversation', async () => {
|
|
204
|
+
const sockets: FakeWebSocket[] = []
|
|
205
|
+
const otherSession: CustomerSupportSession = {
|
|
206
|
+
...session,
|
|
207
|
+
id: 'support-2',
|
|
208
|
+
sessionNo: 'CS-2',
|
|
209
|
+
title: '其他客服会话',
|
|
210
|
+
lastMessage: '旧消息',
|
|
211
|
+
unreadCount: 0,
|
|
212
|
+
}
|
|
213
|
+
const updatedOtherSession = {
|
|
214
|
+
...otherSession,
|
|
215
|
+
lastMessage: '其他会话的新回复',
|
|
216
|
+
unreadCount: 1,
|
|
217
|
+
version: '2',
|
|
218
|
+
}
|
|
219
|
+
const transport = createTransport()
|
|
220
|
+
vi.mocked(transport.listSessions)
|
|
221
|
+
.mockResolvedValueOnce([session, otherSession])
|
|
222
|
+
.mockResolvedValue([session, updatedOtherSession])
|
|
223
|
+
const client = new CustomerSupportClient({
|
|
224
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
225
|
+
transport,
|
|
226
|
+
webSocketFactory: (url) => {
|
|
227
|
+
const socket = new FakeWebSocket(url)
|
|
228
|
+
sockets.push(socket)
|
|
229
|
+
return socket.asWebSocket()
|
|
230
|
+
},
|
|
231
|
+
})
|
|
232
|
+
const sessionLists: Array<readonly CustomerSupportSession[]> = []
|
|
233
|
+
client.on('sessions', ({ sessions }) => sessionLists.push(sessions))
|
|
234
|
+
await client.start()
|
|
235
|
+
|
|
236
|
+
sockets[0]?.receive({
|
|
237
|
+
type: 'realtime_event',
|
|
238
|
+
channel: 'customer_support',
|
|
239
|
+
event: {
|
|
240
|
+
type: 'support_event',
|
|
241
|
+
event_type: 'support_message_created',
|
|
242
|
+
session_id: 'support-2',
|
|
243
|
+
payload: {},
|
|
244
|
+
},
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
await vi.waitFor(() => expect(transport.listSessions).toHaveBeenCalledTimes(2))
|
|
248
|
+
expect(sessionLists.at(-1)?.find((candidate) => candidate.id === 'support-2')).toEqual(
|
|
249
|
+
updatedOtherSession,
|
|
250
|
+
)
|
|
251
|
+
expect(transport.listMessages).toHaveBeenCalledTimes(1)
|
|
252
|
+
expect(transport.markRead).toHaveBeenCalledTimes(1)
|
|
253
|
+
expect(client.messages).toEqual(history)
|
|
254
|
+
client.destroy()
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('uses read-state events to refresh unread projections without reloading history', async () => {
|
|
258
|
+
const sockets: FakeWebSocket[] = []
|
|
259
|
+
const otherSession: CustomerSupportSession = {
|
|
260
|
+
...session,
|
|
261
|
+
id: 'support-2',
|
|
262
|
+
sessionNo: 'CS-2',
|
|
263
|
+
unreadCount: 2,
|
|
264
|
+
}
|
|
265
|
+
const transport = createTransport()
|
|
266
|
+
vi.mocked(transport.listSessions)
|
|
267
|
+
.mockResolvedValueOnce([session, otherSession])
|
|
268
|
+
.mockResolvedValue([session, { ...otherSession, unreadCount: 0 }])
|
|
269
|
+
const client = new CustomerSupportClient({
|
|
270
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
271
|
+
transport,
|
|
272
|
+
webSocketFactory: (url) => {
|
|
273
|
+
const socket = new FakeWebSocket(url)
|
|
274
|
+
sockets.push(socket)
|
|
275
|
+
return socket.asWebSocket()
|
|
276
|
+
},
|
|
277
|
+
})
|
|
278
|
+
await client.start()
|
|
279
|
+
|
|
280
|
+
sockets[0]?.receive({
|
|
281
|
+
type: 'realtime_event',
|
|
282
|
+
channel: 'customer_support',
|
|
283
|
+
event: {
|
|
284
|
+
type: 'support_event',
|
|
285
|
+
event_type: 'support_read_state_changed',
|
|
286
|
+
session_id: 'support-2',
|
|
287
|
+
payload: { last_read_sequence: '2' },
|
|
288
|
+
},
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
await vi.waitFor(() => expect(transport.listSessions).toHaveBeenCalledTimes(2))
|
|
292
|
+
expect(transport.listMessages).toHaveBeenCalledTimes(1)
|
|
293
|
+
expect(transport.markRead).toHaveBeenCalledTimes(1)
|
|
294
|
+
client.destroy()
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('deduplicates in-flight cursors and submits only the newest pending cursor', async () => {
|
|
298
|
+
const sockets: FakeWebSocket[] = []
|
|
299
|
+
const firstRead = deferred<void>()
|
|
300
|
+
const transport = createTransport()
|
|
301
|
+
const markRead = vi.fn<(sessionId: string, cursor: string) => Promise<void>>(async () => {})
|
|
302
|
+
transport.markRead = markRead
|
|
303
|
+
vi.mocked(transport.listMessages).mockResolvedValue({
|
|
304
|
+
messages: history,
|
|
305
|
+
latestSequence: '5',
|
|
306
|
+
sessionVersion: '1',
|
|
307
|
+
})
|
|
308
|
+
markRead.mockImplementationOnce(() => firstRead.promise).mockResolvedValue(undefined)
|
|
309
|
+
const client = new CustomerSupportClient({
|
|
310
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
311
|
+
transport,
|
|
312
|
+
webSocketFactory: (url) => {
|
|
313
|
+
const socket = new FakeWebSocket(url)
|
|
314
|
+
sockets.push(socket)
|
|
315
|
+
return socket.asWebSocket()
|
|
316
|
+
},
|
|
317
|
+
})
|
|
318
|
+
await client.start()
|
|
319
|
+
expect(transport.markRead).toHaveBeenCalledTimes(1)
|
|
320
|
+
|
|
321
|
+
for (const [index, latestSequence] of ['5', '4', '6', '6'].entries()) {
|
|
322
|
+
vi.mocked(transport.listMessages).mockResolvedValueOnce({
|
|
323
|
+
messages: history,
|
|
324
|
+
latestSequence,
|
|
325
|
+
sessionVersion: '1',
|
|
326
|
+
})
|
|
327
|
+
sockets[0]?.receive({ type: 'channel_resync_required', channel: 'customer_support' })
|
|
328
|
+
await vi.waitFor(() => expect(transport.listMessages).toHaveBeenCalledTimes(index + 2))
|
|
329
|
+
}
|
|
330
|
+
expect(transport.markRead).toHaveBeenCalledTimes(1)
|
|
331
|
+
|
|
332
|
+
firstRead.resolve()
|
|
333
|
+
await vi.waitFor(() => expect(transport.markRead).toHaveBeenCalledTimes(2))
|
|
334
|
+
expect(transport.markRead).toHaveBeenNthCalledWith(1, 'support-1', '5')
|
|
335
|
+
expect(transport.markRead).toHaveBeenNthCalledWith(2, 'support-1', '6')
|
|
336
|
+
client.destroy()
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('keeps acknowledged read cursors isolated across session switches', async () => {
|
|
340
|
+
const secondSession: CustomerSupportSession = {
|
|
341
|
+
...session,
|
|
342
|
+
id: 'support-2',
|
|
343
|
+
sessionNo: 'CS-2',
|
|
344
|
+
version: '3',
|
|
345
|
+
}
|
|
346
|
+
const transport = createTransport()
|
|
347
|
+
vi.mocked(transport.listSessions).mockResolvedValue([session, secondSession])
|
|
348
|
+
vi.mocked(transport.listMessages).mockImplementation(async (sessionId) => ({
|
|
349
|
+
messages: [],
|
|
350
|
+
latestSequence: sessionId === 'support-1' ? '2' : '7',
|
|
351
|
+
sessionVersion: sessionId === 'support-1' ? '1' : '3',
|
|
352
|
+
}))
|
|
353
|
+
const client = new CustomerSupportClient({
|
|
354
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
355
|
+
transport,
|
|
356
|
+
webSocketFactory: (url) => new FakeWebSocket(url).asWebSocket(),
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
await client.start()
|
|
360
|
+
await vi.waitFor(() => expect(transport.markRead).toHaveBeenCalledTimes(1))
|
|
361
|
+
await client.bindSession('support-2')
|
|
362
|
+
await vi.waitFor(() => expect(transport.markRead).toHaveBeenCalledTimes(2))
|
|
363
|
+
await client.bindSession('support-1')
|
|
364
|
+
await Promise.resolve()
|
|
365
|
+
|
|
366
|
+
expect(transport.markRead).toHaveBeenCalledTimes(2)
|
|
367
|
+
expect(transport.markRead).toHaveBeenNthCalledWith(1, 'support-1', '2')
|
|
368
|
+
expect(transport.markRead).toHaveBeenNthCalledWith(2, 'support-2', '7')
|
|
369
|
+
client.destroy()
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('retries a failed cursor only after a later message refresh', async () => {
|
|
373
|
+
const sockets: FakeWebSocket[] = []
|
|
374
|
+
const transport = createTransport()
|
|
375
|
+
const markRead = vi.fn<(sessionId: string, cursor: string) => Promise<void>>(async () => {})
|
|
376
|
+
transport.markRead = markRead
|
|
377
|
+
vi.mocked(transport.listMessages).mockResolvedValue({
|
|
378
|
+
messages: history,
|
|
379
|
+
latestSequence: '3',
|
|
380
|
+
sessionVersion: '1',
|
|
381
|
+
})
|
|
382
|
+
markRead.mockRejectedValueOnce(new Error('temporary failure')).mockResolvedValue(undefined)
|
|
383
|
+
const client = new CustomerSupportClient({
|
|
384
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
385
|
+
transport,
|
|
386
|
+
webSocketFactory: (url) => {
|
|
387
|
+
const socket = new FakeWebSocket(url)
|
|
388
|
+
sockets.push(socket)
|
|
389
|
+
return socket.asWebSocket()
|
|
390
|
+
},
|
|
391
|
+
})
|
|
392
|
+
await client.start()
|
|
393
|
+
await Promise.resolve()
|
|
394
|
+
await Promise.resolve()
|
|
395
|
+
expect(transport.markRead).toHaveBeenCalledTimes(1)
|
|
396
|
+
|
|
397
|
+
for (let index = 0; index < 5; index += 1) {
|
|
398
|
+
sockets[0]?.receive({
|
|
399
|
+
type: 'realtime_event',
|
|
400
|
+
channel: 'customer_support',
|
|
401
|
+
event: {
|
|
402
|
+
type: 'support_event',
|
|
403
|
+
event_type: 'support_read_state_changed',
|
|
404
|
+
session_id: 'support-1',
|
|
405
|
+
payload: {},
|
|
406
|
+
},
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
await Promise.resolve()
|
|
410
|
+
expect(transport.markRead).toHaveBeenCalledTimes(1)
|
|
411
|
+
|
|
412
|
+
sockets[0]?.receive({
|
|
413
|
+
type: 'realtime_event',
|
|
414
|
+
channel: 'customer_support',
|
|
415
|
+
event: {
|
|
416
|
+
type: 'support_event',
|
|
417
|
+
event_type: 'support_message_created',
|
|
418
|
+
session_id: 'support-1',
|
|
419
|
+
payload: {},
|
|
420
|
+
},
|
|
421
|
+
})
|
|
422
|
+
await vi.waitFor(() => expect(transport.markRead).toHaveBeenCalledTimes(2))
|
|
423
|
+
expect(transport.markRead).toHaveBeenLastCalledWith('support-1', '3')
|
|
424
|
+
client.destroy()
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
it('creates the first support session atomically and emits one local message', async () => {
|
|
428
|
+
const sockets: FakeWebSocket[] = []
|
|
429
|
+
const transport = createTransport()
|
|
430
|
+
vi.mocked(transport.listSessions).mockResolvedValue([])
|
|
431
|
+
vi.mocked(transport.createSession).mockResolvedValue({
|
|
432
|
+
session,
|
|
433
|
+
initialMessage: {
|
|
434
|
+
...history[0]!,
|
|
435
|
+
senderType: 'customer',
|
|
436
|
+
content: '需要人工协助',
|
|
437
|
+
},
|
|
438
|
+
})
|
|
439
|
+
const client = new CustomerSupportClient({
|
|
440
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
441
|
+
transport,
|
|
442
|
+
requestIdFactory: () => 'b95e15b8-55df-4d84-9fbe-c00dc45ae3a7',
|
|
443
|
+
webSocketFactory: (url) => {
|
|
444
|
+
const socket = new FakeWebSocket(url)
|
|
445
|
+
sockets.push(socket)
|
|
446
|
+
return socket.asWebSocket()
|
|
447
|
+
},
|
|
448
|
+
})
|
|
449
|
+
const received: string[] = []
|
|
450
|
+
client.on('message', ({ message }) => received.push(message.id))
|
|
451
|
+
await client.start()
|
|
452
|
+
const message = await client.sendMessage('需要人工协助')
|
|
453
|
+
|
|
454
|
+
expect(transport.createSession).toHaveBeenCalledWith({
|
|
455
|
+
clientRequestId: 'b95e15b8-55df-4d84-9fbe-c00dc45ae3a7',
|
|
456
|
+
title: '需要人工协助',
|
|
457
|
+
initialMessage: '需要人工协助',
|
|
458
|
+
})
|
|
459
|
+
expect(message.content).toBe('需要人工协助')
|
|
460
|
+
expect(received).toEqual(['support-message-1'])
|
|
461
|
+
client.destroy()
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
it('does not restore an empty waiting session before the customer sends a message', async () => {
|
|
465
|
+
const emptyWaitingSession: CustomerSupportSession = {
|
|
466
|
+
...session,
|
|
467
|
+
status: 'waiting',
|
|
468
|
+
statusText: '等待接入',
|
|
469
|
+
lastMessage: '',
|
|
470
|
+
}
|
|
471
|
+
const initialMessage: CustomerSupportMessage = {
|
|
472
|
+
...history[0]!,
|
|
473
|
+
senderType: 'customer',
|
|
474
|
+
senderName: '',
|
|
475
|
+
content: '首次咨询',
|
|
476
|
+
}
|
|
477
|
+
const transport = createTransport()
|
|
478
|
+
vi.mocked(transport.listSessions).mockResolvedValue([emptyWaitingSession])
|
|
479
|
+
vi.mocked(transport.createSession).mockResolvedValue({
|
|
480
|
+
session: { ...emptyWaitingSession, lastMessage: '首次咨询' },
|
|
481
|
+
initialMessage,
|
|
482
|
+
})
|
|
483
|
+
const client = new CustomerSupportClient({
|
|
484
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
485
|
+
transport,
|
|
486
|
+
requestIdFactory: () => 'b95e15b8-55df-4d84-9fbe-c00dc45ae3a7',
|
|
487
|
+
webSocketFactory: (url) => new FakeWebSocket(url).asWebSocket(),
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
await client.start()
|
|
491
|
+
expect(client.session).toBeNull()
|
|
492
|
+
expect(transport.listMessages).not.toHaveBeenCalled()
|
|
493
|
+
expect(transport.createSession).not.toHaveBeenCalled()
|
|
494
|
+
|
|
495
|
+
const message = await client.sendMessage('首次咨询')
|
|
496
|
+
expect(transport.createSession).toHaveBeenCalledWith({
|
|
497
|
+
clientRequestId: 'b95e15b8-55df-4d84-9fbe-c00dc45ae3a7',
|
|
498
|
+
title: '首次咨询',
|
|
499
|
+
initialMessage: '首次咨询',
|
|
500
|
+
})
|
|
501
|
+
expect(message).toEqual(initialMessage)
|
|
502
|
+
expect(client.session?.status).toBe('waiting')
|
|
503
|
+
client.destroy()
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
it('refreshes an expired support credential once and then stops retrying', async () => {
|
|
507
|
+
const sockets: FakeWebSocket[] = []
|
|
508
|
+
const transport = createTransport()
|
|
509
|
+
const client = new CustomerSupportClient({
|
|
510
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
511
|
+
transport,
|
|
512
|
+
reconnect: { initialDelayMs: 1_000, maximumDelayMs: 1_000, jitter: 0 },
|
|
513
|
+
webSocketFactory: (url) => {
|
|
514
|
+
const socket = new FakeWebSocket(url)
|
|
515
|
+
sockets.push(socket)
|
|
516
|
+
return socket.asWebSocket()
|
|
517
|
+
},
|
|
518
|
+
})
|
|
519
|
+
const errors: Array<{ code: string; message: string; recoverable: boolean }> = []
|
|
520
|
+
client.on('error', (detail) => errors.push(detail))
|
|
521
|
+
await client.start()
|
|
522
|
+
|
|
523
|
+
sockets[0]?.open()
|
|
524
|
+
await Promise.resolve()
|
|
525
|
+
sockets[0]?.receive({ type: 'authentication_error', code: 'UNAUTHORIZED' })
|
|
526
|
+
sockets[0]?.serverClose(4401)
|
|
527
|
+
await vi.advanceTimersByTimeAsync(1_000)
|
|
528
|
+
sockets[1]?.open()
|
|
529
|
+
await Promise.resolve()
|
|
530
|
+
sockets[1]?.receive({ type: 'authentication_error', code: 'UNAUTHORIZED' })
|
|
531
|
+
sockets[1]?.serverClose(4401)
|
|
532
|
+
|
|
533
|
+
expect(sockets).toHaveLength(2)
|
|
534
|
+
expect(transport.getAccessToken).toHaveBeenCalledTimes(2)
|
|
535
|
+
expect(client.connectionState).toBe('disconnected')
|
|
536
|
+
expect(vi.getTimerCount()).toBe(0)
|
|
537
|
+
expect(errors.at(-1)).toEqual({
|
|
538
|
+
code: 'AUTHENTICATION_FAILED',
|
|
539
|
+
message: '人工客服身份验证失败,请重新登录后再试。',
|
|
540
|
+
recoverable: false,
|
|
541
|
+
})
|
|
542
|
+
client.destroy()
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
it('does not label access denial as an expired credential or retry it', async () => {
|
|
546
|
+
const sockets: FakeWebSocket[] = []
|
|
547
|
+
const transport = createTransport()
|
|
548
|
+
const client = new CustomerSupportClient({
|
|
549
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
550
|
+
transport,
|
|
551
|
+
webSocketFactory: (url) => {
|
|
552
|
+
const socket = new FakeWebSocket(url)
|
|
553
|
+
sockets.push(socket)
|
|
554
|
+
return socket.asWebSocket()
|
|
555
|
+
},
|
|
556
|
+
})
|
|
557
|
+
const errors: Array<{ code: string; message: string; recoverable: boolean }> = []
|
|
558
|
+
client.on('error', (detail) => errors.push(detail))
|
|
559
|
+
await client.start()
|
|
560
|
+
sockets[0]?.open()
|
|
561
|
+
await Promise.resolve()
|
|
562
|
+
sockets[0]?.receive({ type: 'authentication_error', code: 'ACCESS_DENIED' })
|
|
563
|
+
sockets[0]?.serverClose(4403)
|
|
564
|
+
|
|
565
|
+
expect(client.connectionState).toBe('disconnected')
|
|
566
|
+
expect(vi.getTimerCount()).toBe(0)
|
|
567
|
+
expect(errors).toEqual([
|
|
568
|
+
{
|
|
569
|
+
code: 'AUTHENTICATION_FAILED',
|
|
570
|
+
message: '当前身份无权使用人工客服。',
|
|
571
|
+
recoverable: false,
|
|
572
|
+
},
|
|
573
|
+
])
|
|
574
|
+
client.destroy()
|
|
575
|
+
})
|
|
576
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export class FakeWebSocket {
|
|
2
|
+
readonly url: string
|
|
3
|
+
readyState = 0
|
|
4
|
+
sent: string[] = []
|
|
5
|
+
closeCount = 0
|
|
6
|
+
onopen: ((event: Event) => void) | null = null
|
|
7
|
+
onmessage: ((event: MessageEvent) => void) | null = null
|
|
8
|
+
onerror: ((event: Event) => void) | null = null
|
|
9
|
+
onclose: ((event: CloseEvent) => void) | null = null
|
|
10
|
+
|
|
11
|
+
constructor(url: string) {
|
|
12
|
+
this.url = url
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
open(): void {
|
|
16
|
+
this.readyState = 1
|
|
17
|
+
this.onopen?.(new Event('open'))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
receive(payload: unknown): void {
|
|
21
|
+
this.onmessage?.(new MessageEvent('message', { data: JSON.stringify(payload) }))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
fail(): void {
|
|
25
|
+
this.onerror?.(new Event('error'))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
serverClose(code = 1006): void {
|
|
29
|
+
this.readyState = 3
|
|
30
|
+
this.onclose?.(createCloseEvent(code))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
send(payload: string): void {
|
|
34
|
+
if (this.readyState !== 1) throw new Error('socket is not open')
|
|
35
|
+
this.sent.push(payload)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
close(code = 1000): void {
|
|
39
|
+
if (this.readyState >= 2) return
|
|
40
|
+
this.closeCount += 1
|
|
41
|
+
this.readyState = 3
|
|
42
|
+
this.onclose?.(createCloseEvent(code))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
asWebSocket(): WebSocket {
|
|
46
|
+
return this as unknown as WebSocket
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createCloseEvent(code: number): CloseEvent {
|
|
51
|
+
if (typeof CloseEvent === 'function') return new CloseEvent('close', { code })
|
|
52
|
+
const event = new Event('close') as CloseEvent
|
|
53
|
+
Object.defineProperty(event, 'code', { value: code })
|
|
54
|
+
return event
|
|
55
|
+
}
|