@luxexchange/websocket 1.0.1 → 1.0.3
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/.depcheckrc +15 -0
- package/.eslintrc.js +20 -0
- package/README.md +275 -0
- package/package.json +2 -3
- package/project.json +2 -6
- package/src/client/__tests__/MockWebSocket.ts +54 -0
- package/src/client/__tests__/createWebSocketClient.integration.test.ts +919 -0
- package/src/client/__tests__/testUtils.ts +113 -0
- package/src/client/createWebSocketClient.ts +281 -0
- package/src/index.ts +22 -0
- package/src/store/createZustandConnectionStore.test.ts +162 -0
- package/src/store/createZustandConnectionStore.ts +74 -0
- package/src/store/types.ts +17 -0
- package/src/subscriptions/SubscriptionManager.test.ts +740 -0
- package/src/subscriptions/SubscriptionManager.ts +283 -0
- package/src/subscriptions/types.ts +21 -0
- package/src/types.ts +88 -0
- package/src/utils/backoff.test.ts +48 -0
- package/src/utils/backoff.ts +15 -0
- package/tsconfig.json +20 -4
- package/tsconfig.lint.json +8 -0
- package/vitest.config.ts +17 -0
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
import type { TestMessage } from '@luxexchange/websocket/src/client/__tests__/testUtils'
|
|
2
|
+
import { connectViaSubscribe, createTestClient } from '@luxexchange/websocket/src/client/__tests__/testUtils'
|
|
3
|
+
import type { ConnectionStatus } from '@luxexchange/websocket/src/types'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
describe('createWebSocketClient integration', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.useFakeTimers()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.useRealTimers()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
describe('lazy connection lifecycle', () => {
|
|
16
|
+
it('first subscribe triggers connection: status transitions to connecting then connected', () => {
|
|
17
|
+
const { client, mockSocket } = createTestClient()
|
|
18
|
+
const statusChanges: ConnectionStatus[] = []
|
|
19
|
+
|
|
20
|
+
client.onStatusChange((s) => statusChanges.push(s))
|
|
21
|
+
|
|
22
|
+
// Subscribe triggers lazy connect
|
|
23
|
+
client.subscribe({
|
|
24
|
+
channel: 'prices',
|
|
25
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
26
|
+
onMessage: vi.fn(),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
expect(statusChanges).toEqual(['connecting'])
|
|
30
|
+
expect(client.getConnectionStatus()).toBe('connecting')
|
|
31
|
+
|
|
32
|
+
mockSocket.simulateOpen()
|
|
33
|
+
|
|
34
|
+
expect(statusChanges).toEqual(['connecting', 'connected'])
|
|
35
|
+
expect(client.getConnectionStatus()).toBe('connected')
|
|
36
|
+
expect(client.isConnected()).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('last unsubscribe triggers disconnect: clears all state and sets status to disconnected', async () => {
|
|
40
|
+
const { client, mockSocket } = createTestClient()
|
|
41
|
+
const statusChanges: ConnectionStatus[] = []
|
|
42
|
+
|
|
43
|
+
client.onStatusChange((s) => statusChanges.push(s))
|
|
44
|
+
|
|
45
|
+
const unsub = client.subscribe({
|
|
46
|
+
channel: 'prices',
|
|
47
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
48
|
+
onMessage: vi.fn(),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
mockSocket.simulateOpen()
|
|
52
|
+
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-123' })
|
|
53
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
54
|
+
|
|
55
|
+
unsub()
|
|
56
|
+
await vi.advanceTimersByTimeAsync(2000)
|
|
57
|
+
|
|
58
|
+
// After last unsubscribe + debounce window, should be disconnected
|
|
59
|
+
expect(client.getConnectionStatus()).toBe('disconnected')
|
|
60
|
+
expect(client.isConnected()).toBe(false)
|
|
61
|
+
expect(client.getConnectionId()).toBe(null)
|
|
62
|
+
expect(statusChanges).toContain('disconnected')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('second subscribe to same params does not reconnect', () => {
|
|
66
|
+
const { client, mockSocket } = createTestClient()
|
|
67
|
+
const statusChanges: ConnectionStatus[] = []
|
|
68
|
+
|
|
69
|
+
client.onStatusChange((s) => statusChanges.push(s))
|
|
70
|
+
|
|
71
|
+
client.subscribe({
|
|
72
|
+
channel: 'prices',
|
|
73
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
74
|
+
onMessage: vi.fn(),
|
|
75
|
+
})
|
|
76
|
+
mockSocket.simulateOpen()
|
|
77
|
+
|
|
78
|
+
// Second subscribe with same key — should not create new connection
|
|
79
|
+
client.subscribe({
|
|
80
|
+
channel: 'prices',
|
|
81
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
82
|
+
onMessage: vi.fn(),
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
expect(statusChanges).toEqual(['connecting', 'connected'])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('connection established message stores connectionId and fires callback', () => {
|
|
89
|
+
const { client, mockSocket } = createTestClient()
|
|
90
|
+
const connectionIds: string[] = []
|
|
91
|
+
|
|
92
|
+
client.onConnectionEstablished((id) => connectionIds.push(id))
|
|
93
|
+
|
|
94
|
+
client.subscribe({
|
|
95
|
+
channel: 'prices',
|
|
96
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
97
|
+
onMessage: vi.fn(),
|
|
98
|
+
})
|
|
99
|
+
mockSocket.simulateOpen()
|
|
100
|
+
|
|
101
|
+
expect(client.getConnectionId()).toBe(null)
|
|
102
|
+
|
|
103
|
+
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-123' })
|
|
104
|
+
|
|
105
|
+
expect(client.getConnectionId()).toBe('conn-123')
|
|
106
|
+
expect(connectionIds).toEqual(['conn-123'])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('disconnect does not produce spurious reconnecting status', async () => {
|
|
110
|
+
const { client, mockSocket } = createTestClient()
|
|
111
|
+
const statusChanges: ConnectionStatus[] = []
|
|
112
|
+
|
|
113
|
+
client.onStatusChange((s) => statusChanges.push(s))
|
|
114
|
+
|
|
115
|
+
const unsub = client.subscribe({
|
|
116
|
+
channel: 'prices',
|
|
117
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
118
|
+
onMessage: vi.fn(),
|
|
119
|
+
})
|
|
120
|
+
mockSocket.simulateOpen()
|
|
121
|
+
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-123' })
|
|
122
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
123
|
+
|
|
124
|
+
// Unsubscribing the last subscriber triggers debounced disconnect
|
|
125
|
+
// wasConnected is set to false BEFORE socket.close(), preventing spurious 'reconnecting'
|
|
126
|
+
unsub()
|
|
127
|
+
await vi.advanceTimersByTimeAsync(2000)
|
|
128
|
+
|
|
129
|
+
// Should go straight to disconnected without reconnecting
|
|
130
|
+
expect(statusChanges).toEqual(['connecting', 'connected', 'disconnected'])
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('subscription flow', () => {
|
|
135
|
+
it('subscribe() calls handler via subscribeBatch with connectionId after microtask flush', async () => {
|
|
136
|
+
const { client, mockSocket, handler } = createTestClient()
|
|
137
|
+
|
|
138
|
+
connectViaSubscribe({ client, mockSocket })
|
|
139
|
+
|
|
140
|
+
client.subscribe({
|
|
141
|
+
channel: 'prices',
|
|
142
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
143
|
+
onMessage: vi.fn(),
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
147
|
+
|
|
148
|
+
// The connectViaSubscribe helper creates one subscription, so subscribeBatch is called
|
|
149
|
+
// for resubscribeAll after connectionId, then our new subscribe
|
|
150
|
+
expect(handler.subscribeBatch).toHaveBeenCalledWith(
|
|
151
|
+
'conn-123',
|
|
152
|
+
expect.arrayContaining([{ channel: 'prices', id: 'token-1' }]),
|
|
153
|
+
)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('subscribe() before connection is established queues subs until connected', async () => {
|
|
157
|
+
const { client, mockSocket, handler } = createTestClient()
|
|
158
|
+
|
|
159
|
+
// Subscribe before socket is open — triggers connection
|
|
160
|
+
client.subscribe({
|
|
161
|
+
channel: 'prices',
|
|
162
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
163
|
+
onMessage: vi.fn(),
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
167
|
+
|
|
168
|
+
// No connectionId yet, so no REST calls
|
|
169
|
+
expect(handler.subscribeBatch).not.toHaveBeenCalled()
|
|
170
|
+
|
|
171
|
+
// Now connection establishes
|
|
172
|
+
mockSocket.simulateOpen()
|
|
173
|
+
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-123' })
|
|
174
|
+
|
|
175
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
176
|
+
|
|
177
|
+
// resubscribeAll sends the queued subscription
|
|
178
|
+
expect(handler.subscribeBatch).toHaveBeenCalledWith('conn-123', [{ channel: 'prices', id: 'token-1' }])
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('multiple subscribers to same params calls handler once (reference counting)', async () => {
|
|
182
|
+
const { client, mockSocket, handler } = createTestClient()
|
|
183
|
+
|
|
184
|
+
connectViaSubscribe({ client, mockSocket })
|
|
185
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
186
|
+
handler.subscribeBatch.mockClear()
|
|
187
|
+
|
|
188
|
+
client.subscribe({
|
|
189
|
+
channel: 'prices',
|
|
190
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
191
|
+
onMessage: vi.fn(),
|
|
192
|
+
})
|
|
193
|
+
client.subscribe({
|
|
194
|
+
channel: 'prices',
|
|
195
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
196
|
+
onMessage: vi.fn(),
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
200
|
+
|
|
201
|
+
expect(handler.subscribeBatch).toHaveBeenCalledTimes(1)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('unsubscribe last subscriber calls handler.unsubscribeBatch', async () => {
|
|
205
|
+
const { client, mockSocket, handler } = createTestClient()
|
|
206
|
+
|
|
207
|
+
connectViaSubscribe({ client, mockSocket })
|
|
208
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
209
|
+
handler.unsubscribeBatch.mockClear()
|
|
210
|
+
|
|
211
|
+
const unsub = client.subscribe({
|
|
212
|
+
channel: 'prices',
|
|
213
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
214
|
+
onMessage: vi.fn(),
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
218
|
+
unsub()
|
|
219
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
220
|
+
|
|
221
|
+
expect(handler.unsubscribeBatch).toHaveBeenCalledWith(
|
|
222
|
+
'conn-123',
|
|
223
|
+
expect.arrayContaining([{ channel: 'prices', id: 'token-1' }]),
|
|
224
|
+
)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('unsubscribe with remaining subscribers does NOT call handler.unsubscribe', async () => {
|
|
228
|
+
const { client, mockSocket, handler } = createTestClient()
|
|
229
|
+
|
|
230
|
+
connectViaSubscribe({ client, mockSocket })
|
|
231
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
232
|
+
handler.unsubscribeBatch.mockClear()
|
|
233
|
+
handler.unsubscribe.mockClear()
|
|
234
|
+
|
|
235
|
+
const unsub1 = client.subscribe({
|
|
236
|
+
channel: 'prices',
|
|
237
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
238
|
+
onMessage: vi.fn(),
|
|
239
|
+
})
|
|
240
|
+
client.subscribe({
|
|
241
|
+
channel: 'prices',
|
|
242
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
243
|
+
onMessage: vi.fn(),
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
247
|
+
unsub1()
|
|
248
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
249
|
+
|
|
250
|
+
expect(handler.unsubscribeBatch).not.toHaveBeenCalled()
|
|
251
|
+
expect(handler.unsubscribe).not.toHaveBeenCalled()
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
describe('message routing', () => {
|
|
256
|
+
it('message received is parsed and routed to correct callback', async () => {
|
|
257
|
+
const { client, mockSocket } = createTestClient()
|
|
258
|
+
const messages: TestMessage[] = []
|
|
259
|
+
|
|
260
|
+
connectViaSubscribe({ client, mockSocket })
|
|
261
|
+
|
|
262
|
+
client.subscribe({
|
|
263
|
+
channel: 'prices',
|
|
264
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
265
|
+
onMessage: (m) => messages.push(m),
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
269
|
+
|
|
270
|
+
mockSocket.simulateMessage({
|
|
271
|
+
channel: 'prices',
|
|
272
|
+
key: 'prices:token-1',
|
|
273
|
+
data: { data: 'price-update', price: 3000 },
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
expect(messages).toEqual([{ data: 'price-update', price: 3000 }])
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('message for unknown subscription is ignored', async () => {
|
|
280
|
+
const { client, mockSocket } = createTestClient()
|
|
281
|
+
const messages: TestMessage[] = []
|
|
282
|
+
|
|
283
|
+
connectViaSubscribe({ client, mockSocket })
|
|
284
|
+
|
|
285
|
+
client.subscribe({
|
|
286
|
+
channel: 'prices',
|
|
287
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
288
|
+
onMessage: (m) => messages.push(m),
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
292
|
+
|
|
293
|
+
// Message for different subscription key
|
|
294
|
+
mockSocket.simulateMessage({
|
|
295
|
+
channel: 'prices',
|
|
296
|
+
key: 'prices:token-2',
|
|
297
|
+
data: { data: 'unknown' },
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
expect(messages).toEqual([])
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('message to multiple subscribers invokes all callbacks', async () => {
|
|
304
|
+
const { client, mockSocket } = createTestClient()
|
|
305
|
+
const messages1: TestMessage[] = []
|
|
306
|
+
const messages2: TestMessage[] = []
|
|
307
|
+
|
|
308
|
+
connectViaSubscribe({ client, mockSocket })
|
|
309
|
+
|
|
310
|
+
client.subscribe({
|
|
311
|
+
channel: 'prices',
|
|
312
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
313
|
+
onMessage: (m) => messages1.push(m),
|
|
314
|
+
})
|
|
315
|
+
client.subscribe({
|
|
316
|
+
channel: 'prices',
|
|
317
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
318
|
+
onMessage: (m) => messages2.push(m),
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
322
|
+
|
|
323
|
+
mockSocket.simulateMessage({
|
|
324
|
+
channel: 'prices',
|
|
325
|
+
key: 'prices:token-1',
|
|
326
|
+
data: { data: 'price-update' },
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
expect(messages1).toEqual([{ data: 'price-update' }])
|
|
330
|
+
expect(messages2).toEqual([{ data: 'price-update' }])
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('malformed message calls onError and does not crash', () => {
|
|
334
|
+
const onError = vi.fn()
|
|
335
|
+
const { client, mockSocket } = createTestClient({ onError })
|
|
336
|
+
|
|
337
|
+
connectViaSubscribe({ client, mockSocket })
|
|
338
|
+
|
|
339
|
+
// Simulate a message that will fail JSON.parse
|
|
340
|
+
const handlers = (mockSocket as unknown as { listeners: Map<string, Set<(e: unknown) => void>> }).listeners.get(
|
|
341
|
+
'message',
|
|
342
|
+
)
|
|
343
|
+
if (handlers) {
|
|
344
|
+
for (const handler of handlers) {
|
|
345
|
+
handler({ data: 'not valid json' })
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
expect(onError).toHaveBeenCalled()
|
|
350
|
+
})
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
describe('reconnection', () => {
|
|
354
|
+
it('socket closes after connected sets status to reconnecting', () => {
|
|
355
|
+
const { client, mockSocket } = createTestClient()
|
|
356
|
+
const statusChanges: ConnectionStatus[] = []
|
|
357
|
+
|
|
358
|
+
client.onStatusChange((s) => statusChanges.push(s))
|
|
359
|
+
|
|
360
|
+
// Subscribe triggers lazy connect
|
|
361
|
+
client.subscribe({
|
|
362
|
+
channel: 'prices',
|
|
363
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
364
|
+
onMessage: vi.fn(),
|
|
365
|
+
})
|
|
366
|
+
mockSocket.simulateOpen()
|
|
367
|
+
|
|
368
|
+
mockSocket.simulateClose()
|
|
369
|
+
|
|
370
|
+
expect(statusChanges).toContain('reconnecting')
|
|
371
|
+
expect(client.getConnectionStatus()).toBe('reconnecting')
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('socket closes before fully connected sets status to disconnected', () => {
|
|
375
|
+
const { client, mockSocket } = createTestClient()
|
|
376
|
+
const statusChanges: ConnectionStatus[] = []
|
|
377
|
+
|
|
378
|
+
client.onStatusChange((s) => statusChanges.push(s))
|
|
379
|
+
|
|
380
|
+
// Subscribe triggers lazy connect
|
|
381
|
+
client.subscribe({
|
|
382
|
+
channel: 'prices',
|
|
383
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
384
|
+
onMessage: vi.fn(),
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
// Don't simulate open - close before connecting
|
|
388
|
+
mockSocket.simulateClose()
|
|
389
|
+
|
|
390
|
+
expect(statusChanges).toContain('disconnected')
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('resubscribes all after reconnection with new connectionId', async () => {
|
|
394
|
+
const { client, mockSocket, handler } = createTestClient()
|
|
395
|
+
|
|
396
|
+
connectViaSubscribe({ client, mockSocket, connectionId: 'conn-1' })
|
|
397
|
+
|
|
398
|
+
client.subscribe({
|
|
399
|
+
channel: 'prices',
|
|
400
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
401
|
+
onMessage: vi.fn(),
|
|
402
|
+
})
|
|
403
|
+
client.subscribe({
|
|
404
|
+
channel: 'events',
|
|
405
|
+
params: { channel: 'events', id: 'event-1' },
|
|
406
|
+
onMessage: vi.fn(),
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
410
|
+
handler.subscribeBatch.mockClear()
|
|
411
|
+
|
|
412
|
+
// Simulate reconnect
|
|
413
|
+
mockSocket.simulateClose()
|
|
414
|
+
mockSocket.simulateOpen()
|
|
415
|
+
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-2' })
|
|
416
|
+
|
|
417
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
418
|
+
|
|
419
|
+
// Resubscription happens with the new connectionId via subscribeBatch
|
|
420
|
+
expect(handler.subscribeBatch).toHaveBeenCalledWith(
|
|
421
|
+
'conn-2',
|
|
422
|
+
expect.arrayContaining([
|
|
423
|
+
{ channel: 'prices', id: 'token-1' },
|
|
424
|
+
{ channel: 'events', id: 'event-1' },
|
|
425
|
+
]),
|
|
426
|
+
)
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('connection established message updates connectionId', () => {
|
|
430
|
+
const { client, mockSocket } = createTestClient()
|
|
431
|
+
|
|
432
|
+
connectViaSubscribe({ client, mockSocket, connectionId: 'conn-1' })
|
|
433
|
+
|
|
434
|
+
expect(client.getConnectionId()).toBe('conn-1')
|
|
435
|
+
|
|
436
|
+
// Simulate reconnect with new connectionId
|
|
437
|
+
mockSocket.simulateClose()
|
|
438
|
+
mockSocket.simulateOpen()
|
|
439
|
+
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-2' })
|
|
440
|
+
|
|
441
|
+
expect(client.getConnectionId()).toBe('conn-2')
|
|
442
|
+
})
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
describe('early unsubscribe', () => {
|
|
446
|
+
it('subscribe + immediate unsubscribe in same microtask produces net-zero API calls', async () => {
|
|
447
|
+
const { client, mockSocket, handler } = createTestClient()
|
|
448
|
+
|
|
449
|
+
connectViaSubscribe({ client, mockSocket })
|
|
450
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
451
|
+
handler.subscribeBatch.mockClear()
|
|
452
|
+
handler.subscribe.mockClear()
|
|
453
|
+
handler.unsubscribeBatch.mockClear()
|
|
454
|
+
handler.unsubscribe.mockClear()
|
|
455
|
+
|
|
456
|
+
// Subscribe and immediately unsubscribe in same microtask
|
|
457
|
+
const unsub = client.subscribe({
|
|
458
|
+
channel: 'prices',
|
|
459
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
460
|
+
onMessage: vi.fn(),
|
|
461
|
+
})
|
|
462
|
+
unsub()
|
|
463
|
+
|
|
464
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
465
|
+
|
|
466
|
+
// Net-zero: the pending subscribe and unsubscribe for the same key cancel out
|
|
467
|
+
expect(handler.subscribeBatch).not.toHaveBeenCalled()
|
|
468
|
+
expect(handler.subscribe).not.toHaveBeenCalled()
|
|
469
|
+
// Note: unsubscribeBatch may be called for the __connect helper key if it was the last subscriber
|
|
470
|
+
// But for our 'prices:token-1' key, no unsubscribe should occur since it was never subscribed
|
|
471
|
+
})
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
describe('error handling', () => {
|
|
475
|
+
it('handler.subscribe error calls onError', async () => {
|
|
476
|
+
const onError = vi.fn()
|
|
477
|
+
const subscribeError = new Error('Subscribe failed')
|
|
478
|
+
|
|
479
|
+
const { client, mockSocket } = createTestClient({
|
|
480
|
+
onError,
|
|
481
|
+
subscriptionHandler: {
|
|
482
|
+
subscribe: vi.fn().mockRejectedValue(subscribeError),
|
|
483
|
+
unsubscribe: vi.fn().mockResolvedValue(undefined),
|
|
484
|
+
},
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
connectViaSubscribe({ client, mockSocket })
|
|
488
|
+
|
|
489
|
+
client.subscribe({
|
|
490
|
+
channel: 'prices',
|
|
491
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
492
|
+
onMessage: vi.fn(),
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
496
|
+
|
|
497
|
+
expect(onError).toHaveBeenCalledWith(subscribeError)
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
it('socket error calls onError', () => {
|
|
501
|
+
const onError = vi.fn()
|
|
502
|
+
const { client, mockSocket } = createTestClient({ onError })
|
|
503
|
+
|
|
504
|
+
connectViaSubscribe({ client, mockSocket })
|
|
505
|
+
|
|
506
|
+
mockSocket.simulateError('Connection failed')
|
|
507
|
+
|
|
508
|
+
expect(onError).toHaveBeenCalledWith(expect.any(Error))
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
it('parseMessage throws calls onError', () => {
|
|
512
|
+
const onError = vi.fn()
|
|
513
|
+
const parseError = new Error('Parse failed')
|
|
514
|
+
|
|
515
|
+
const { client, mockSocket } = createTestClient({
|
|
516
|
+
onError,
|
|
517
|
+
parseMessage: () => {
|
|
518
|
+
throw parseError
|
|
519
|
+
},
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
connectViaSubscribe({ client, mockSocket })
|
|
523
|
+
|
|
524
|
+
// This message will trigger parseMessage
|
|
525
|
+
mockSocket.simulateMessage({ channel: 'prices', key: 'prices:token-1', data: { data: 'test' } })
|
|
526
|
+
|
|
527
|
+
expect(onError).toHaveBeenCalledWith(parseError)
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
it('callback throws calls onError but other callbacks still invoked', async () => {
|
|
531
|
+
const onError = vi.fn()
|
|
532
|
+
const { client, mockSocket } = createTestClient({ onError })
|
|
533
|
+
const messages: TestMessage[] = []
|
|
534
|
+
const callbackError = new Error('Callback error')
|
|
535
|
+
|
|
536
|
+
connectViaSubscribe({ client, mockSocket })
|
|
537
|
+
|
|
538
|
+
client.subscribe({
|
|
539
|
+
channel: 'prices',
|
|
540
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
541
|
+
onMessage: () => {
|
|
542
|
+
throw callbackError
|
|
543
|
+
},
|
|
544
|
+
})
|
|
545
|
+
client.subscribe({
|
|
546
|
+
channel: 'prices',
|
|
547
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
548
|
+
onMessage: (m) => messages.push(m),
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
552
|
+
|
|
553
|
+
mockSocket.simulateMessage({
|
|
554
|
+
channel: 'prices',
|
|
555
|
+
key: 'prices:token-1',
|
|
556
|
+
data: { data: 'test' },
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
expect(onError).toHaveBeenCalledWith(callbackError)
|
|
560
|
+
expect(messages).toEqual([{ data: 'test' }])
|
|
561
|
+
})
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
describe('status callbacks', () => {
|
|
565
|
+
it('onStatusChange receives all transitions', async () => {
|
|
566
|
+
const { client, mockSocket } = createTestClient()
|
|
567
|
+
const statusChanges: ConnectionStatus[] = []
|
|
568
|
+
|
|
569
|
+
client.onStatusChange((s) => statusChanges.push(s))
|
|
570
|
+
|
|
571
|
+
// Subscribe triggers connection
|
|
572
|
+
const unsub1 = client.subscribe({
|
|
573
|
+
channel: 'prices',
|
|
574
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
575
|
+
onMessage: vi.fn(),
|
|
576
|
+
})
|
|
577
|
+
mockSocket.simulateOpen()
|
|
578
|
+
mockSocket.simulateClose()
|
|
579
|
+
mockSocket.simulateOpen()
|
|
580
|
+
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-123' })
|
|
581
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
582
|
+
|
|
583
|
+
// Last unsubscribe triggers debounced disconnect
|
|
584
|
+
unsub1()
|
|
585
|
+
await vi.advanceTimersByTimeAsync(2000)
|
|
586
|
+
|
|
587
|
+
expect(statusChanges).toEqual(['connecting', 'connected', 'reconnecting', 'connected', 'disconnected'])
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
it('onConnectionEstablished fires when connectionId received', () => {
|
|
591
|
+
const { client, mockSocket } = createTestClient()
|
|
592
|
+
const connectionIds: string[] = []
|
|
593
|
+
|
|
594
|
+
client.onConnectionEstablished((id) => connectionIds.push(id))
|
|
595
|
+
|
|
596
|
+
// Subscribe triggers connection
|
|
597
|
+
client.subscribe({
|
|
598
|
+
channel: 'prices',
|
|
599
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
600
|
+
onMessage: vi.fn(),
|
|
601
|
+
})
|
|
602
|
+
mockSocket.simulateOpen()
|
|
603
|
+
|
|
604
|
+
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-1' })
|
|
605
|
+
mockSocket.simulateClose()
|
|
606
|
+
mockSocket.simulateOpen()
|
|
607
|
+
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-2' })
|
|
608
|
+
|
|
609
|
+
expect(connectionIds).toEqual(['conn-1', 'conn-2'])
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
it('unsubscribe from status callback works', () => {
|
|
613
|
+
const { client, mockSocket } = createTestClient()
|
|
614
|
+
const statusChanges: ConnectionStatus[] = []
|
|
615
|
+
|
|
616
|
+
const unsub = client.onStatusChange((s) => statusChanges.push(s))
|
|
617
|
+
|
|
618
|
+
client.subscribe({
|
|
619
|
+
channel: 'prices',
|
|
620
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
621
|
+
onMessage: vi.fn(),
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
unsub()
|
|
625
|
+
|
|
626
|
+
mockSocket.simulateOpen()
|
|
627
|
+
|
|
628
|
+
expect(statusChanges).toEqual(['connecting'])
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
it('unsubscribe from connection callback works', () => {
|
|
632
|
+
const { client, mockSocket } = createTestClient()
|
|
633
|
+
const connectionIds: string[] = []
|
|
634
|
+
|
|
635
|
+
const unsub = client.onConnectionEstablished((id) => connectionIds.push(id))
|
|
636
|
+
|
|
637
|
+
client.subscribe({
|
|
638
|
+
channel: 'prices',
|
|
639
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
640
|
+
onMessage: vi.fn(),
|
|
641
|
+
})
|
|
642
|
+
mockSocket.simulateOpen()
|
|
643
|
+
|
|
644
|
+
unsub()
|
|
645
|
+
|
|
646
|
+
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-1' })
|
|
647
|
+
|
|
648
|
+
expect(connectionIds).toEqual([])
|
|
649
|
+
})
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
describe('full lifecycle', () => {
|
|
653
|
+
it('complete subscription lifecycle from subscribe to last-unsubscribe disconnect', async () => {
|
|
654
|
+
const { client, mockSocket, handler } = createTestClient()
|
|
655
|
+
const statusChanges: ConnectionStatus[] = []
|
|
656
|
+
const messages: TestMessage[] = []
|
|
657
|
+
|
|
658
|
+
client.onStatusChange((s) => statusChanges.push(s))
|
|
659
|
+
|
|
660
|
+
// Subscribe triggers lazy connect
|
|
661
|
+
const unsub = client.subscribe({
|
|
662
|
+
channel: 'prices',
|
|
663
|
+
params: { channel: 'prices', id: 'ETH' },
|
|
664
|
+
onMessage: (m) => messages.push(m),
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
expect(statusChanges).toEqual(['connecting'])
|
|
668
|
+
|
|
669
|
+
mockSocket.simulateOpen()
|
|
670
|
+
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-123' })
|
|
671
|
+
|
|
672
|
+
expect(statusChanges).toEqual(['connecting', 'connected'])
|
|
673
|
+
expect(client.getConnectionId()).toBe('conn-123')
|
|
674
|
+
|
|
675
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
676
|
+
|
|
677
|
+
// resubscribeAll fires because there are active subs when connectionId is received
|
|
678
|
+
expect(handler.subscribeBatch).toHaveBeenCalledWith('conn-123', [{ channel: 'prices', id: 'ETH' }])
|
|
679
|
+
|
|
680
|
+
mockSocket.simulateMessage({
|
|
681
|
+
channel: 'prices',
|
|
682
|
+
key: 'prices:ETH',
|
|
683
|
+
data: { data: 'price', price: 3000 },
|
|
684
|
+
})
|
|
685
|
+
expect(messages).toEqual([{ data: 'price', price: 3000 }])
|
|
686
|
+
|
|
687
|
+
unsub()
|
|
688
|
+
await vi.advanceTimersByTimeAsync(2000)
|
|
689
|
+
|
|
690
|
+
// Last unsubscribe triggers debounced disconnect
|
|
691
|
+
expect(statusChanges).toContain('disconnected')
|
|
692
|
+
})
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
describe('subscribe without onMessage', () => {
|
|
696
|
+
it('subscribe without onMessage still triggers connection and REST subscribe', async () => {
|
|
697
|
+
const { client, mockSocket, handler } = createTestClient()
|
|
698
|
+
|
|
699
|
+
client.subscribe({
|
|
700
|
+
channel: 'prices',
|
|
701
|
+
params: { channel: 'prices', id: 'token-1' },
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
// Should still trigger connection
|
|
705
|
+
expect(client.getConnectionStatus()).toBe('connecting')
|
|
706
|
+
|
|
707
|
+
mockSocket.simulateOpen()
|
|
708
|
+
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-123' })
|
|
709
|
+
|
|
710
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
711
|
+
|
|
712
|
+
expect(handler.subscribeBatch).toHaveBeenCalledWith('conn-123', [{ channel: 'prices', id: 'token-1' }])
|
|
713
|
+
})
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
describe('onRawMessage callback', () => {
|
|
717
|
+
it('onRawMessage receives all parsed messages', () => {
|
|
718
|
+
const onRawMessage = vi.fn()
|
|
719
|
+
const { client, mockSocket } = createTestClient({ onRawMessage })
|
|
720
|
+
|
|
721
|
+
connectViaSubscribe({ client, mockSocket })
|
|
722
|
+
|
|
723
|
+
mockSocket.simulateMessage({ channel: 'prices', key: 'prices:ETH', data: { data: 'test' } })
|
|
724
|
+
|
|
725
|
+
// onRawMessage called for: connection message (from connectViaSubscribe) + the data message
|
|
726
|
+
expect(onRawMessage).toHaveBeenCalledWith({ type: 'connected', connectionId: 'conn-123' })
|
|
727
|
+
expect(onRawMessage).toHaveBeenCalledWith({ channel: 'prices', key: 'prices:ETH', data: { data: 'test' } })
|
|
728
|
+
})
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
describe('session refresh timer', () => {
|
|
732
|
+
it('starts timer on connect and calls refreshSession at interval', async () => {
|
|
733
|
+
const { client, mockSocket, handler } = createTestClient({
|
|
734
|
+
sessionRefreshIntervalMs: 5000,
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
connectViaSubscribe({ client, mockSocket })
|
|
738
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
739
|
+
|
|
740
|
+
expect(handler.refreshSession).not.toHaveBeenCalled()
|
|
741
|
+
|
|
742
|
+
await vi.advanceTimersByTimeAsync(5000)
|
|
743
|
+
|
|
744
|
+
expect(handler.refreshSession).toHaveBeenCalledTimes(1)
|
|
745
|
+
|
|
746
|
+
await vi.advanceTimersByTimeAsync(5000)
|
|
747
|
+
|
|
748
|
+
expect(handler.refreshSession).toHaveBeenCalledTimes(2)
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
it('stops timer on disconnect (last unsubscribe)', async () => {
|
|
752
|
+
const { client, mockSocket, handler } = createTestClient({
|
|
753
|
+
sessionRefreshIntervalMs: 5000,
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
const unsub = connectViaSubscribe({ client, mockSocket })
|
|
757
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
758
|
+
|
|
759
|
+
unsub()
|
|
760
|
+
// Advance past the 2000ms disconnect debounce, then check no refreshSession fires
|
|
761
|
+
await vi.advanceTimersByTimeAsync(10000)
|
|
762
|
+
|
|
763
|
+
expect(handler.refreshSession).not.toHaveBeenCalled()
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
it('stops timer on socket close', async () => {
|
|
767
|
+
const { client, mockSocket, handler } = createTestClient({
|
|
768
|
+
sessionRefreshIntervalMs: 5000,
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
connectViaSubscribe({ client, mockSocket })
|
|
772
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
773
|
+
|
|
774
|
+
mockSocket.simulateClose()
|
|
775
|
+
|
|
776
|
+
await vi.advanceTimersByTimeAsync(10000)
|
|
777
|
+
|
|
778
|
+
expect(handler.refreshSession).not.toHaveBeenCalled()
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
it('restarts timer on reconnect', async () => {
|
|
782
|
+
const { client, mockSocket, handler } = createTestClient({
|
|
783
|
+
sessionRefreshIntervalMs: 5000,
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
connectViaSubscribe({ client, mockSocket })
|
|
787
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
788
|
+
|
|
789
|
+
// Simulate reconnect
|
|
790
|
+
mockSocket.simulateClose()
|
|
791
|
+
mockSocket.simulateOpen()
|
|
792
|
+
mockSocket.simulateMessage({ type: 'connected', connectionId: 'conn-456' })
|
|
793
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
794
|
+
|
|
795
|
+
handler.refreshSession.mockClear()
|
|
796
|
+
|
|
797
|
+
await vi.advanceTimersByTimeAsync(5000)
|
|
798
|
+
|
|
799
|
+
expect(handler.refreshSession).toHaveBeenCalledTimes(1)
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
it('does not start timer without sessionRefreshIntervalMs option', async () => {
|
|
803
|
+
const { client, mockSocket, handler } = createTestClient()
|
|
804
|
+
|
|
805
|
+
connectViaSubscribe({ client, mockSocket })
|
|
806
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
807
|
+
|
|
808
|
+
await vi.advanceTimersByTimeAsync(60000)
|
|
809
|
+
|
|
810
|
+
expect(handler.refreshSession).not.toHaveBeenCalled()
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
it('does not start timer without handler refreshSession method', async () => {
|
|
814
|
+
const { client, mockSocket } = createTestClient({
|
|
815
|
+
sessionRefreshIntervalMs: 5000,
|
|
816
|
+
subscriptionHandler: {
|
|
817
|
+
subscribe: vi.fn().mockResolvedValue(undefined),
|
|
818
|
+
unsubscribe: vi.fn().mockResolvedValue(undefined),
|
|
819
|
+
},
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
connectViaSubscribe({ client, mockSocket })
|
|
823
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
824
|
+
|
|
825
|
+
// No error thrown, timer simply doesn't start
|
|
826
|
+
await vi.advanceTimersByTimeAsync(10000)
|
|
827
|
+
})
|
|
828
|
+
})
|
|
829
|
+
|
|
830
|
+
describe('debounced disconnect', () => {
|
|
831
|
+
it('does not disconnect immediately when subscription count drops to 0', async () => {
|
|
832
|
+
const { client, mockSocket } = createTestClient()
|
|
833
|
+
|
|
834
|
+
const unsub = connectViaSubscribe({ client, mockSocket })
|
|
835
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
836
|
+
|
|
837
|
+
unsub()
|
|
838
|
+
|
|
839
|
+
// Still connected — disconnect is debounced
|
|
840
|
+
expect(client.getConnectionStatus()).not.toBe('disconnected')
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
it('disconnects after the debounce window expires', async () => {
|
|
844
|
+
const { client, mockSocket } = createTestClient()
|
|
845
|
+
|
|
846
|
+
const unsub = connectViaSubscribe({ client, mockSocket })
|
|
847
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
848
|
+
|
|
849
|
+
unsub()
|
|
850
|
+
await vi.advanceTimersByTimeAsync(2000)
|
|
851
|
+
|
|
852
|
+
expect(client.getConnectionStatus()).toBe('disconnected')
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
it('cancels pending disconnect when new subscription arrives during debounce window', async () => {
|
|
856
|
+
const { client, mockSocket } = createTestClient()
|
|
857
|
+
const statusChanges: ConnectionStatus[] = []
|
|
858
|
+
|
|
859
|
+
client.onStatusChange((s) => statusChanges.push(s))
|
|
860
|
+
|
|
861
|
+
const unsub1 = connectViaSubscribe({ client, mockSocket })
|
|
862
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
863
|
+
|
|
864
|
+
// Unsubscribe last — starts debounce timer
|
|
865
|
+
unsub1()
|
|
866
|
+
|
|
867
|
+
// Resubscribe within the debounce window (simulates navigation: old page unmounts, new page mounts)
|
|
868
|
+
client.subscribe({
|
|
869
|
+
channel: 'prices',
|
|
870
|
+
params: { channel: 'prices', id: 'token-2' },
|
|
871
|
+
onMessage: vi.fn(),
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
// Advance past the debounce window
|
|
875
|
+
await vi.advanceTimersByTimeAsync(200)
|
|
876
|
+
|
|
877
|
+
// Should NOT have disconnected — the resubscribe cancelled the pending disconnect
|
|
878
|
+
expect(client.getConnectionStatus()).not.toBe('disconnected')
|
|
879
|
+
expect(statusChanges).not.toContain('disconnected')
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
it('preserves connection across simulated page navigation (unsub all → resub)', async () => {
|
|
883
|
+
const { client, mockSocket } = createTestClient()
|
|
884
|
+
|
|
885
|
+
connectViaSubscribe({ client, mockSocket })
|
|
886
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
887
|
+
|
|
888
|
+
// Simulate old page unmounting: unsubscribe all tokens
|
|
889
|
+
const unsub1 = client.subscribe({
|
|
890
|
+
channel: 'prices',
|
|
891
|
+
params: { channel: 'prices', id: 'ETH' },
|
|
892
|
+
onMessage: vi.fn(),
|
|
893
|
+
})
|
|
894
|
+
const unsub2 = client.subscribe({
|
|
895
|
+
channel: 'prices',
|
|
896
|
+
params: { channel: 'prices', id: 'BTC' },
|
|
897
|
+
onMessage: vi.fn(),
|
|
898
|
+
})
|
|
899
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
900
|
+
|
|
901
|
+
// Old page unmounts (cleanup effects fire)
|
|
902
|
+
unsub1()
|
|
903
|
+
unsub2()
|
|
904
|
+
|
|
905
|
+
// New page mounts (setup effects fire) — within the debounce window
|
|
906
|
+
client.subscribe({
|
|
907
|
+
channel: 'prices',
|
|
908
|
+
params: { channel: 'prices', id: 'UNI' },
|
|
909
|
+
onMessage: vi.fn(),
|
|
910
|
+
})
|
|
911
|
+
|
|
912
|
+
await vi.advanceTimersByTimeAsync(200)
|
|
913
|
+
|
|
914
|
+
// Connection should have persisted — no disconnect/reconnect cycle
|
|
915
|
+
expect(client.isConnected()).toBe(true)
|
|
916
|
+
expect(client.getConnectionStatus()).toBe('connected')
|
|
917
|
+
})
|
|
918
|
+
})
|
|
919
|
+
})
|