@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.
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Shared Redis Provider Tests
3
+ *
4
+ * Tests using testcontainers for real Redis integration
5
+ */
6
+
7
+ import {
8
+ afterAll,
9
+ afterEach,
10
+ beforeAll,
11
+ describe,
12
+ expect,
13
+ it,
14
+ } from 'bun:test';
15
+ import { Effect, pipe } from 'effect';
16
+ import {
17
+ GenericContainer,
18
+ type StartedTestContainer,
19
+ Wait,
20
+ } from 'testcontainers';
21
+
22
+ import {
23
+ SharedRedisProvider,
24
+ SharedRedisService,
25
+ makeSharedRedisLayer,
26
+ getSharedRedis,
27
+ } from './shared-redis';
28
+
29
+ describe('SharedRedisProvider', () => {
30
+ let redisContainer: StartedTestContainer;
31
+ let redisUrl: string;
32
+
33
+ beforeAll(async () => {
34
+ // Start Redis container
35
+ redisContainer = await new GenericContainer('redis:7-alpine')
36
+ .withExposedPorts(6379)
37
+ .withWaitStrategy(Wait.forLogMessage(/.*Ready to accept connections.*/))
38
+ .withStartupTimeout(30000)
39
+ .withLogConsumer(() => {
40
+ // Suppress container logs
41
+ })
42
+ .start();
43
+
44
+ const host = redisContainer.getHost();
45
+ const port = redisContainer.getMappedPort(6379);
46
+ redisUrl = `redis://${host}:${port}`;
47
+ });
48
+
49
+ afterAll(async () => {
50
+ await SharedRedisProvider.reset();
51
+ if (redisContainer) {
52
+ await redisContainer.stop();
53
+ }
54
+ });
55
+
56
+ afterEach(async () => {
57
+ await SharedRedisProvider.reset();
58
+ });
59
+
60
+ describe('configuration', () => {
61
+ it('should configure the provider', () => {
62
+ expect(SharedRedisProvider.isConfigured()).toBe(false);
63
+
64
+ SharedRedisProvider.configure({ url: redisUrl });
65
+
66
+ expect(SharedRedisProvider.isConfigured()).toBe(true);
67
+ });
68
+
69
+ it('should throw when getClient called without configuration', async () => {
70
+ await expect(SharedRedisProvider.getClient()).rejects.toThrow(
71
+ 'SharedRedisProvider not configured',
72
+ );
73
+ });
74
+ });
75
+
76
+ describe('connection', () => {
77
+ it('should connect and return a client', async () => {
78
+ SharedRedisProvider.configure({
79
+ url: redisUrl,
80
+ keyPrefix: 'test:',
81
+ });
82
+
83
+ const client = await SharedRedisProvider.getClient();
84
+
85
+ expect(client).toBeDefined();
86
+ expect(client.isConnected()).toBe(true);
87
+ expect(SharedRedisProvider.isConnected()).toBe(true);
88
+ });
89
+
90
+ it('should return the same instance on multiple calls', async () => {
91
+ SharedRedisProvider.configure({ url: redisUrl });
92
+
93
+ const client1 = await SharedRedisProvider.getClient();
94
+ const client2 = await SharedRedisProvider.getClient();
95
+
96
+ expect(client1).toBe(client2);
97
+ });
98
+
99
+ it('should handle concurrent getClient calls', async () => {
100
+ SharedRedisProvider.configure({ url: redisUrl });
101
+
102
+ // Call getClient multiple times concurrently
103
+ const [client1, client2, client3] = await Promise.all([
104
+ SharedRedisProvider.getClient(),
105
+ SharedRedisProvider.getClient(),
106
+ SharedRedisProvider.getClient(),
107
+ ]);
108
+
109
+ // All should be the same instance
110
+ expect(client1).toBe(client2);
111
+ expect(client2).toBe(client3);
112
+ });
113
+
114
+ it('should disconnect properly', async () => {
115
+ SharedRedisProvider.configure({ url: redisUrl });
116
+
117
+ await SharedRedisProvider.getClient();
118
+ expect(SharedRedisProvider.isConnected()).toBe(true);
119
+
120
+ await SharedRedisProvider.disconnect();
121
+
122
+ expect(SharedRedisProvider.isConnected()).toBe(false);
123
+ });
124
+
125
+ it('should reconnect after disconnect', async () => {
126
+ SharedRedisProvider.configure({ url: redisUrl });
127
+
128
+ const client1 = await SharedRedisProvider.getClient();
129
+ await SharedRedisProvider.disconnect();
130
+
131
+ const client2 = await SharedRedisProvider.getClient();
132
+
133
+ expect(client2).toBeDefined();
134
+ expect(client2.isConnected()).toBe(true);
135
+ // After disconnect and reconnect, we get a new instance
136
+ expect(client1).not.toBe(client2);
137
+ });
138
+ });
139
+
140
+ describe('client operations', () => {
141
+ it('should perform basic Redis operations', async () => {
142
+ SharedRedisProvider.configure({
143
+ url: redisUrl,
144
+ keyPrefix: 'test:ops:',
145
+ });
146
+
147
+ const client = await SharedRedisProvider.getClient();
148
+
149
+ // Set and get
150
+ await client.set('testkey', 'testvalue');
151
+ const value = await client.get('testkey');
152
+ expect(value).toBe('testvalue');
153
+
154
+ // Delete
155
+ await client.del('testkey');
156
+ const deleted = await client.get('testkey');
157
+ expect(deleted).toBeNull();
158
+ });
159
+ });
160
+
161
+ describe('createClient', () => {
162
+ it('should create standalone client with custom URL', async () => {
163
+ SharedRedisProvider.configure({ url: redisUrl });
164
+
165
+ const standalone = SharedRedisProvider.createClient({
166
+ url: redisUrl,
167
+ keyPrefix: 'standalone:',
168
+ });
169
+
170
+ await standalone.connect();
171
+ expect(standalone.isConnected()).toBe(true);
172
+
173
+ // Should be different from shared client
174
+ const shared = await SharedRedisProvider.getClient();
175
+ expect(standalone).not.toBe(shared);
176
+
177
+ await standalone.disconnect();
178
+ });
179
+
180
+ it('should use base options when not specified', async () => {
181
+ SharedRedisProvider.configure({
182
+ url: redisUrl,
183
+ keyPrefix: 'base:',
184
+ reconnect: true,
185
+ });
186
+
187
+ const standalone = SharedRedisProvider.createClient({});
188
+
189
+ await standalone.connect();
190
+ expect(standalone.isConnected()).toBe(true);
191
+ await standalone.disconnect();
192
+ });
193
+
194
+ it('should throw when URL is not available', () => {
195
+ // Don't configure provider
196
+ expect(() => SharedRedisProvider.createClient({})).toThrow('Redis URL is required');
197
+ });
198
+ });
199
+
200
+ describe('reset', () => {
201
+ it('should reset provider state completely', async () => {
202
+ SharedRedisProvider.configure({ url: redisUrl });
203
+ await SharedRedisProvider.getClient();
204
+
205
+ expect(SharedRedisProvider.isConnected()).toBe(true);
206
+ expect(SharedRedisProvider.isConfigured()).toBe(true);
207
+
208
+ await SharedRedisProvider.reset();
209
+
210
+ expect(SharedRedisProvider.isConnected()).toBe(false);
211
+ expect(SharedRedisProvider.isConfigured()).toBe(false);
212
+ });
213
+ });
214
+
215
+ describe('Effect.js integration', () => {
216
+ it('should work with makeSharedRedisLayer', async () => {
217
+ const layer = makeSharedRedisLayer({
218
+ url: redisUrl,
219
+ keyPrefix: 'effect:',
220
+ });
221
+
222
+ const program = pipe(
223
+ SharedRedisService,
224
+ Effect.flatMap((redis) =>
225
+ Effect.promise(async () => {
226
+ await redis.set('effect-test', 'value');
227
+
228
+ return await redis.get('effect-test');
229
+ }),
230
+ ),
231
+ );
232
+
233
+ const result = await Effect.runPromise(Effect.provide(program, layer));
234
+
235
+ expect(result).toBe('value');
236
+ });
237
+
238
+ it('should fail getSharedRedis when not configured', async () => {
239
+ // Reset to ensure not configured
240
+ await SharedRedisProvider.reset();
241
+
242
+ const result = await Effect.runPromiseExit(getSharedRedis);
243
+
244
+ expect(result._tag).toBe('Failure');
245
+ });
246
+
247
+ it('should succeed getSharedRedis when configured', async () => {
248
+ SharedRedisProvider.configure({ url: redisUrl });
249
+
250
+ const result = await Effect.runPromiseExit(getSharedRedis);
251
+
252
+ expect(result._tag).toBe('Success');
253
+ });
254
+ });
255
+ });
@@ -5,6 +5,8 @@
5
5
 
6
6
  import { Effect, Layer } from 'effect';
7
7
 
8
+ import type { IConfig, OneBunAppConfig } from '../module/config.interface';
9
+
8
10
  import type { Logger, SyncLogger } from '@onebun/logger';
9
11
  import { LoggerService } from '@onebun/logger';
10
12
 
@@ -337,3 +339,57 @@ export function createMockSyncLogger(): SyncLogger {
337
339
  export function makeMockLoggerLayer(): Layer.Layer<Logger, never, never> {
338
340
  return Layer.succeed(LoggerService, createMockLogger());
339
341
  }
342
+
343
+ /**
344
+ * Create a mock config for testing.
345
+ * Returns an IConfig-compatible object with customizable values.
346
+ *
347
+ * @param values - Optional object with config values that get() will return
348
+ * @param options - Optional configuration options
349
+ * @returns An IConfig-compatible mock object
350
+ *
351
+ * @example
352
+ * ```typescript
353
+ * const mockConfig = createMockConfig({
354
+ * 'server.port': 3000,
355
+ * 'server.host': '0.0.0.0'
356
+ * });
357
+ * controller.initializeController(mockLogger, mockConfig);
358
+ * ```
359
+ */
360
+ export function createMockConfig(
361
+ values: Record<string, unknown> = {},
362
+ options?: { isInitialized?: boolean },
363
+ ): IConfig<OneBunAppConfig> {
364
+ const isInitialized = options?.isInitialized ?? true;
365
+
366
+ return {
367
+ get(path: string): unknown {
368
+ if (!isInitialized) {
369
+ throw new Error('Configuration not initialized. Provide envSchema in ApplicationOptions.');
370
+ }
371
+
372
+ return values[path];
373
+ },
374
+ get values(): OneBunAppConfig {
375
+ if (!isInitialized) {
376
+ throw new Error('Configuration not initialized. Provide envSchema in ApplicationOptions.');
377
+ }
378
+
379
+ return values as unknown as OneBunAppConfig;
380
+ },
381
+ getSafeConfig(): OneBunAppConfig {
382
+ if (!isInitialized) {
383
+ throw new Error('Configuration not initialized. Provide envSchema in ApplicationOptions.');
384
+ }
385
+
386
+ return values as unknown as OneBunAppConfig;
387
+ },
388
+ get isInitialized(): boolean {
389
+ return isInitialized;
390
+ },
391
+ async initialize(): Promise<void> {
392
+ // No-op for mock
393
+ },
394
+ };
395
+ }
@@ -13,6 +13,8 @@ import {
13
13
 
14
14
  import type { WsServiceDefinition } from './ws-service-definition';
15
15
 
16
+ import { useFakeTimers } from '../testing/test-utils';
17
+
16
18
  import { createWsClient } from './ws-client';
17
19
  import { WsConnectionState } from './ws-client.types';
18
20
  import { WsHandlerType } from './ws.types';
@@ -359,50 +361,84 @@ describe('WsClient', () => {
359
361
  });
360
362
 
361
363
  it('should emit message and wait for acknowledgement', async () => {
362
- const definition = createMockDefinition();
363
- const client = createWsClient(definition, {
364
- url: 'ws://localhost:3000',
365
- timeout: 1000,
366
- });
367
-
368
- await client.connect();
369
-
370
- const ws = MockWebSocket.getLastInstance();
371
-
372
- // Start emit (returns promise)
373
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
374
- const emitPromise = (client as any).TestGateway.emit('test:event', { foo: 'bar' });
375
-
376
- // Simulate server acknowledgement
377
- await new Promise(resolve => setTimeout(resolve, 10));
378
- const sent = JSON.parse(ws!.sentMessages[ws!.sentMessages.length - 1]);
379
- ws?.receiveMessage(JSON.stringify({
380
- event: 'ack',
381
- data: { result: 'ok' },
382
- ack: sent.ack,
383
- }));
384
-
385
- const result = await emitPromise;
386
- expect(result).toEqual({ result: 'ok' });
364
+ const { advanceTime, restore } = useFakeTimers();
365
+
366
+ try {
367
+ const definition = createMockDefinition();
368
+ const client = createWsClient(definition, {
369
+ url: 'ws://localhost:3000',
370
+ timeout: 1000,
371
+ });
372
+
373
+ // Advance time to trigger MockWebSocket's async open
374
+ const connectPromise = client.connect();
375
+ advanceTime(1);
376
+ await connectPromise;
377
+
378
+ const ws = MockWebSocket.getLastInstance();
379
+
380
+ // Start emit (returns promise)
381
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
382
+ const emitPromise = (client as any).TestGateway.emit('test:event', { foo: 'bar' });
383
+
384
+ // Advance time to process sent message
385
+ advanceTime(10);
386
+ const sent = JSON.parse(ws!.sentMessages[ws!.sentMessages.length - 1]);
387
+ ws?.receiveMessage(JSON.stringify({
388
+ event: 'ack',
389
+ data: { result: 'ok' },
390
+ ack: sent.ack,
391
+ }));
392
+
393
+ const result = await emitPromise;
394
+ expect(result).toEqual({ result: 'ok' });
395
+ } finally {
396
+ restore();
397
+ }
387
398
  });
388
399
 
389
400
  it('should timeout emit if no acknowledgement', async () => {
390
- const definition = createMockDefinition();
391
- const client = createWsClient(definition, {
392
- url: 'ws://localhost:3000',
393
- timeout: 50, // Short timeout for test
394
- });
395
-
396
- await client.connect();
397
-
398
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
399
- const emitPromise = (client as any).TestGateway.emit('test:event', { foo: 'bar' });
400
-
401
- await expect(emitPromise).rejects.toThrow('Request timeout');
401
+ const { advanceTime, restore } = useFakeTimers();
402
+
403
+ try {
404
+ const definition = createMockDefinition();
405
+ const client = createWsClient(definition, {
406
+ url: 'ws://localhost:3000',
407
+ timeout: 50, // Short timeout for test
408
+ });
409
+
410
+ // Advance time to trigger MockWebSocket's async open
411
+ const connectPromise = client.connect();
412
+ advanceTime(1);
413
+ await connectPromise;
414
+
415
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
416
+ const emitPromise = (client as any).TestGateway.emit('test:event', { foo: 'bar' });
417
+
418
+ // Advance time past the timeout
419
+ advanceTime(100);
420
+
421
+ await expect(emitPromise).rejects.toThrow('Request timeout');
422
+ } finally {
423
+ restore();
424
+ }
402
425
  });
403
426
  });
404
427
 
405
428
  describe('reconnection', () => {
429
+ let advanceTime: (ms: number) => void;
430
+ let restore: () => void;
431
+
432
+ beforeEach(() => {
433
+ const fakeTimers = useFakeTimers();
434
+ advanceTime = fakeTimers.advanceTime;
435
+ restore = fakeTimers.restore;
436
+ });
437
+
438
+ afterEach(() => {
439
+ restore();
440
+ });
441
+
406
442
  it('should attempt reconnection on disconnect', async () => {
407
443
  const definition = createMockDefinition();
408
444
  const client = createWsClient(definition, {
@@ -413,15 +449,19 @@ describe('WsClient', () => {
413
449
  });
414
450
  const reconnectAttemptHandler = mock(() => undefined);
415
451
 
416
- await client.connect();
452
+ // Advance time to trigger MockWebSocket's async open
453
+ const connectPromise = client.connect();
454
+ advanceTime(1);
455
+ await connectPromise;
456
+
417
457
  client.on('reconnect_attempt', reconnectAttemptHandler);
418
458
 
419
459
  // Simulate server disconnect
420
460
  const ws = MockWebSocket.getLastInstance();
421
461
  ws?.close(1006, 'Connection lost');
422
462
 
423
- // Wait for reconnect attempt
424
- await new Promise(resolve => setTimeout(resolve, 50));
463
+ // Advance time for reconnect attempt
464
+ advanceTime(50);
425
465
 
426
466
  expect(reconnectAttemptHandler).toHaveBeenCalled();
427
467
  });
@@ -436,7 +476,11 @@ describe('WsClient', () => {
436
476
  });
437
477
  let reconnectAttemptCount = 0;
438
478
 
439
- await client.connect();
479
+ // Advance time to trigger MockWebSocket's async open
480
+ const connectPromise = client.connect();
481
+ advanceTime(1);
482
+ await connectPromise;
483
+
440
484
  client.on('reconnect_attempt', (attempt) => {
441
485
  reconnectAttemptCount = attempt;
442
486
  });
@@ -445,8 +489,8 @@ describe('WsClient', () => {
445
489
  const ws = MockWebSocket.getLastInstance();
446
490
  ws?.close(1006, 'Connection lost');
447
491
 
448
- // Wait for first reconnect attempt
449
- await new Promise(resolve => setTimeout(resolve, 50));
492
+ // Advance time for first reconnect attempt
493
+ advanceTime(50);
450
494
 
451
495
  expect(reconnectAttemptCount).toBeGreaterThanOrEqual(1);
452
496
  });
@@ -459,19 +503,23 @@ describe('WsClient', () => {
459
503
  });
460
504
  const reconnectAttemptHandler = mock(() => undefined);
461
505
 
462
- await client.connect();
463
- client.on('reconnect_attempt', reconnectAttemptHandler);
506
+ // Advance time to trigger MockWebSocket's async open
507
+ const connectPromise = client.connect();
508
+ advanceTime(1);
509
+ await connectPromise;
464
510
 
465
- const initialInstanceCount = MockWebSocket.instances.length;
511
+ client.on('reconnect_attempt', reconnectAttemptHandler);
466
512
 
467
513
  // Simulate disconnect
468
514
  const ws = MockWebSocket.getLastInstance();
469
515
  ws?.close(1006, 'Connection lost');
470
516
 
471
- await new Promise(resolve => setTimeout(resolve, 50));
517
+ advanceTime(50);
472
518
 
519
+ // Main assertion: reconnect handler should not be called when reconnect is disabled
473
520
  expect(reconnectAttemptHandler).not.toHaveBeenCalled();
474
- expect(MockWebSocket.instances.length).toBe(initialInstanceCount);
521
+ // Note: We don't check MockWebSocket.instances.length here because it's a static array
522
+ // that can be affected by parallel test runs. The handler check is sufficient.
475
523
  });
476
524
  });
477
525