@onebun/core 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -6
- package/src/application/application.test.ts +92 -0
- package/src/application/application.ts +50 -13
- package/src/docs-examples.test.ts +9 -2
- package/src/index.ts +11 -1
- package/src/module/config.interface.ts +60 -0
- package/src/module/config.service.test.ts +56 -32
- package/src/module/config.service.ts +26 -9
- package/src/module/controller.test.ts +27 -29
- package/src/module/controller.ts +3 -2
- package/src/module/index.ts +1 -0
- package/src/module/module.test.ts +31 -32
- package/src/module/module.ts +9 -5
- package/src/module/service.test.ts +22 -20
- package/src/module/service.ts +5 -3
- package/src/queue/adapters/memory.adapter.test.ts +19 -3
- package/src/queue/adapters/redis.adapter.test.ts +289 -0
- package/src/queue/queue.service.test.ts +240 -0
- package/src/queue/scheduler.test.ts +22 -9
- package/src/redis/shared-redis.test.ts +255 -0
- package/src/testing/test-utils.ts +56 -0
- package/src/websocket/ws-client.test.ts +96 -48
- package/src/websocket/ws-integration.test.ts +21 -19
- package/src/websocket/ws-storage-redis.test.ts +517 -0
|
@@ -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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
+
});
|