@onebun/core 0.1.1 → 0.1.2

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,517 @@
1
+ /**
2
+ * Integration tests for WebSocket Gateway + Client
3
+ *
4
+ * Tests the full cycle of WebSocket communication including:
5
+ * - Server startup with WebSocket Gateway
6
+ * - Client connection and message exchange
7
+ * - Room management
8
+ * - Event patterns
9
+ */
10
+
11
+ import {
12
+ describe,
13
+ it,
14
+ expect,
15
+ beforeAll,
16
+ afterAll,
17
+ beforeEach,
18
+ afterEach,
19
+ } from 'bun:test';
20
+
21
+ import type { WsClientData } from './ws.types';
22
+
23
+ import { OneBunApplication } from './application';
24
+ import { Module } from './decorators';
25
+ import { BaseWebSocketGateway } from './ws-base-gateway';
26
+ import { createWsClient } from './ws-client';
27
+ import {
28
+ WebSocketGateway,
29
+ OnConnect,
30
+ OnDisconnect,
31
+ OnMessage,
32
+ OnJoinRoom,
33
+ OnLeaveRoom,
34
+ Client,
35
+ MessageData,
36
+ RoomName,
37
+ PatternParams,
38
+ UseWsGuards,
39
+ } from './ws-decorators';
40
+ import { WsAuthGuard } from './ws-guards';
41
+ import { createWsServiceDefinition } from './ws-service-definition';
42
+
43
+ // Test port - use a high port to avoid conflicts
44
+ const TEST_PORT = 19876;
45
+ const TEST_URL = `ws://localhost:${TEST_PORT}/ws`;
46
+
47
+ // Test Gateway implementation
48
+ @WebSocketGateway({ path: '/ws' })
49
+ class TestGateway extends BaseWebSocketGateway {
50
+ public connectCount = 0;
51
+ public disconnectCount = 0;
52
+ public lastMessage: unknown = null;
53
+ public lastClient: WsClientData | null = null;
54
+
55
+ @OnConnect()
56
+ handleConnect(@Client() client: WsClientData) {
57
+ this.connectCount++;
58
+ this.lastClient = client;
59
+
60
+ return { event: 'welcome', data: { message: 'Welcome!', clientId: client.id } };
61
+ }
62
+
63
+ @OnDisconnect()
64
+ handleDisconnect(@Client() client: WsClientData) {
65
+ this.disconnectCount++;
66
+ this.lastClient = client;
67
+ }
68
+
69
+ @OnMessage('echo')
70
+ handleEcho(@MessageData() data: unknown) {
71
+ this.lastMessage = data;
72
+
73
+ return { event: 'echo:response', data };
74
+ }
75
+
76
+ @OnMessage('broadcast')
77
+ handleBroadcast(@Client() client: WsClientData, @MessageData() data: { text: string }) {
78
+ this.broadcast('broadcast:message', { from: client.id, text: data.text });
79
+ }
80
+
81
+ @OnMessage('chat:{roomId}:message')
82
+ handleChatMessage(
83
+ @Client() client: WsClientData,
84
+ @MessageData() data: { text: string },
85
+ @PatternParams() params: { roomId: string },
86
+ ) {
87
+ this.emitToRoom(`room:${params.roomId}`, 'chat:message', {
88
+ roomId: params.roomId,
89
+ from: client.id,
90
+ text: data.text,
91
+ });
92
+
93
+ return { event: 'chat:sent', data: { roomId: params.roomId } };
94
+ }
95
+
96
+ @OnJoinRoom()
97
+ async handleJoinRoom(@Client() client: WsClientData, @RoomName() room: string) {
98
+ await this.joinRoom(client.id, room);
99
+
100
+ return { event: 'room:joined', data: { room } };
101
+ }
102
+
103
+ @OnLeaveRoom()
104
+ async handleLeaveRoom(@Client() client: WsClientData, @RoomName() room: string) {
105
+ await this.leaveRoom(client.id, room);
106
+
107
+ return { event: 'room:left', data: { room } };
108
+ }
109
+
110
+ @UseWsGuards(WsAuthGuard)
111
+ @OnMessage('protected')
112
+ handleProtected(@Client() client: WsClientData) {
113
+ return { event: 'protected:response', data: { userId: client.auth?.userId } };
114
+ }
115
+ }
116
+
117
+ // Test Module
118
+ @Module({
119
+ controllers: [TestGateway],
120
+ })
121
+ class TestModule {}
122
+
123
+ describe('WebSocket Integration', () => {
124
+ let app: OneBunApplication;
125
+ let definition: ReturnType<typeof createWsServiceDefinition>;
126
+
127
+ beforeAll(async () => {
128
+ // Start the application
129
+ app = new OneBunApplication(TestModule, {
130
+ port: TEST_PORT,
131
+ development: true,
132
+ });
133
+ await app.start();
134
+
135
+ // Create service definition for typed client
136
+ definition = createWsServiceDefinition(TestModule);
137
+ });
138
+
139
+ afterAll(async () => {
140
+ await app.stop();
141
+ });
142
+
143
+ describe('basic connection', () => {
144
+ it('should connect and receive welcome message', async () => {
145
+ const client = createWsClient(definition, { url: TEST_URL });
146
+
147
+ let welcomeReceived = false;
148
+ let welcomeData: unknown = null;
149
+
150
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
151
+ (client as any).TestGateway.on('welcome', (data: unknown) => {
152
+ welcomeReceived = true;
153
+ welcomeData = data;
154
+ });
155
+
156
+ await client.connect();
157
+
158
+ // Wait for welcome message
159
+ await new Promise((resolve) => setTimeout(resolve, 50));
160
+
161
+ expect(welcomeReceived).toBe(true);
162
+ expect(welcomeData).toHaveProperty('message', 'Welcome!');
163
+ expect(welcomeData).toHaveProperty('clientId');
164
+
165
+ client.disconnect();
166
+ });
167
+
168
+ it('should disconnect cleanly', async () => {
169
+ const client = createWsClient(definition, { url: TEST_URL });
170
+
171
+ await client.connect();
172
+ expect(client.isConnected()).toBe(true);
173
+
174
+ client.disconnect();
175
+ expect(client.isConnected()).toBe(false);
176
+ });
177
+ });
178
+
179
+ describe('message exchange', () => {
180
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
181
+ let client: any;
182
+
183
+ beforeEach(async () => {
184
+ client = createWsClient(definition, { url: TEST_URL, timeout: 2000 });
185
+ await client.connect();
186
+ // Wait for connection to stabilize
187
+ await new Promise((resolve) => setTimeout(resolve, 50));
188
+ });
189
+
190
+ afterEach(() => {
191
+ if (client?.isConnected()) {
192
+ client.disconnect();
193
+ }
194
+ });
195
+
196
+ it('should echo messages', async () => {
197
+ const testData = { hello: 'world', number: 42 };
198
+
199
+ let responseReceived = false;
200
+ let responseData: unknown = null;
201
+
202
+ client.TestGateway.on('echo:response', (data: unknown) => {
203
+ responseReceived = true;
204
+ responseData = data;
205
+ });
206
+
207
+ client.TestGateway.send('echo', testData);
208
+
209
+ // Wait for response
210
+ await new Promise((resolve) => setTimeout(resolve, 100));
211
+
212
+ expect(responseReceived).toBe(true);
213
+ expect(responseData).toEqual(testData);
214
+ });
215
+
216
+ it('should handle emit with acknowledgement', async () => {
217
+ const testData = { test: 'data' };
218
+
219
+ const response = await client.TestGateway.emit('echo', testData);
220
+
221
+ // Response should be the acknowledgement
222
+ expect(response).toBeDefined();
223
+ });
224
+ });
225
+
226
+ describe('room management', () => {
227
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
228
+ let client1: any;
229
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
230
+ let client2: any;
231
+
232
+ beforeEach(async () => {
233
+ client1 = createWsClient(definition, { url: TEST_URL, timeout: 2000 });
234
+ client2 = createWsClient(definition, { url: TEST_URL, timeout: 2000 });
235
+ await client1.connect();
236
+ await client2.connect();
237
+ await new Promise((resolve) => setTimeout(resolve, 50));
238
+ });
239
+
240
+ afterEach(() => {
241
+ if (client1?.isConnected()) {
242
+ client1.disconnect();
243
+ }
244
+ if (client2?.isConnected()) {
245
+ client2.disconnect();
246
+ }
247
+ });
248
+
249
+ it('should join and leave rooms', async () => {
250
+ // Use emit with acknowledgement for more reliable testing
251
+ const joinResponse = await client1.TestGateway.emit('join', 'room:test');
252
+ // Handler returns { event: 'room:joined', data: { room } }
253
+ // With ack, we get the full response
254
+ expect(joinResponse).toBeDefined();
255
+
256
+ const leaveResponse = await client1.TestGateway.emit('leave', 'room:test');
257
+ expect(leaveResponse).toBeDefined();
258
+ });
259
+
260
+ it('should broadcast messages to room members', async () => {
261
+ const roomName = 'room:chat';
262
+ let client2ReceivedMessage = false;
263
+ let receivedData: unknown = null;
264
+
265
+ // Both clients join the same room
266
+ client1.TestGateway.send('join', roomName);
267
+ client2.TestGateway.send('join', roomName);
268
+ await new Promise((resolve) => setTimeout(resolve, 100));
269
+
270
+ // Client2 listens for messages
271
+ client2.TestGateway.on('chat:message', (data: unknown) => {
272
+ client2ReceivedMessage = true;
273
+ receivedData = data;
274
+ });
275
+
276
+ // Client1 sends a message to the room
277
+ client1.TestGateway.send('chat:chat:message', { text: 'Hello room!' });
278
+ await new Promise((resolve) => setTimeout(resolve, 100));
279
+
280
+ expect(client2ReceivedMessage).toBe(true);
281
+ expect(receivedData).toHaveProperty('text', 'Hello room!');
282
+ });
283
+ });
284
+
285
+ describe('pattern matching', () => {
286
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
287
+ let client: any;
288
+
289
+ beforeEach(async () => {
290
+ client = createWsClient(definition, { url: TEST_URL, timeout: 2000 });
291
+ await client.connect();
292
+ await new Promise((resolve) => setTimeout(resolve, 50));
293
+ });
294
+
295
+ afterEach(() => {
296
+ if (client?.isConnected()) {
297
+ client.disconnect();
298
+ }
299
+ });
300
+
301
+ it('should match parameterized patterns', async () => {
302
+ let sentResponse: unknown = null;
303
+
304
+ client.TestGateway.on('chat:sent', (data: unknown) => {
305
+ sentResponse = data;
306
+ });
307
+
308
+ // Send message to chat:room123:message pattern
309
+ client.TestGateway.send('chat:room123:message', { text: 'Hello!' });
310
+ await new Promise((resolve) => setTimeout(resolve, 100));
311
+
312
+ expect(sentResponse).toHaveProperty('roomId', 'room123');
313
+ });
314
+ });
315
+
316
+ describe('multiple clients', () => {
317
+ it('should handle multiple simultaneous connections', async () => {
318
+ const clients = [];
319
+ const connectedClients: string[] = [];
320
+
321
+ // Create 5 clients
322
+ for (let i = 0; i < 5; i++) {
323
+ const client = createWsClient(definition, { url: TEST_URL });
324
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
325
+ (client as any).TestGateway.on('welcome', (data: { clientId: string }) => {
326
+ connectedClients.push(data.clientId);
327
+ });
328
+ clients.push(client);
329
+ }
330
+
331
+ // Connect all clients
332
+ await Promise.all(clients.map((c) => c.connect()));
333
+ await new Promise((resolve) => setTimeout(resolve, 100));
334
+
335
+ expect(connectedClients.length).toBe(5);
336
+
337
+ // All client IDs should be unique
338
+ const uniqueIds = new Set(connectedClients);
339
+ expect(uniqueIds.size).toBe(5);
340
+
341
+ // Disconnect all
342
+ clients.forEach((c) => c.disconnect());
343
+ });
344
+
345
+ it('should broadcast to all clients', async () => {
346
+ const client1 = createWsClient(definition, { url: TEST_URL });
347
+ const client2 = createWsClient(definition, { url: TEST_URL });
348
+
349
+ let client1Received = false;
350
+ let client2Received = false;
351
+
352
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
353
+ (client1 as any).TestGateway.on('broadcast:message', () => {
354
+ client1Received = true;
355
+ });
356
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
357
+ (client2 as any).TestGateway.on('broadcast:message', () => {
358
+ client2Received = true;
359
+ });
360
+
361
+ await client1.connect();
362
+ await client2.connect();
363
+ await new Promise((resolve) => setTimeout(resolve, 50));
364
+
365
+ // Client1 broadcasts
366
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
367
+ (client1 as any).TestGateway.send('broadcast', { text: 'Hello everyone!' });
368
+ await new Promise((resolve) => setTimeout(resolve, 100));
369
+
370
+ expect(client1Received).toBe(true);
371
+ expect(client2Received).toBe(true);
372
+
373
+ client1.disconnect();
374
+ client2.disconnect();
375
+ });
376
+ });
377
+
378
+ describe('authentication and guards', () => {
379
+ it('should reject protected handler without auth', async () => {
380
+ // Client without auth token
381
+ const client = createWsClient(definition, {
382
+ url: TEST_URL,
383
+ timeout: 2000,
384
+ });
385
+
386
+ await client.connect();
387
+ await new Promise((resolve) => setTimeout(resolve, 50));
388
+
389
+ // Try to access protected endpoint without auth - set up error listener
390
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
391
+ (client as any).TestGateway.on('error', () => {
392
+ // Error expected when auth fails
393
+ });
394
+
395
+ // Send to protected endpoint - should not receive response
396
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
397
+ (client as any).TestGateway.send('protected', {});
398
+ await new Promise((resolve) => setTimeout(resolve, 100));
399
+
400
+ // We should not receive protected:response since guard blocks it
401
+ let protectedResponseReceived = false;
402
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
403
+ (client as any).TestGateway.on('protected:response', () => {
404
+ protectedResponseReceived = true;
405
+ });
406
+
407
+ expect(protectedResponseReceived).toBe(false);
408
+
409
+ client.disconnect();
410
+ });
411
+
412
+ it('should allow protected handler with valid auth token', async () => {
413
+ // Client with auth token
414
+ const client = createWsClient(definition, {
415
+ url: TEST_URL,
416
+ timeout: 2000,
417
+ auth: {
418
+ token: 'valid-test-token',
419
+ },
420
+ });
421
+
422
+ await client.connect();
423
+ await new Promise((resolve) => setTimeout(resolve, 50));
424
+
425
+ let responseReceived = false;
426
+ let responseData: { userId?: string } | null = null;
427
+
428
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
429
+ (client as any).TestGateway.on('protected:response', (data: { userId?: string }) => {
430
+ responseReceived = true;
431
+ responseData = data;
432
+ });
433
+
434
+ // Send to protected endpoint with auth
435
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
436
+ (client as any).TestGateway.send('protected', {});
437
+ await new Promise((resolve) => setTimeout(resolve, 100));
438
+
439
+ // With auth token, the request should be allowed
440
+ expect(responseReceived).toBe(true);
441
+ expect(responseData).toBeDefined();
442
+
443
+ client.disconnect();
444
+ });
445
+
446
+ it('should pass auth data to handler via client object', async () => {
447
+ const testToken = 'test-user-token-123';
448
+ const client = createWsClient(definition, {
449
+ url: TEST_URL,
450
+ timeout: 2000,
451
+ auth: {
452
+ token: testToken,
453
+ },
454
+ });
455
+
456
+ await client.connect();
457
+ await new Promise((resolve) => setTimeout(resolve, 50));
458
+
459
+ let responseData: { userId?: string } | null = null;
460
+
461
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
462
+ (client as any).TestGateway.on('protected:response', (data: { userId?: string }) => {
463
+ responseData = data;
464
+ });
465
+
466
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
467
+ (client as any).TestGateway.send('protected', {});
468
+ await new Promise((resolve) => setTimeout(resolve, 100));
469
+
470
+ // Auth data should be available in handler
471
+ expect(responseData).toBeDefined();
472
+
473
+ client.disconnect();
474
+ });
475
+
476
+ it('should handle multiple authenticated clients independently', async () => {
477
+ const client1 = createWsClient(definition, {
478
+ url: TEST_URL,
479
+ auth: { token: 'user1-token' },
480
+ });
481
+ const client2 = createWsClient(definition, {
482
+ url: TEST_URL,
483
+ auth: { token: 'user2-token' },
484
+ });
485
+
486
+ await client1.connect();
487
+ await client2.connect();
488
+ await new Promise((resolve) => setTimeout(resolve, 50));
489
+
490
+ let client1Response = false;
491
+ let client2Response = false;
492
+
493
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
494
+ (client1 as any).TestGateway.on('protected:response', () => {
495
+ client1Response = true;
496
+ });
497
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
498
+ (client2 as any).TestGateway.on('protected:response', () => {
499
+ client2Response = true;
500
+ });
501
+
502
+ // Both clients access protected endpoint
503
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
504
+ (client1 as any).TestGateway.send('protected', {});
505
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
506
+ (client2 as any).TestGateway.send('protected', {});
507
+ await new Promise((resolve) => setTimeout(resolve, 100));
508
+
509
+ // Both should receive responses
510
+ expect(client1Response).toBe(true);
511
+ expect(client2Response).toBe(true);
512
+
513
+ client1.disconnect();
514
+ client2.disconnect();
515
+ });
516
+ });
517
+ });
@@ -0,0 +1,152 @@
1
+ /**
2
+ * WebSocket Pattern Matcher Tests
3
+ */
4
+
5
+ import {
6
+ describe,
7
+ it,
8
+ expect,
9
+ } from 'bun:test';
10
+
11
+ import {
12
+ matchPattern,
13
+ isPatternMatch,
14
+ createPatternMatcher,
15
+ isPattern,
16
+ getPatternParams,
17
+ buildFromPattern,
18
+ findMatchingValues,
19
+ } from './ws-pattern-matcher';
20
+
21
+ describe('ws-pattern-matcher', () => {
22
+ describe('matchPattern', () => {
23
+ it('should match exact strings', () => {
24
+ const result = matchPattern('chat:message', 'chat:message');
25
+ expect(result.matched).toBe(true);
26
+ expect(result.params).toEqual({});
27
+ });
28
+
29
+ it('should not match different strings', () => {
30
+ const result = matchPattern('chat:message', 'chat:other');
31
+ expect(result.matched).toBe(false);
32
+ });
33
+
34
+ it('should match wildcard (*) patterns', () => {
35
+ const result = matchPattern('chat:*', 'chat:general');
36
+ expect(result.matched).toBe(true);
37
+ expect(result.params).toEqual({});
38
+ });
39
+
40
+ it('should match wildcard in the middle', () => {
41
+ const result = matchPattern('user:*:action', 'user:123:action');
42
+ expect(result.matched).toBe(true);
43
+ });
44
+
45
+ it('should not match wildcard with extra segments', () => {
46
+ const result = matchPattern('chat:*', 'chat:general:extra');
47
+ expect(result.matched).toBe(false);
48
+ });
49
+
50
+ it('should extract named parameters', () => {
51
+ const result = matchPattern('chat:{roomId}', 'chat:general');
52
+ expect(result.matched).toBe(true);
53
+ expect(result.params).toEqual({ roomId: 'general' });
54
+ });
55
+
56
+ it('should extract multiple parameters', () => {
57
+ const result = matchPattern('user:{userId}:room:{roomId}', 'user:123:room:general');
58
+ expect(result.matched).toBe(true);
59
+ expect(result.params).toEqual({ userId: '123', roomId: 'general' });
60
+ });
61
+
62
+ it('should handle combined wildcard and parameters', () => {
63
+ const result = matchPattern('service:{service}:*', 'service:auth:login');
64
+ expect(result.matched).toBe(true);
65
+ expect(result.params).toEqual({ service: 'auth' });
66
+ });
67
+
68
+ it('should not match when parameter value contains separator', () => {
69
+ const result = matchPattern('chat:{roomId}', 'chat:room:sub');
70
+ expect(result.matched).toBe(false);
71
+ });
72
+ });
73
+
74
+ describe('isPatternMatch', () => {
75
+ it('should return true for matching patterns', () => {
76
+ expect(isPatternMatch('chat:*', 'chat:general')).toBe(true);
77
+ });
78
+
79
+ it('should return false for non-matching patterns', () => {
80
+ expect(isPatternMatch('chat:*', 'room:general')).toBe(false);
81
+ });
82
+ });
83
+
84
+ describe('createPatternMatcher', () => {
85
+ it('should create reusable matcher function', () => {
86
+ const matcher = createPatternMatcher('chat:{roomId}:message');
87
+
88
+ const result1 = matcher('chat:general:message');
89
+ expect(result1.matched).toBe(true);
90
+ expect(result1.params).toEqual({ roomId: 'general' });
91
+
92
+ const result2 = matcher('chat:private:message');
93
+ expect(result2.matched).toBe(true);
94
+ expect(result2.params).toEqual({ roomId: 'private' });
95
+
96
+ const result3 = matcher('chat:other:action');
97
+ expect(result3.matched).toBe(false);
98
+ });
99
+ });
100
+
101
+ describe('isPattern', () => {
102
+ it('should return true for wildcard patterns', () => {
103
+ expect(isPattern('chat:*')).toBe(true);
104
+ });
105
+
106
+ it('should return true for parameter patterns', () => {
107
+ expect(isPattern('chat:{roomId}')).toBe(true);
108
+ });
109
+
110
+ it('should return false for literal strings', () => {
111
+ expect(isPattern('chat:general')).toBe(false);
112
+ });
113
+ });
114
+
115
+ describe('getPatternParams', () => {
116
+ it('should extract parameter names', () => {
117
+ const params = getPatternParams('chat:{roomId}:user:{userId}');
118
+ expect(params).toEqual(['roomId', 'userId']);
119
+ });
120
+
121
+ it('should return empty array for patterns without parameters', () => {
122
+ const params = getPatternParams('chat:*');
123
+ expect(params).toEqual([]);
124
+ });
125
+ });
126
+
127
+ describe('buildFromPattern', () => {
128
+ it('should build value from pattern and params', () => {
129
+ const result = buildFromPattern('chat:{roomId}:message', { roomId: 'general' });
130
+ expect(result).toBe('chat:general:message');
131
+ });
132
+
133
+ it('should handle multiple parameters', () => {
134
+ const result = buildFromPattern('user:{id}:room:{room}', { id: '123', room: 'test' });
135
+ expect(result).toBe('user:123:room:test');
136
+ });
137
+ });
138
+
139
+ describe('findMatchingValues', () => {
140
+ it('should find all matching values', () => {
141
+ const values = ['chat:general', 'chat:private', 'room:lobby', 'chat:admin'];
142
+ const matching = findMatchingValues('chat:*', values);
143
+ expect(matching).toEqual(['chat:general', 'chat:private', 'chat:admin']);
144
+ });
145
+
146
+ it('should return empty array when nothing matches', () => {
147
+ const values = ['room:lobby', 'room:admin'];
148
+ const matching = findMatchingValues('chat:*', values);
149
+ expect(matching).toEqual([]);
150
+ });
151
+ });
152
+ });