@luxexchange/websocket 1.0.0 → 1.0.1

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