@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.
@@ -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
+ })