@onebun/core 0.1.9 → 0.1.11

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.
@@ -22,6 +22,7 @@ import type { WsClientData } from './ws.types';
22
22
 
23
23
  import { OneBunApplication } from '../application/application';
24
24
  import { Module } from '../decorators/decorators';
25
+ import { makeMockLoggerLayer } from '../testing/test-utils';
25
26
 
26
27
  import { BaseWebSocketGateway } from './ws-base-gateway';
27
28
  import { createWsClient } from './ws-client';
@@ -130,6 +131,7 @@ describe('WebSocket Integration', () => {
130
131
  app = new OneBunApplication(TestModule, {
131
132
  port: TEST_PORT,
132
133
  development: true,
134
+ loggerLayer: makeMockLoggerLayer(),
133
135
  });
134
136
  await app.start();
135
137
 
@@ -157,7 +159,7 @@ describe('WebSocket Integration', () => {
157
159
  await client.connect();
158
160
 
159
161
  // Wait for welcome message
160
- await new Promise((resolve) => setTimeout(resolve, 50));
162
+ await new Promise((resolve) => setTimeout(resolve, 20));
161
163
 
162
164
  expect(welcomeReceived).toBe(true);
163
165
  expect(welcomeData).toHaveProperty('message', 'Welcome!');
@@ -185,7 +187,7 @@ describe('WebSocket Integration', () => {
185
187
  client = createWsClient(definition, { url: TEST_URL, timeout: 2000 });
186
188
  await client.connect();
187
189
  // Wait for connection to stabilize
188
- await new Promise((resolve) => setTimeout(resolve, 50));
190
+ await new Promise((resolve) => setTimeout(resolve, 20));
189
191
  });
190
192
 
191
193
  afterEach(() => {
@@ -208,7 +210,7 @@ describe('WebSocket Integration', () => {
208
210
  client.TestGateway.send('echo', testData);
209
211
 
210
212
  // Wait for response
211
- await new Promise((resolve) => setTimeout(resolve, 100));
213
+ await new Promise((resolve) => setTimeout(resolve, 30));
212
214
 
213
215
  expect(responseReceived).toBe(true);
214
216
  expect(responseData).toEqual(testData);
@@ -235,7 +237,7 @@ describe('WebSocket Integration', () => {
235
237
  client2 = createWsClient(definition, { url: TEST_URL, timeout: 2000 });
236
238
  await client1.connect();
237
239
  await client2.connect();
238
- await new Promise((resolve) => setTimeout(resolve, 50));
240
+ await new Promise((resolve) => setTimeout(resolve, 20));
239
241
  });
240
242
 
241
243
  afterEach(() => {
@@ -266,7 +268,7 @@ describe('WebSocket Integration', () => {
266
268
  // Both clients join the same room
267
269
  client1.TestGateway.send('join', roomName);
268
270
  client2.TestGateway.send('join', roomName);
269
- await new Promise((resolve) => setTimeout(resolve, 100));
271
+ await new Promise((resolve) => setTimeout(resolve, 30));
270
272
 
271
273
  // Client2 listens for messages
272
274
  client2.TestGateway.on('chat:message', (data: unknown) => {
@@ -276,7 +278,7 @@ describe('WebSocket Integration', () => {
276
278
 
277
279
  // Client1 sends a message to the room
278
280
  client1.TestGateway.send('chat:chat:message', { text: 'Hello room!' });
279
- await new Promise((resolve) => setTimeout(resolve, 100));
281
+ await new Promise((resolve) => setTimeout(resolve, 30));
280
282
 
281
283
  expect(client2ReceivedMessage).toBe(true);
282
284
  expect(receivedData).toHaveProperty('text', 'Hello room!');
@@ -290,7 +292,7 @@ describe('WebSocket Integration', () => {
290
292
  beforeEach(async () => {
291
293
  client = createWsClient(definition, { url: TEST_URL, timeout: 2000 });
292
294
  await client.connect();
293
- await new Promise((resolve) => setTimeout(resolve, 50));
295
+ await new Promise((resolve) => setTimeout(resolve, 20));
294
296
  });
295
297
 
296
298
  afterEach(() => {
@@ -308,7 +310,7 @@ describe('WebSocket Integration', () => {
308
310
 
309
311
  // Send message to chat:room123:message pattern
310
312
  client.TestGateway.send('chat:room123:message', { text: 'Hello!' });
311
- await new Promise((resolve) => setTimeout(resolve, 100));
313
+ await new Promise((resolve) => setTimeout(resolve, 30));
312
314
 
313
315
  expect(sentResponse).toHaveProperty('roomId', 'room123');
314
316
  });
@@ -331,7 +333,7 @@ describe('WebSocket Integration', () => {
331
333
 
332
334
  // Connect all clients
333
335
  await Promise.all(clients.map((c) => c.connect()));
334
- await new Promise((resolve) => setTimeout(resolve, 100));
336
+ await new Promise((resolve) => setTimeout(resolve, 30));
335
337
 
336
338
  expect(connectedClients.length).toBe(5);
337
339
 
@@ -361,12 +363,12 @@ describe('WebSocket Integration', () => {
361
363
 
362
364
  await client1.connect();
363
365
  await client2.connect();
364
- await new Promise((resolve) => setTimeout(resolve, 50));
366
+ await new Promise((resolve) => setTimeout(resolve, 20));
365
367
 
366
368
  // Client1 broadcasts
367
369
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
368
370
  (client1 as any).TestGateway.send('broadcast', { text: 'Hello everyone!' });
369
- await new Promise((resolve) => setTimeout(resolve, 100));
371
+ await new Promise((resolve) => setTimeout(resolve, 30));
370
372
 
371
373
  expect(client1Received).toBe(true);
372
374
  expect(client2Received).toBe(true);
@@ -385,7 +387,7 @@ describe('WebSocket Integration', () => {
385
387
  });
386
388
 
387
389
  await client.connect();
388
- await new Promise((resolve) => setTimeout(resolve, 50));
390
+ await new Promise((resolve) => setTimeout(resolve, 20));
389
391
 
390
392
  // Try to access protected endpoint without auth - set up error listener
391
393
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -396,7 +398,7 @@ describe('WebSocket Integration', () => {
396
398
  // Send to protected endpoint - should not receive response
397
399
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
398
400
  (client as any).TestGateway.send('protected', {});
399
- await new Promise((resolve) => setTimeout(resolve, 100));
401
+ await new Promise((resolve) => setTimeout(resolve, 30));
400
402
 
401
403
  // We should not receive protected:response since guard blocks it
402
404
  let protectedResponseReceived = false;
@@ -421,7 +423,7 @@ describe('WebSocket Integration', () => {
421
423
  });
422
424
 
423
425
  await client.connect();
424
- await new Promise((resolve) => setTimeout(resolve, 50));
426
+ await new Promise((resolve) => setTimeout(resolve, 20));
425
427
 
426
428
  let responseReceived = false;
427
429
  let responseData: { userId?: string } | null = null;
@@ -435,7 +437,7 @@ describe('WebSocket Integration', () => {
435
437
  // Send to protected endpoint with auth
436
438
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
437
439
  (client as any).TestGateway.send('protected', {});
438
- await new Promise((resolve) => setTimeout(resolve, 100));
440
+ await new Promise((resolve) => setTimeout(resolve, 30));
439
441
 
440
442
  // With auth token, the request should be allowed
441
443
  expect(responseReceived).toBe(true);
@@ -455,7 +457,7 @@ describe('WebSocket Integration', () => {
455
457
  });
456
458
 
457
459
  await client.connect();
458
- await new Promise((resolve) => setTimeout(resolve, 50));
460
+ await new Promise((resolve) => setTimeout(resolve, 20));
459
461
 
460
462
  let responseData: { userId?: string } | null = null;
461
463
 
@@ -466,7 +468,7 @@ describe('WebSocket Integration', () => {
466
468
 
467
469
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
468
470
  (client as any).TestGateway.send('protected', {});
469
- await new Promise((resolve) => setTimeout(resolve, 100));
471
+ await new Promise((resolve) => setTimeout(resolve, 30));
470
472
 
471
473
  // Auth data should be available in handler
472
474
  expect(responseData).toBeDefined();
@@ -486,7 +488,7 @@ describe('WebSocket Integration', () => {
486
488
 
487
489
  await client1.connect();
488
490
  await client2.connect();
489
- await new Promise((resolve) => setTimeout(resolve, 50));
491
+ await new Promise((resolve) => setTimeout(resolve, 20));
490
492
 
491
493
  let client1Response = false;
492
494
  let client2Response = false;
@@ -505,7 +507,7 @@ describe('WebSocket Integration', () => {
505
507
  (client1 as any).TestGateway.send('protected', {});
506
508
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
507
509
  (client2 as any).TestGateway.send('protected', {});
508
- await new Promise((resolve) => setTimeout(resolve, 100));
510
+ await new Promise((resolve) => setTimeout(resolve, 30));
509
511
 
510
512
  // Both should receive responses
511
513
  expect(client1Response).toBe(true);
@@ -0,0 +1,517 @@
1
+ /**
2
+ * Redis WebSocket Storage Tests
3
+ *
4
+ * Tests using testcontainers for real Redis integration
5
+ */
6
+
7
+ import {
8
+ afterAll,
9
+ afterEach,
10
+ beforeAll,
11
+ beforeEach,
12
+ describe,
13
+ expect,
14
+ it,
15
+ mock,
16
+ } from 'bun:test';
17
+ import {
18
+ GenericContainer,
19
+ type StartedTestContainer,
20
+ Wait,
21
+ } from 'testcontainers';
22
+
23
+ import type { WsClientData, WsRoom } from './ws.types';
24
+
25
+ import { SharedRedisProvider } from '../redis/shared-redis';
26
+
27
+ import { WsStorageEvent, type WsStorageEventPayload } from './ws-storage';
28
+ import { RedisWsStorage, createRedisWsStorage } from './ws-storage-redis';
29
+
30
+ describe('RedisWsStorage', () => {
31
+ let redisContainer: StartedTestContainer;
32
+ let redisUrl: string;
33
+ let storage: RedisWsStorage;
34
+
35
+ beforeAll(async () => {
36
+ // Start Redis container
37
+ redisContainer = await new GenericContainer('redis:7-alpine')
38
+ .withExposedPorts(6379)
39
+ .withWaitStrategy(Wait.forLogMessage(/.*Ready to accept connections.*/))
40
+ .withStartupTimeout(30000)
41
+ .withLogConsumer(() => {
42
+ // Suppress container logs
43
+ })
44
+ .start();
45
+
46
+ const host = redisContainer.getHost();
47
+ const port = redisContainer.getMappedPort(6379);
48
+ redisUrl = `redis://${host}:${port}`;
49
+
50
+ // Configure shared Redis
51
+ SharedRedisProvider.configure({
52
+ url: redisUrl,
53
+ keyPrefix: 'ws:test:',
54
+ });
55
+ });
56
+
57
+ afterAll(async () => {
58
+ await SharedRedisProvider.reset();
59
+ if (redisContainer) {
60
+ await redisContainer.stop();
61
+ }
62
+ });
63
+
64
+ beforeEach(async () => {
65
+ const client = await SharedRedisProvider.getClient();
66
+ storage = createRedisWsStorage(client) as RedisWsStorage;
67
+ await storage.clear();
68
+ });
69
+
70
+ afterEach(async () => {
71
+ await storage.close();
72
+ });
73
+
74
+ // Helper function
75
+ const createClient = (id: string, options?: Partial<WsClientData>): WsClientData => ({
76
+ id,
77
+ rooms: [],
78
+ connectedAt: Date.now(),
79
+ auth: null,
80
+ metadata: {},
81
+ ...options,
82
+ });
83
+
84
+ describe('client operations', () => {
85
+ it('should add and retrieve a client', async () => {
86
+ const client = createClient('client1');
87
+ await storage.addClient(client);
88
+
89
+ const retrieved = await storage.getClient('client1');
90
+
91
+ expect(retrieved).not.toBeNull();
92
+ expect(retrieved?.id).toBe('client1');
93
+ });
94
+
95
+ it('should return null for non-existent client', async () => {
96
+ const result = await storage.getClient('non-existent');
97
+
98
+ expect(result).toBeNull();
99
+ });
100
+
101
+ it('should update client data', async () => {
102
+ const client = createClient('client1');
103
+ await storage.addClient(client);
104
+
105
+ await storage.updateClient('client1', { metadata: { custom: 'value' } });
106
+
107
+ const updated = await storage.getClient('client1');
108
+ expect(updated?.metadata).toEqual({ custom: 'value' });
109
+ });
110
+
111
+ it('should not update non-existent client', async () => {
112
+ // Should not throw
113
+ await storage.updateClient('non-existent', { metadata: { test: 1 } });
114
+ });
115
+
116
+ it('should remove a client', async () => {
117
+ const client = createClient('client1');
118
+ await storage.addClient(client);
119
+
120
+ await storage.removeClient('client1');
121
+
122
+ const result = await storage.getClient('client1');
123
+ expect(result).toBeNull();
124
+ });
125
+
126
+ it('should get all clients', async () => {
127
+ await storage.addClient(createClient('client1'));
128
+ await storage.addClient(createClient('client2'));
129
+
130
+ const clients = await storage.getAllClients();
131
+
132
+ expect(clients).toHaveLength(2);
133
+ expect(clients.map((c) => c.id)).toContain('client1');
134
+ expect(clients.map((c) => c.id)).toContain('client2');
135
+ });
136
+
137
+ it('should get client count', async () => {
138
+ await storage.addClient(createClient('client1'));
139
+ await storage.addClient(createClient('client2'));
140
+
141
+ const count = await storage.getClientCount();
142
+
143
+ expect(count).toBe(2);
144
+ });
145
+
146
+ it('should handle malformed JSON in client data', async () => {
147
+ // Manually insert invalid JSON
148
+ const client = await SharedRedisProvider.getClient();
149
+ await client.set('clients:invalid', 'not-json');
150
+
151
+ const result = await storage.getClient('invalid');
152
+
153
+ expect(result).toBeNull();
154
+ });
155
+ });
156
+
157
+ describe('room operations', () => {
158
+ it('should create and retrieve a room', async () => {
159
+ const room: WsRoom = { name: 'test-room', clientIds: [] };
160
+ await storage.createRoom(room);
161
+
162
+ const retrieved = await storage.getRoom('test-room');
163
+
164
+ expect(retrieved).not.toBeNull();
165
+ expect(retrieved?.name).toBe('test-room');
166
+ });
167
+
168
+ it('should create room with initial members', async () => {
169
+ await storage.addClient(createClient('client1'));
170
+ await storage.addClient(createClient('client2'));
171
+
172
+ const room: WsRoom = { name: 'room-with-members', clientIds: ['client1', 'client2'] };
173
+ await storage.createRoom(room);
174
+
175
+ const retrieved = await storage.getRoom('room-with-members');
176
+
177
+ expect(retrieved?.clientIds).toContain('client1');
178
+ expect(retrieved?.clientIds).toContain('client2');
179
+ });
180
+
181
+ it('should delete a room', async () => {
182
+ const room: WsRoom = { name: 'test-room', clientIds: [] };
183
+ await storage.createRoom(room);
184
+
185
+ await storage.deleteRoom('test-room');
186
+
187
+ const result = await storage.getRoom('test-room');
188
+ expect(result).toBeNull();
189
+ });
190
+
191
+ it('should delete room and clean up client references', async () => {
192
+ await storage.addClient(createClient('client1'));
193
+ await storage.addClientToRoom('client1', 'cleanup-room');
194
+
195
+ await storage.deleteRoom('cleanup-room');
196
+
197
+ const rooms = await storage.getRoomsForClient('client1');
198
+ expect(rooms).not.toContain('cleanup-room');
199
+ });
200
+
201
+ it('should not throw when deleting non-existent room', async () => {
202
+ // Should not throw
203
+ await storage.deleteRoom('non-existent');
204
+ });
205
+
206
+ it('should get all rooms', async () => {
207
+ await storage.createRoom({ name: 'room1', clientIds: [] });
208
+ await storage.createRoom({ name: 'room2', clientIds: [] });
209
+
210
+ const rooms = await storage.getAllRooms();
211
+
212
+ expect(rooms).toHaveLength(2);
213
+ });
214
+
215
+ it('should get rooms by pattern', async () => {
216
+ await storage.createRoom({ name: 'chat:general', clientIds: [] });
217
+ await storage.createRoom({ name: 'chat:private', clientIds: [] });
218
+ await storage.createRoom({ name: 'game:lobby', clientIds: [] });
219
+
220
+ const chatRooms = await storage.getRoomsByPattern('chat:*');
221
+
222
+ expect(chatRooms).toHaveLength(2);
223
+ expect(chatRooms.map((r) => r.name)).toContain('chat:general');
224
+ expect(chatRooms.map((r) => r.name)).toContain('chat:private');
225
+ });
226
+
227
+ it('should update room metadata', async () => {
228
+ await storage.createRoom({ name: 'test-room', clientIds: [] });
229
+
230
+ await storage.updateRoomMetadata('test-room', { topic: 'General Discussion' });
231
+
232
+ const room = await storage.getRoom('test-room');
233
+ expect(room?.metadata).toEqual({ topic: 'General Discussion' });
234
+ });
235
+
236
+ it('should not update metadata for non-existent room', async () => {
237
+ // Should not throw
238
+ await storage.updateRoomMetadata('non-existent', { test: 1 });
239
+ });
240
+
241
+ it('should handle malformed JSON in room data', async () => {
242
+ // Manually insert invalid JSON
243
+ const client = await SharedRedisProvider.getClient();
244
+ await client.set('rooms:invalid', 'not-json');
245
+
246
+ const result = await storage.getRoom('invalid');
247
+
248
+ expect(result).toBeNull();
249
+ });
250
+ });
251
+
252
+ describe('room membership', () => {
253
+ it('should add client to room', async () => {
254
+ await storage.addClient(createClient('client1'));
255
+ await storage.addClientToRoom('client1', 'test-room');
256
+
257
+ const clientIds = await storage.getClientsInRoom('test-room');
258
+
259
+ expect(clientIds).toContain('client1');
260
+ });
261
+
262
+ it('should create room automatically when adding client', async () => {
263
+ await storage.addClient(createClient('client1'));
264
+ await storage.addClientToRoom('client1', 'auto-room');
265
+
266
+ const room = await storage.getRoom('auto-room');
267
+
268
+ expect(room).not.toBeNull();
269
+ });
270
+
271
+ it('should update client rooms list', async () => {
272
+ await storage.addClient(createClient('client1'));
273
+ await storage.addClientToRoom('client1', 'room1');
274
+ await storage.addClientToRoom('client1', 'room2');
275
+
276
+ const client = await storage.getClient('client1');
277
+
278
+ expect(client?.rooms).toContain('room1');
279
+ expect(client?.rooms).toContain('room2');
280
+ });
281
+
282
+ it('should not duplicate room in client rooms list', async () => {
283
+ await storage.addClient(createClient('client1'));
284
+ await storage.addClientToRoom('client1', 'room1');
285
+ await storage.addClientToRoom('client1', 'room1'); // Add again
286
+
287
+ const client = await storage.getClient('client1');
288
+
289
+ expect(client?.rooms.filter((r) => r === 'room1')).toHaveLength(1);
290
+ });
291
+
292
+ it('should remove client from room', async () => {
293
+ await storage.addClient(createClient('client1'));
294
+ await storage.addClientToRoom('client1', 'test-room');
295
+
296
+ await storage.removeClientFromRoom('client1', 'test-room');
297
+
298
+ const clientIds = await storage.getClientsInRoom('test-room');
299
+ expect(clientIds).not.toContain('client1');
300
+ });
301
+
302
+ it('should update client rooms list on removal', async () => {
303
+ await storage.addClient(createClient('client1'));
304
+ await storage.addClientToRoom('client1', 'test-room');
305
+
306
+ await storage.removeClientFromRoom('client1', 'test-room');
307
+
308
+ const client = await storage.getClient('client1');
309
+ expect(client?.rooms).not.toContain('test-room');
310
+ });
311
+
312
+ it('should delete empty room after client leaves', async () => {
313
+ await storage.addClient(createClient('client1'));
314
+ await storage.addClientToRoom('client1', 'temp-room');
315
+
316
+ await storage.removeClientFromRoom('client1', 'temp-room');
317
+
318
+ const room = await storage.getRoom('temp-room');
319
+ expect(room).toBeNull();
320
+ });
321
+
322
+ it('should keep room if other clients remain', async () => {
323
+ await storage.addClient(createClient('client1'));
324
+ await storage.addClient(createClient('client2'));
325
+ await storage.addClientToRoom('client1', 'shared-room');
326
+ await storage.addClientToRoom('client2', 'shared-room');
327
+
328
+ await storage.removeClientFromRoom('client1', 'shared-room');
329
+
330
+ const room = await storage.getRoom('shared-room');
331
+ expect(room).not.toBeNull();
332
+ expect(room?.clientIds).toContain('client2');
333
+ });
334
+
335
+ it('should get rooms for a client', async () => {
336
+ await storage.addClient(createClient('client1'));
337
+ await storage.addClientToRoom('client1', 'room1');
338
+ await storage.addClientToRoom('client1', 'room2');
339
+
340
+ const rooms = await storage.getRoomsForClient('client1');
341
+
342
+ expect(rooms).toHaveLength(2);
343
+ expect(rooms).toContain('room1');
344
+ expect(rooms).toContain('room2');
345
+ });
346
+
347
+ it('should remove client from all rooms', async () => {
348
+ await storage.addClient(createClient('client1'));
349
+ await storage.addClientToRoom('client1', 'room1');
350
+ await storage.addClientToRoom('client1', 'room2');
351
+
352
+ await storage.removeClientFromAllRooms('client1');
353
+
354
+ const rooms = await storage.getRoomsForClient('client1');
355
+ expect(rooms).toHaveLength(0);
356
+ });
357
+
358
+ it('should remove client from all rooms when client is removed', async () => {
359
+ await storage.addClient(createClient('client1'));
360
+ await storage.addClient(createClient('client2'));
361
+ await storage.addClientToRoom('client1', 'shared-room');
362
+ await storage.addClientToRoom('client2', 'shared-room');
363
+
364
+ await storage.removeClient('client1');
365
+
366
+ const clientIds = await storage.getClientsInRoom('shared-room');
367
+ expect(clientIds).not.toContain('client1');
368
+ expect(clientIds).toContain('client2');
369
+ });
370
+ });
371
+
372
+ describe('pub/sub', () => {
373
+ it('should subscribe and receive events', async () => {
374
+ const received: WsStorageEventPayload[] = [];
375
+
376
+ await storage.subscribe((payload) => {
377
+ received.push(payload);
378
+ });
379
+
380
+ // Create second storage instance to publish
381
+ const client2 = await SharedRedisProvider.getClient();
382
+ const storage2 = createRedisWsStorage(client2);
383
+
384
+ await storage2.publish({
385
+ type: WsStorageEvent.BROADCAST,
386
+ sourceInstanceId: 'test-instance',
387
+ data: {
388
+ event: 'test:event',
389
+ message: { hello: 'world' },
390
+ },
391
+ });
392
+
393
+ // Wait for pub/sub delivery
394
+ await new Promise((r) => setTimeout(r, 30));
395
+
396
+ expect(received).toHaveLength(1);
397
+ expect(received[0].data.event).toBe('test:event');
398
+ expect(received[0].data.message).toEqual({ hello: 'world' });
399
+
400
+ await storage2.close();
401
+ });
402
+
403
+ it('should allow multiple handlers', async () => {
404
+ const handler1 = mock(() => undefined);
405
+ const handler2 = mock(() => undefined);
406
+
407
+ await storage.subscribe(handler1);
408
+ await storage.subscribe(handler2);
409
+
410
+ // Publish from another instance
411
+ const client2 = await SharedRedisProvider.getClient();
412
+ const storage2 = createRedisWsStorage(client2);
413
+
414
+ await storage2.publish({
415
+ type: WsStorageEvent.BROADCAST,
416
+ sourceInstanceId: 'test-instance',
417
+ data: {
418
+ event: 'test:multi',
419
+ },
420
+ });
421
+
422
+ await new Promise((r) => setTimeout(r, 30));
423
+
424
+ expect(handler1).toHaveBeenCalled();
425
+ expect(handler2).toHaveBeenCalled();
426
+
427
+ await storage2.close();
428
+ });
429
+
430
+ it('should unsubscribe and clear handlers', async () => {
431
+ const handler = mock(() => undefined);
432
+
433
+ await storage.subscribe(handler);
434
+ await storage.unsubscribe();
435
+
436
+ // After unsubscribe, no more messages should be received
437
+ const client2 = await SharedRedisProvider.getClient();
438
+ const storage2 = createRedisWsStorage(client2);
439
+
440
+ await storage2.publish({
441
+ type: WsStorageEvent.BROADCAST,
442
+ sourceInstanceId: 'test-instance',
443
+ data: {
444
+ event: 'test:after-unsub',
445
+ },
446
+ });
447
+
448
+ await new Promise((r) => setTimeout(r, 30));
449
+
450
+ // Handler should not receive new messages after unsubscribe
451
+ await storage2.close();
452
+ });
453
+
454
+ it('should handle invalid JSON in pub/sub messages', async () => {
455
+ const handler = mock(() => undefined);
456
+ await storage.subscribe(handler);
457
+
458
+ // Manually publish invalid JSON
459
+ const client = await SharedRedisProvider.getClient();
460
+ await client.publish('ws:events', 'not-json');
461
+
462
+ await new Promise((r) => setTimeout(r, 30));
463
+
464
+ // Handler should not be called for invalid messages
465
+ // (or if called, shouldn't crash)
466
+ });
467
+
468
+ it('should handle errors in event handlers', async () => {
469
+ const errorHandler = mock(() => {
470
+ throw new Error('Handler error');
471
+ });
472
+ const normalHandler = mock(() => undefined);
473
+
474
+ await storage.subscribe(errorHandler);
475
+ await storage.subscribe(normalHandler);
476
+
477
+ const client2 = await SharedRedisProvider.getClient();
478
+ const storage2 = createRedisWsStorage(client2);
479
+
480
+ await storage2.publish({
481
+ type: WsStorageEvent.BROADCAST,
482
+ sourceInstanceId: 'test-instance',
483
+ data: {
484
+ event: 'test:error',
485
+ },
486
+ });
487
+
488
+ await new Promise((r) => setTimeout(r, 30));
489
+
490
+ // Both handlers should be called despite error in first
491
+ expect(errorHandler).toHaveBeenCalled();
492
+ expect(normalHandler).toHaveBeenCalled();
493
+
494
+ await storage2.close();
495
+ });
496
+ });
497
+
498
+ describe('lifecycle', () => {
499
+ it('should clear all data', async () => {
500
+ await storage.addClient(createClient('client1'));
501
+ await storage.createRoom({ name: 'test-room', clientIds: [] });
502
+
503
+ await storage.clear();
504
+
505
+ expect(await storage.getClientCount()).toBe(0);
506
+ expect(await storage.getAllRooms()).toHaveLength(0);
507
+ });
508
+
509
+ it('should close without disconnecting shared client', async () => {
510
+ await storage.close();
511
+
512
+ // Shared client should still be connected
513
+ const client = await SharedRedisProvider.getClient();
514
+ expect(client.isConnected()).toBe(true);
515
+ });
516
+ });
517
+ });