@luxexchange/websocket 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,556 @@
1
+ import { SubscriptionManager } from '@luxexchange/websocket/src/subscriptions/SubscriptionManager'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+
4
+ interface TestParams {
5
+ channel: string
6
+ id: string
7
+ }
8
+
9
+ interface TestMessage {
10
+ data: string
11
+ }
12
+
13
+ function flushMicrotasks(): Promise<void> {
14
+ return new Promise((resolve) => setTimeout(resolve, 0))
15
+ }
16
+
17
+ function createTestManager(
18
+ overrides?: Partial<{
19
+ subscribe: (connectionId: string, params: TestParams) => Promise<void>
20
+ unsubscribe: (connectionId: string, params: TestParams) => Promise<void>
21
+ subscribeBatch: (connectionId: string, params: TestParams[]) => Promise<void>
22
+ unsubscribeBatch: (connectionId: string, params: TestParams[]) => Promise<void>
23
+ refreshSession: (connectionId: string) => Promise<void>
24
+ onSubscriptionCountChange: (count: number) => void
25
+ }>,
26
+ ): {
27
+ manager: SubscriptionManager<TestParams, TestMessage>
28
+ handler: {
29
+ subscribe: ReturnType<typeof vi.fn>
30
+ unsubscribe: ReturnType<typeof vi.fn>
31
+ subscribeBatch: ReturnType<typeof vi.fn>
32
+ unsubscribeBatch: ReturnType<typeof vi.fn>
33
+ refreshSession: ReturnType<typeof vi.fn>
34
+ }
35
+ } {
36
+ const handler = {
37
+ subscribe: vi.fn().mockResolvedValue(undefined),
38
+ unsubscribe: vi.fn().mockResolvedValue(undefined),
39
+ subscribeBatch: vi.fn().mockResolvedValue(undefined),
40
+ unsubscribeBatch: vi.fn().mockResolvedValue(undefined),
41
+ refreshSession: vi.fn().mockResolvedValue(undefined),
42
+ ...overrides,
43
+ }
44
+
45
+ const manager = new SubscriptionManager<TestParams, TestMessage>({
46
+ handler,
47
+ createKey: (channel, params): string => `${channel}:${params.id}`,
48
+ onSubscriptionCountChange: overrides?.onSubscriptionCountChange,
49
+ })
50
+
51
+ return { manager, handler }
52
+ }
53
+
54
+ describe('SubscriptionManager', () => {
55
+ describe('subscribe', () => {
56
+ it('calls handler via subscribeBatch on first subscriber after microtask flush', async () => {
57
+ const { manager, handler } = createTestManager()
58
+ manager.setConnectionId('conn-123')
59
+
60
+ const callback = vi.fn()
61
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback })
62
+
63
+ // Not called yet — batched
64
+ expect(handler.subscribeBatch).not.toHaveBeenCalled()
65
+ expect(handler.subscribe).not.toHaveBeenCalled()
66
+
67
+ await flushMicrotasks()
68
+
69
+ expect(handler.subscribeBatch).toHaveBeenCalledWith('conn-123', [{ channel: 'prices', id: 'token-1' }])
70
+ expect(handler.subscribeBatch).toHaveBeenCalledTimes(1)
71
+ })
72
+
73
+ it('falls back to individual subscribe when subscribeBatch is not provided', async () => {
74
+ const handler = {
75
+ subscribe: vi.fn().mockResolvedValue(undefined),
76
+ unsubscribe: vi.fn().mockResolvedValue(undefined),
77
+ refreshSession: vi.fn().mockResolvedValue(undefined),
78
+ }
79
+ const manager = new SubscriptionManager<TestParams, TestMessage>({
80
+ handler,
81
+ createKey: (channel, params): string => `${channel}:${params.id}`,
82
+ })
83
+ manager.setConnectionId('conn-123')
84
+
85
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
86
+
87
+ await flushMicrotasks()
88
+
89
+ expect(handler.subscribe).toHaveBeenCalledWith('conn-123', { channel: 'prices', id: 'token-1' })
90
+ expect(handler.subscribe).toHaveBeenCalledTimes(1)
91
+ })
92
+
93
+ it('batches multiple subscribes in same microtask into one subscribeBatch call', async () => {
94
+ const { manager, handler } = createTestManager()
95
+ manager.setConnectionId('conn-123')
96
+
97
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
98
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-2' }, callback: vi.fn() })
99
+ manager.subscribe({ channel: 'events', params: { channel: 'events', id: 'event-1' }, callback: vi.fn() })
100
+
101
+ await flushMicrotasks()
102
+
103
+ expect(handler.subscribeBatch).toHaveBeenCalledTimes(1)
104
+ expect(handler.subscribeBatch).toHaveBeenCalledWith('conn-123', [
105
+ { channel: 'prices', id: 'token-1' },
106
+ { channel: 'prices', id: 'token-2' },
107
+ { channel: 'events', id: 'event-1' },
108
+ ])
109
+ })
110
+
111
+ it('does not call handler.subscribe for subsequent subscribers to same key', async () => {
112
+ const { manager, handler } = createTestManager()
113
+ manager.setConnectionId('conn-123')
114
+
115
+ const callback1 = vi.fn()
116
+ const callback2 = vi.fn()
117
+
118
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: callback1 })
119
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: callback2 })
120
+
121
+ await flushMicrotasks()
122
+
123
+ expect(handler.subscribeBatch).toHaveBeenCalledTimes(1)
124
+ expect(handler.subscribeBatch).toHaveBeenCalledWith('conn-123', [{ channel: 'prices', id: 'token-1' }])
125
+ })
126
+
127
+ it('does not call handler when not connected', async () => {
128
+ const { manager, handler } = createTestManager()
129
+ // No connectionId set
130
+
131
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
132
+
133
+ await flushMicrotasks()
134
+
135
+ expect(handler.subscribeBatch).not.toHaveBeenCalled()
136
+ expect(handler.subscribe).not.toHaveBeenCalled()
137
+ })
138
+
139
+ it('returns synchronous unsubscribe function', () => {
140
+ const { manager } = createTestManager()
141
+ manager.setConnectionId('conn-123')
142
+
143
+ const unsubscribe = manager.subscribe({
144
+ channel: 'prices',
145
+ params: { channel: 'prices', id: 'token-1' },
146
+ callback: vi.fn(),
147
+ })
148
+
149
+ expect(typeof unsubscribe).toBe('function')
150
+ })
151
+
152
+ it('works without a callback (optional onMessage)', async () => {
153
+ const { manager, handler } = createTestManager()
154
+ manager.setConnectionId('conn-123')
155
+
156
+ const unsubscribe = manager.subscribe({
157
+ channel: 'prices',
158
+ params: { channel: 'prices', id: 'token-1' },
159
+ })
160
+
161
+ await flushMicrotasks()
162
+
163
+ expect(handler.subscribeBatch).toHaveBeenCalledTimes(1)
164
+ expect(typeof unsubscribe).toBe('function')
165
+ })
166
+ })
167
+
168
+ describe('unsubscribe', () => {
169
+ it('calls handler.unsubscribeBatch when last subscriber leaves', async () => {
170
+ const { manager, handler } = createTestManager()
171
+ manager.setConnectionId('conn-123')
172
+
173
+ const callback = vi.fn()
174
+ const unsubscribe = manager.subscribe({
175
+ channel: 'prices',
176
+ params: { channel: 'prices', id: 'token-1' },
177
+ callback,
178
+ })
179
+
180
+ await flushMicrotasks()
181
+
182
+ unsubscribe()
183
+
184
+ await flushMicrotasks()
185
+
186
+ expect(handler.unsubscribeBatch).toHaveBeenCalledWith('conn-123', [{ channel: 'prices', id: 'token-1' }])
187
+ })
188
+
189
+ it('does not call handler.unsubscribe when other subscribers remain', async () => {
190
+ const { manager, handler } = createTestManager()
191
+ manager.setConnectionId('conn-123')
192
+
193
+ const callback1 = vi.fn()
194
+ const callback2 = vi.fn()
195
+
196
+ const unsubscribe1 = manager.subscribe({
197
+ channel: 'prices',
198
+ params: { channel: 'prices', id: 'token-1' },
199
+ callback: callback1,
200
+ })
201
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: callback2 })
202
+
203
+ await flushMicrotasks()
204
+
205
+ unsubscribe1()
206
+
207
+ await flushMicrotasks()
208
+
209
+ expect(handler.unsubscribeBatch).not.toHaveBeenCalled()
210
+ expect(handler.unsubscribe).not.toHaveBeenCalled()
211
+ })
212
+
213
+ it('batches multiple unsubscribes in same microtask', async () => {
214
+ const { manager, handler } = createTestManager()
215
+ manager.setConnectionId('conn-123')
216
+
217
+ const unsub1 = manager.subscribe({
218
+ channel: 'prices',
219
+ params: { channel: 'prices', id: 'token-1' },
220
+ callback: vi.fn(),
221
+ })
222
+ const unsub2 = manager.subscribe({
223
+ channel: 'prices',
224
+ params: { channel: 'prices', id: 'token-2' },
225
+ callback: vi.fn(),
226
+ })
227
+
228
+ await flushMicrotasks()
229
+
230
+ unsub1()
231
+ unsub2()
232
+
233
+ await flushMicrotasks()
234
+
235
+ expect(handler.unsubscribeBatch).toHaveBeenCalledTimes(1)
236
+ expect(handler.unsubscribeBatch).toHaveBeenCalledWith('conn-123', [
237
+ { channel: 'prices', id: 'token-1' },
238
+ { channel: 'prices', id: 'token-2' },
239
+ ])
240
+ })
241
+
242
+ it('subscribe + immediate unsubscribe in same microtask produces net-zero API calls', async () => {
243
+ const { manager, handler } = createTestManager()
244
+ manager.setConnectionId('conn-123')
245
+
246
+ const unsub = manager.subscribe({
247
+ channel: 'prices',
248
+ params: { channel: 'prices', id: 'token-1' },
249
+ callback: vi.fn(),
250
+ })
251
+ unsub()
252
+
253
+ await flushMicrotasks()
254
+
255
+ // Both pending subscribe and unsubscribe should cancel out
256
+ expect(handler.subscribeBatch).not.toHaveBeenCalled()
257
+ expect(handler.subscribe).not.toHaveBeenCalled()
258
+ expect(handler.unsubscribeBatch).not.toHaveBeenCalled()
259
+ expect(handler.unsubscribe).not.toHaveBeenCalled()
260
+ })
261
+ })
262
+
263
+ describe('dispatch', () => {
264
+ it('routes messages to correct callbacks', () => {
265
+ const { manager } = createTestManager()
266
+ manager.setConnectionId('conn-123')
267
+
268
+ const callback1 = vi.fn()
269
+ const callback2 = vi.fn()
270
+
271
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: callback1 })
272
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-2' }, callback: callback2 })
273
+
274
+ manager.dispatch('prices:token-1', { data: 'price-update-1' })
275
+
276
+ expect(callback1).toHaveBeenCalledWith({ data: 'price-update-1' })
277
+ expect(callback2).not.toHaveBeenCalled()
278
+ })
279
+
280
+ it('calls all callbacks for same subscription', () => {
281
+ const { manager } = createTestManager()
282
+ manager.setConnectionId('conn-123')
283
+
284
+ const callback1 = vi.fn()
285
+ const callback2 = vi.fn()
286
+
287
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: callback1 })
288
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: callback2 })
289
+
290
+ manager.dispatch('prices:token-1', { data: 'price-update' })
291
+
292
+ expect(callback1).toHaveBeenCalledWith({ data: 'price-update' })
293
+ expect(callback2).toHaveBeenCalledWith({ data: 'price-update' })
294
+ })
295
+
296
+ it('ignores messages for unknown subscriptions', () => {
297
+ const { manager } = createTestManager()
298
+
299
+ // Should not throw
300
+ manager.dispatch('unknown:key', { data: 'unknown' })
301
+ })
302
+ })
303
+
304
+ describe('resubscribeAll', () => {
305
+ it('resubscribes all active subscriptions with new connectionId using subscribeBatch', async () => {
306
+ const { manager, handler } = createTestManager()
307
+ manager.setConnectionId('conn-old')
308
+
309
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
310
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-2' }, callback: vi.fn() })
311
+
312
+ await flushMicrotasks()
313
+ handler.subscribeBatch.mockClear()
314
+
315
+ await manager.resubscribeAll('conn-new')
316
+
317
+ expect(handler.subscribeBatch).toHaveBeenCalledTimes(1)
318
+ expect(handler.subscribeBatch).toHaveBeenCalledWith('conn-new', [
319
+ { channel: 'prices', id: 'token-1' },
320
+ { channel: 'prices', id: 'token-2' },
321
+ ])
322
+ })
323
+
324
+ it('falls back to individual subscribe calls when subscribeBatch is not provided', async () => {
325
+ const handler = {
326
+ subscribe: vi.fn().mockResolvedValue(undefined),
327
+ unsubscribe: vi.fn().mockResolvedValue(undefined),
328
+ refreshSession: vi.fn().mockResolvedValue(undefined),
329
+ }
330
+ const manager = new SubscriptionManager<TestParams, TestMessage>({
331
+ handler,
332
+ createKey: (channel, params): string => `${channel}:${params.id}`,
333
+ })
334
+ manager.setConnectionId('conn-old')
335
+
336
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
337
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-2' }, callback: vi.fn() })
338
+
339
+ await flushMicrotasks()
340
+ handler.subscribe.mockClear()
341
+
342
+ await manager.resubscribeAll('conn-new')
343
+
344
+ expect(handler.subscribe).toHaveBeenCalledTimes(2)
345
+ expect(handler.subscribe).toHaveBeenCalledWith('conn-new', { channel: 'prices', id: 'token-1' })
346
+ expect(handler.subscribe).toHaveBeenCalledWith('conn-new', { channel: 'prices', id: 'token-2' })
347
+ })
348
+
349
+ it('updates internal connectionId', async () => {
350
+ const { manager } = createTestManager()
351
+ manager.setConnectionId('conn-old')
352
+
353
+ await manager.resubscribeAll('conn-new')
354
+
355
+ expect(manager.getConnectionId()).toBe('conn-new')
356
+ })
357
+ })
358
+
359
+ describe('onSubscriptionCountChange', () => {
360
+ it('fires when first subscription is added', () => {
361
+ const onSubscriptionCountChange = vi.fn()
362
+ const { manager } = createTestManager({ onSubscriptionCountChange })
363
+ manager.setConnectionId('conn-123')
364
+
365
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
366
+
367
+ expect(onSubscriptionCountChange).toHaveBeenCalledWith(1)
368
+ })
369
+
370
+ it('fires when subscription count increases', () => {
371
+ const onSubscriptionCountChange = vi.fn()
372
+ const { manager } = createTestManager({ onSubscriptionCountChange })
373
+ manager.setConnectionId('conn-123')
374
+
375
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
376
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-2' }, callback: vi.fn() })
377
+
378
+ expect(onSubscriptionCountChange).toHaveBeenCalledTimes(2)
379
+ expect(onSubscriptionCountChange).toHaveBeenNthCalledWith(1, 1)
380
+ expect(onSubscriptionCountChange).toHaveBeenNthCalledWith(2, 2)
381
+ })
382
+
383
+ it('fires when last subscription is removed', async () => {
384
+ const onSubscriptionCountChange = vi.fn()
385
+ const { manager } = createTestManager({ onSubscriptionCountChange })
386
+ manager.setConnectionId('conn-123')
387
+
388
+ const unsub = manager.subscribe({
389
+ channel: 'prices',
390
+ params: { channel: 'prices', id: 'token-1' },
391
+ callback: vi.fn(),
392
+ })
393
+
394
+ onSubscriptionCountChange.mockClear()
395
+ unsub()
396
+
397
+ expect(onSubscriptionCountChange).toHaveBeenCalledWith(0)
398
+ })
399
+
400
+ it('does not fire for additional subscribers to same key', () => {
401
+ const onSubscriptionCountChange = vi.fn()
402
+ const { manager } = createTestManager({ onSubscriptionCountChange })
403
+ manager.setConnectionId('conn-123')
404
+
405
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
406
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
407
+
408
+ // Only fired once — second subscriber to same key doesn't change unique subscription count
409
+ expect(onSubscriptionCountChange).toHaveBeenCalledTimes(1)
410
+ expect(onSubscriptionCountChange).toHaveBeenCalledWith(1)
411
+ })
412
+ })
413
+
414
+ describe('getActiveSubscriptions', () => {
415
+ it('returns all active subscriptions with subscriber counts', () => {
416
+ const { manager } = createTestManager()
417
+ manager.setConnectionId('conn-123')
418
+
419
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
420
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
421
+ manager.subscribe({ channel: 'events', params: { channel: 'events', id: 'event-1' }, callback: vi.fn() })
422
+
423
+ const subscriptions = manager.getActiveSubscriptions()
424
+
425
+ expect(subscriptions).toHaveLength(2)
426
+ expect(subscriptions).toContainEqual({
427
+ channel: 'prices',
428
+ params: { channel: 'prices', id: 'token-1' },
429
+ subscriberCount: 2,
430
+ })
431
+ expect(subscriptions).toContainEqual({
432
+ channel: 'events',
433
+ params: { channel: 'events', id: 'event-1' },
434
+ subscriberCount: 1,
435
+ })
436
+ })
437
+ })
438
+
439
+ describe('hasActiveSubscriptions', () => {
440
+ it('returns false when no subscriptions', () => {
441
+ const { manager } = createTestManager()
442
+
443
+ expect(manager.hasActiveSubscriptions()).toBe(false)
444
+ })
445
+
446
+ it('returns true when subscriptions exist', () => {
447
+ const { manager } = createTestManager()
448
+ manager.setConnectionId('conn-123')
449
+
450
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
451
+
452
+ expect(manager.hasActiveSubscriptions()).toBe(true)
453
+ })
454
+ })
455
+
456
+ describe('clear', () => {
457
+ it('removes all subscriptions, pending batches, and resets connectionId', () => {
458
+ const { manager } = createTestManager()
459
+ manager.setConnectionId('conn-123')
460
+
461
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
462
+
463
+ manager.clear()
464
+
465
+ expect(manager.hasActiveSubscriptions()).toBe(false)
466
+ expect(manager.getConnectionId()).toBe(null)
467
+ })
468
+
469
+ it('prevents pending subscribes from flushing after clear', async () => {
470
+ const { manager, handler } = createTestManager()
471
+ manager.setConnectionId('conn-123')
472
+
473
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
474
+
475
+ // Clear before microtask flush
476
+ manager.clear()
477
+
478
+ await flushMicrotasks()
479
+
480
+ // The subscribe batch should not have fired since connectionId was cleared
481
+ expect(handler.subscribeBatch).not.toHaveBeenCalled()
482
+ expect(handler.subscribe).not.toHaveBeenCalled()
483
+ })
484
+ })
485
+
486
+ describe('refreshSession', () => {
487
+ it('calls handler.refreshSession when connected', async () => {
488
+ const { manager, handler } = createTestManager()
489
+ manager.setConnectionId('conn-123')
490
+
491
+ await manager.refreshSession()
492
+
493
+ expect(handler.refreshSession).toHaveBeenCalledWith('conn-123')
494
+ })
495
+
496
+ it('does nothing when not connected', async () => {
497
+ const { manager, handler } = createTestManager()
498
+
499
+ await manager.refreshSession()
500
+
501
+ expect(handler.refreshSession).not.toHaveBeenCalled()
502
+ })
503
+ })
504
+
505
+ describe('error handling', () => {
506
+ it('calls onError when subscribeBatch fails (errors are async, not thrown)', async () => {
507
+ const onError = vi.fn()
508
+ const error = new Error('Subscribe failed')
509
+
510
+ const manager = new SubscriptionManager<TestParams, TestMessage>({
511
+ handler: {
512
+ subscribe: vi.fn().mockResolvedValue(undefined),
513
+ unsubscribe: vi.fn().mockResolvedValue(undefined),
514
+ subscribeBatch: vi.fn().mockRejectedValue(error),
515
+ unsubscribeBatch: vi.fn().mockResolvedValue(undefined),
516
+ },
517
+ createKey: (channel, params): string => `${channel}:${params.id}`,
518
+ onError,
519
+ })
520
+
521
+ manager.setConnectionId('conn-123')
522
+ manager.subscribe({ channel: 'prices', params: { channel: 'prices', id: 'token-1' }, callback: vi.fn() })
523
+
524
+ await flushMicrotasks()
525
+
526
+ expect(onError).toHaveBeenCalledWith(error, 'subscribe')
527
+ })
528
+
529
+ it('calls onError when callback throws during dispatch', () => {
530
+ const onError = vi.fn()
531
+ const error = new Error('Callback error')
532
+
533
+ const manager = new SubscriptionManager<TestParams, TestMessage>({
534
+ handler: {
535
+ subscribe: vi.fn().mockResolvedValue(undefined),
536
+ unsubscribe: vi.fn().mockResolvedValue(undefined),
537
+ },
538
+ createKey: (channel, params): string => `${channel}:${params.id}`,
539
+ onError,
540
+ })
541
+
542
+ manager.setConnectionId('conn-123')
543
+ manager.subscribe({
544
+ channel: 'prices',
545
+ params: { channel: 'prices', id: 'token-1' },
546
+ callback: (): void => {
547
+ throw error
548
+ },
549
+ })
550
+
551
+ manager.dispatch('prices:token-1', { data: 'test' })
552
+
553
+ expect(onError).toHaveBeenCalledWith(error, 'dispatch')
554
+ })
555
+ })
556
+ })