@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.
@@ -12,6 +12,8 @@ import {
12
12
 
13
13
  import type { Message } from '../types';
14
14
 
15
+ import { useFakeTimers } from '../../testing/test-utils';
16
+
15
17
  import { InMemoryQueueAdapter, createInMemoryQueueAdapter } from './memory.adapter';
16
18
 
17
19
  describe('InMemoryQueueAdapter', () => {
@@ -114,6 +116,19 @@ describe('InMemoryQueueAdapter', () => {
114
116
  });
115
117
 
116
118
  describe('delayed messages', () => {
119
+ let advanceTime: (ms: number) => void;
120
+ let restore: () => void;
121
+
122
+ beforeEach(() => {
123
+ const fakeTimers = useFakeTimers();
124
+ advanceTime = fakeTimers.advanceTime;
125
+ restore = fakeTimers.restore;
126
+ });
127
+
128
+ afterEach(() => {
129
+ restore();
130
+ });
131
+
117
132
  it('should delay message delivery', async () => {
118
133
  await adapter.connect();
119
134
 
@@ -127,8 +142,8 @@ describe('InMemoryQueueAdapter', () => {
127
142
  // Message should not be received immediately
128
143
  expect(received.length).toBe(0);
129
144
 
130
- // Wait for delay + processing
131
- await new Promise((resolve) => setTimeout(resolve, 200));
145
+ // Advance time past the delay + processing
146
+ advanceTime(200);
132
147
 
133
148
  expect(received.length).toBe(1);
134
149
  expect(received[0].data).toEqual({ data: 'delayed' });
@@ -250,6 +265,7 @@ describe('InMemoryQueueAdapter', () => {
250
265
  });
251
266
 
252
267
  it('should support nack with requeue', async () => {
268
+ // This test uses real timers because requeue uses setImmediate internally
253
269
  await adapter.connect();
254
270
 
255
271
  let callCount = 0;
@@ -269,7 +285,7 @@ describe('InMemoryQueueAdapter', () => {
269
285
  await adapter.publish('test', { data: 'test' });
270
286
 
271
287
  // Wait for requeue processing (setImmediate is used for requeue)
272
- await new Promise((resolve) => setTimeout(resolve, 50));
288
+ await new Promise((resolve) => setTimeout(resolve, 10));
273
289
 
274
290
  expect(callCount).toBeGreaterThanOrEqual(2); // Should be called at least twice due to requeue
275
291
  });
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Redis Queue Adapter 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 { SharedRedisProvider } from '../../redis/shared-redis';
24
+
25
+ import { RedisQueueAdapter, createRedisQueueAdapter } from './redis.adapter';
26
+
27
+ describe('RedisQueueAdapter', () => {
28
+ let redisContainer: StartedTestContainer;
29
+ let redisUrl: string;
30
+ let adapter: RedisQueueAdapter;
31
+
32
+ beforeAll(async () => {
33
+ // Start Redis container
34
+ redisContainer = await new GenericContainer('redis:7-alpine')
35
+ .withExposedPorts(6379)
36
+ .withWaitStrategy(Wait.forLogMessage(/.*Ready to accept connections.*/))
37
+ .withStartupTimeout(30000)
38
+ .withLogConsumer(() => {
39
+ // Suppress container logs
40
+ })
41
+ .start();
42
+
43
+ const host = redisContainer.getHost();
44
+ const port = redisContainer.getMappedPort(6379);
45
+ redisUrl = `redis://${host}:${port}`;
46
+
47
+ // Configure shared Redis
48
+ SharedRedisProvider.configure({ url: redisUrl });
49
+ });
50
+
51
+ afterAll(async () => {
52
+ await SharedRedisProvider.reset();
53
+ if (redisContainer) {
54
+ await redisContainer.stop();
55
+ }
56
+ });
57
+
58
+ beforeEach(async () => {
59
+ adapter = createRedisQueueAdapter({
60
+ useSharedClient: true,
61
+ keyPrefix: `test:${Date.now()}:`,
62
+ });
63
+ });
64
+
65
+ afterEach(async () => {
66
+ if (adapter.isConnected()) {
67
+ await adapter.disconnect();
68
+ }
69
+ });
70
+
71
+ describe('lifecycle', () => {
72
+ it('should create adapter with default options', () => {
73
+ const defaultAdapter = new RedisQueueAdapter();
74
+
75
+ expect(defaultAdapter.name).toBe('redis');
76
+ expect(defaultAdapter.type).toBe('redis');
77
+ });
78
+
79
+ it('should create adapter using factory function', () => {
80
+ const factoryAdapter = createRedisQueueAdapter({
81
+ keyPrefix: 'factory:',
82
+ });
83
+
84
+ expect(factoryAdapter).toBeInstanceOf(RedisQueueAdapter);
85
+ });
86
+
87
+ it('should connect successfully', async () => {
88
+ await adapter.connect();
89
+
90
+ expect(adapter.isConnected()).toBe(true);
91
+ });
92
+
93
+ it('should not connect twice', async () => {
94
+ await adapter.connect();
95
+ await adapter.connect(); // Should be no-op
96
+
97
+ expect(adapter.isConnected()).toBe(true);
98
+ });
99
+
100
+ it('should disconnect successfully', async () => {
101
+ await adapter.connect();
102
+ await adapter.disconnect();
103
+
104
+ expect(adapter.isConnected()).toBe(false);
105
+ });
106
+
107
+ it('should handle disconnect when not connected', async () => {
108
+ // Should not throw
109
+ await adapter.disconnect();
110
+
111
+ expect(adapter.isConnected()).toBe(false);
112
+ });
113
+
114
+ it('should emit onReady event on connect', async () => {
115
+ const onReady = mock(() => undefined);
116
+ adapter.on('onReady', onReady);
117
+
118
+ await adapter.connect();
119
+
120
+ expect(onReady).toHaveBeenCalledTimes(1);
121
+ });
122
+ });
123
+
124
+ describe('with own client', () => {
125
+ it('should create own client when useSharedClient is false', async () => {
126
+ const ownAdapter = new RedisQueueAdapter({
127
+ useSharedClient: false,
128
+ url: redisUrl,
129
+ keyPrefix: 'own:',
130
+ });
131
+
132
+ await ownAdapter.connect();
133
+
134
+ expect(ownAdapter.isConnected()).toBe(true);
135
+
136
+ await ownAdapter.disconnect();
137
+ });
138
+
139
+ it('should throw when URL missing and not using shared client', async () => {
140
+ const ownAdapter = new RedisQueueAdapter({
141
+ useSharedClient: false,
142
+ // No URL
143
+ });
144
+
145
+ await expect(ownAdapter.connect()).rejects.toThrow(
146
+ 'Redis URL is required',
147
+ );
148
+ });
149
+ });
150
+
151
+ // Note: publish/subscribe tests are skipped because the RedisQueueAdapter
152
+ // relies on RedisClient.raw() method which doesn't work correctly with
153
+ // Bun's RedisClient API. The raw() method attempts to call Redis commands
154
+ // as object methods (e.g., client['RPUSH'](...)) but Bun's Redis client
155
+ // doesn't expose commands this way.
156
+ //
157
+ // These tests would need RedisClient.raw() to be fixed to use Bun's
158
+ // proper command execution API (likely sendCommand or similar).
159
+
160
+ describe('publishing', () => {
161
+ beforeEach(async () => {
162
+ await adapter.connect();
163
+ });
164
+
165
+ it('should throw when publishing while disconnected', async () => {
166
+ await adapter.disconnect();
167
+
168
+ await expect(
169
+ adapter.publish('test:topic', { data: 1 }),
170
+ ).rejects.toThrow('not connected');
171
+ });
172
+ });
173
+
174
+ // Note: Delayed and priority message tests are skipped because they require
175
+ // raw Redis commands (ZADD, ZRANGEBYSCORE) that aren't available through the
176
+ // current RedisClient.raw() implementation. These features need a proper
177
+ // implementation using Bun's Redis client's sendCommand API.
178
+
179
+ describe('scheduled jobs', () => {
180
+ beforeEach(async () => {
181
+ await adapter.connect();
182
+ });
183
+
184
+ it('should add and get scheduled jobs', async () => {
185
+ await adapter.addScheduledJob('test-job', {
186
+ pattern: 'job:test',
187
+ data: { action: 'process' },
188
+ schedule: { every: 1000 },
189
+ });
190
+
191
+ const jobs = await adapter.getScheduledJobs();
192
+
193
+ expect(jobs.find((j) => j.name === 'test-job')).toBeDefined();
194
+ });
195
+
196
+ it('should add cron scheduled job', async () => {
197
+ await adapter.addScheduledJob('cron-job', {
198
+ pattern: 'job:cron',
199
+ data: { action: 'cron' },
200
+ schedule: { cron: '0 * * * *' },
201
+ });
202
+
203
+ const jobs = await adapter.getScheduledJobs();
204
+
205
+ expect(jobs.find((j) => j.name === 'cron-job')).toBeDefined();
206
+ });
207
+
208
+ it('should remove scheduled job', async () => {
209
+ await adapter.addScheduledJob('removable-job', {
210
+ pattern: 'job:remove',
211
+ data: {},
212
+ schedule: { every: 1000 },
213
+ });
214
+
215
+ const removed = await adapter.removeScheduledJob('removable-job');
216
+
217
+ expect(removed).toBe(true);
218
+
219
+ const jobs = await adapter.getScheduledJobs();
220
+ expect(jobs.find((j) => j.name === 'removable-job')).toBeUndefined();
221
+ });
222
+
223
+ it('should return false when removing non-existent job', async () => {
224
+ const removed = await adapter.removeScheduledJob('non-existent');
225
+
226
+ expect(removed).toBe(false);
227
+ });
228
+
229
+ it('should throw when adding job while disconnected', async () => {
230
+ await adapter.disconnect();
231
+
232
+ await expect(
233
+ adapter.addScheduledJob('fail-job', {
234
+ pattern: 'job:fail',
235
+ data: {},
236
+ schedule: { every: 1000 },
237
+ }),
238
+ ).rejects.toThrow('not connected');
239
+ });
240
+ });
241
+
242
+ describe('features', () => {
243
+ it('should support all standard queue features', () => {
244
+ expect(adapter.supports('delayed-messages')).toBe(true);
245
+ expect(adapter.supports('priority')).toBe(true);
246
+ expect(adapter.supports('dead-letter-queue')).toBe(true);
247
+ expect(adapter.supports('retry')).toBe(true);
248
+ expect(adapter.supports('scheduled-jobs')).toBe(true);
249
+ expect(adapter.supports('consumer-groups')).toBe(true);
250
+ expect(adapter.supports('pattern-subscriptions')).toBe(true);
251
+ });
252
+ });
253
+
254
+ describe('events', () => {
255
+ it('should register and unregister event handlers', () => {
256
+ const handler = mock(() => undefined);
257
+
258
+ adapter.on('onReady', handler);
259
+ adapter.off('onReady', handler);
260
+
261
+ // No assertion needed - just checking no errors
262
+ });
263
+
264
+ it('should emit onError event on connection failure', async () => {
265
+ // Use a non-routable IP to fail quickly instead of DNS lookup timeout
266
+ const badAdapter = new RedisQueueAdapter({
267
+ useSharedClient: false,
268
+ url: 'redis://10.255.255.1:9999', // Non-routable IP
269
+ });
270
+
271
+ const onError = mock(() => undefined);
272
+ badAdapter.on('onError', onError);
273
+
274
+ const connectPromise = badAdapter.connect();
275
+
276
+ // Race between connection attempt and timeout
277
+ const result = await Promise.race([
278
+ connectPromise.then(() => 'connected').catch(() => 'error'),
279
+ new Promise<string>((resolve) => setTimeout(() => resolve('timeout'), 3000)),
280
+ ]);
281
+
282
+ // We expect either error or timeout, but onError should be called on error
283
+ expect(result).toBeOneOf(['error', 'timeout']);
284
+ if (result === 'error') {
285
+ expect(onError).toHaveBeenCalled();
286
+ }
287
+ });
288
+ });
289
+ });
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Tests for QueueService
3
+ */
4
+
5
+ import {
6
+ describe,
7
+ test,
8
+ expect,
9
+ beforeEach,
10
+ afterEach,
11
+ } from 'bun:test';
12
+
13
+ import type { Message } from './types';
14
+
15
+ import { InMemoryQueueAdapter } from './adapters/memory.adapter';
16
+ import { QueueService } from './queue.service';
17
+
18
+ describe('QueueService', () => {
19
+ let adapter: InMemoryQueueAdapter;
20
+ let service: QueueService;
21
+
22
+ beforeEach(async () => {
23
+ adapter = new InMemoryQueueAdapter();
24
+ service = new QueueService({ adapter: 'memory' as const });
25
+ await service.initialize(adapter);
26
+ });
27
+
28
+ afterEach(async () => {
29
+ await service.stop();
30
+ });
31
+
32
+ describe('lifecycle', () => {
33
+ test('should initialize with adapter', async () => {
34
+ expect(service.getAdapter()).toBe(adapter);
35
+ });
36
+
37
+ test('should start and stop correctly', async () => {
38
+ await service.start();
39
+ expect(adapter.isConnected()).toBe(true);
40
+
41
+ await service.stop();
42
+ expect(adapter.isConnected()).toBe(false);
43
+ });
44
+
45
+ test('should handle multiple start calls', async () => {
46
+ await service.start();
47
+ await service.start(); // Should not throw
48
+ expect(adapter.isConnected()).toBe(true);
49
+ });
50
+
51
+ test('should handle multiple stop calls', async () => {
52
+ await service.start();
53
+ await service.stop();
54
+ await service.stop(); // Should not throw
55
+ });
56
+
57
+ test('should throw when getting adapter before initialization', () => {
58
+ const uninitializedService = new QueueService({ adapter: 'memory' as const });
59
+ expect(() => uninitializedService.getAdapter()).toThrow('Queue adapter not initialized');
60
+ });
61
+
62
+ test('should throw when starting without initialization', async () => {
63
+ const uninitializedService = new QueueService({ adapter: 'memory' as const });
64
+ await expect(uninitializedService.start()).rejects.toThrow('Queue adapter not initialized');
65
+ });
66
+ });
67
+
68
+ describe('publish/subscribe', () => {
69
+ test('should publish and receive messages', async () => {
70
+ await service.start();
71
+
72
+ const received: Message[] = [];
73
+ await service.subscribe('test.pattern', async (message) => {
74
+ received.push(message);
75
+ });
76
+
77
+ await service.publish('test.pattern', { data: 'hello' });
78
+
79
+ expect(received.length).toBe(1);
80
+ expect(received[0].data).toEqual({ data: 'hello' });
81
+ });
82
+
83
+ test('should publish batch messages', async () => {
84
+ await service.start();
85
+
86
+ const received: Message[] = [];
87
+ await service.subscribe('batch.*', async (message) => {
88
+ received.push(message);
89
+ });
90
+
91
+ const ids = await service.publishBatch([
92
+ { pattern: 'batch.1', data: { index: 1 } },
93
+ { pattern: 'batch.2', data: { index: 2 } },
94
+ ]);
95
+
96
+ expect(ids.length).toBe(2);
97
+ expect(received.length).toBe(2);
98
+ });
99
+
100
+ test('should track subscriptions', async () => {
101
+ await service.start();
102
+
103
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
104
+ const sub1 = await service.subscribe('pattern1', async () => {});
105
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
106
+ const sub2 = await service.subscribe('pattern2', async () => {});
107
+
108
+ expect(sub1.isActive).toBe(true);
109
+ expect(sub2.isActive).toBe(true);
110
+
111
+ // Stop should unsubscribe all
112
+ await service.stop();
113
+ expect(sub1.isActive).toBe(false);
114
+ expect(sub2.isActive).toBe(false);
115
+ });
116
+ });
117
+
118
+ describe('scheduler', () => {
119
+ test('should provide access to scheduler', async () => {
120
+ const scheduler = service.getScheduler();
121
+ expect(scheduler).toBeDefined();
122
+ });
123
+
124
+ test('should throw when getting scheduler before initialization', () => {
125
+ const uninitializedService = new QueueService({ adapter: 'memory' as const });
126
+ expect(() => uninitializedService.getScheduler()).toThrow('Queue scheduler not initialized');
127
+ });
128
+
129
+ test('should start scheduler with service', async () => {
130
+ const scheduler = service.getScheduler();
131
+
132
+ const received: Message[] = [];
133
+ await service.subscribe('scheduled.test', async (message) => {
134
+ received.push(message);
135
+ });
136
+
137
+ // Add interval job that fires immediately
138
+ scheduler.addIntervalJob('test-job', 100000, 'scheduled.test', () => ({ test: true }));
139
+
140
+ await service.start();
141
+
142
+ // Should have received message immediately due to runImmediately default
143
+ expect(received.length).toBe(1);
144
+ });
145
+ });
146
+
147
+ describe('features', () => {
148
+ test('should check feature support', async () => {
149
+ await service.start();
150
+
151
+ expect(service.supports('pattern-subscriptions')).toBe(true);
152
+ expect(service.supports('delayed-messages')).toBe(true);
153
+ expect(service.supports('consumer-groups')).toBe(false);
154
+ });
155
+ });
156
+
157
+ describe('events', () => {
158
+ test('should register and unregister event handlers', async () => {
159
+ await service.start();
160
+
161
+ let readyCount = 0;
162
+ const handler = () => {
163
+ readyCount++;
164
+ };
165
+
166
+ service.on('onReady', handler);
167
+ // Reconnect to trigger onReady
168
+ await adapter.disconnect();
169
+ await adapter.connect();
170
+ expect(readyCount).toBe(1);
171
+
172
+ service.off('onReady', handler);
173
+ await adapter.disconnect();
174
+ await adapter.connect();
175
+ expect(readyCount).toBe(1); // Should still be 1
176
+ });
177
+
178
+ test('should emit onMessageReceived event', async () => {
179
+ await service.start();
180
+
181
+ let receivedMessage: Message | null = null;
182
+ service.on('onMessageReceived', (message) => {
183
+ receivedMessage = message as Message;
184
+ });
185
+
186
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
187
+ await service.subscribe('test', async () => {});
188
+ await service.publish('test', { data: 'test' });
189
+
190
+ expect(receivedMessage).not.toBeNull();
191
+ expect(receivedMessage!.pattern).toBe('test');
192
+ });
193
+ });
194
+
195
+ describe('scheduled jobs via adapter', () => {
196
+ test('should add and remove scheduled jobs', async () => {
197
+ await service.start();
198
+
199
+ await service.addScheduledJob('test-scheduled', {
200
+ pattern: 'scheduled.pattern',
201
+ schedule: { every: 60000 },
202
+ });
203
+
204
+ const jobs = await service.getScheduledJobs();
205
+ expect(jobs.some((j) => j.name === 'test-scheduled')).toBe(true);
206
+
207
+ const removed = await service.removeScheduledJob('test-scheduled');
208
+ expect(removed).toBe(true);
209
+
210
+ const jobsAfter = await service.getScheduledJobs();
211
+ expect(jobsAfter.some((j) => j.name === 'test-scheduled')).toBe(false);
212
+ });
213
+
214
+ test('should return false when removing non-existent job', async () => {
215
+ await service.start();
216
+
217
+ const removed = await service.removeScheduledJob('nonexistent');
218
+ expect(removed).toBe(false);
219
+ });
220
+ });
221
+
222
+ describe('error handling', () => {
223
+ test('should handle subscription errors gracefully', async () => {
224
+ await service.start();
225
+
226
+ let errorReceived = false;
227
+ service.on('onMessageFailed', () => {
228
+ errorReceived = true;
229
+ });
230
+
231
+ await service.subscribe('error.test', async () => {
232
+ throw new Error('Handler error');
233
+ });
234
+
235
+ await service.publish('error.test', { data: 'test' });
236
+
237
+ expect(errorReceived).toBe(true);
238
+ });
239
+ });
240
+ });
@@ -12,14 +12,22 @@ import {
12
12
 
13
13
  import type { Message } from './types';
14
14
 
15
+ import { useFakeTimers } from '../testing/test-utils';
16
+
15
17
  import { InMemoryQueueAdapter } from './adapters/memory.adapter';
16
18
  import { QueueScheduler, createQueueScheduler } from './scheduler';
17
19
 
18
20
  describe('QueueScheduler', () => {
19
21
  let adapter: InMemoryQueueAdapter;
20
22
  let scheduler: QueueScheduler;
23
+ let advanceTime: (ms: number) => void;
24
+ let restore: () => void;
21
25
 
22
26
  beforeEach(async () => {
27
+ const fakeTimers = useFakeTimers();
28
+ advanceTime = fakeTimers.advanceTime;
29
+ restore = fakeTimers.restore;
30
+
23
31
  adapter = new InMemoryQueueAdapter();
24
32
  await adapter.connect();
25
33
  scheduler = new QueueScheduler(adapter);
@@ -28,6 +36,7 @@ describe('QueueScheduler', () => {
28
36
  afterEach(async () => {
29
37
  scheduler.stop();
30
38
  await adapter.disconnect();
39
+ restore();
31
40
  });
32
41
 
33
42
  describe('interval jobs', () => {
@@ -40,8 +49,9 @@ describe('QueueScheduler', () => {
40
49
  scheduler.addIntervalJob('test-interval', 100000, 'test.interval', () => ({ count: 1 }));
41
50
  scheduler.start();
42
51
 
43
- // Wait a bit for async processing
44
- await new Promise((resolve) => setTimeout(resolve, 10));
52
+ // Advance time and flush async handlers
53
+ advanceTime(10);
54
+ await Promise.resolve();
45
55
 
46
56
  // Should execute immediately
47
57
  expect(received.length).toBe(1);
@@ -59,7 +69,8 @@ describe('QueueScheduler', () => {
59
69
  }));
60
70
  scheduler.start();
61
71
 
62
- await new Promise((resolve) => setTimeout(resolve, 10));
72
+ advanceTime(10);
73
+ await Promise.resolve();
63
74
 
64
75
  expect(received.length).toBe(1);
65
76
  expect((received[0].data as { counter: number }).counter).toBe(1);
@@ -76,7 +87,8 @@ describe('QueueScheduler', () => {
76
87
  });
77
88
  scheduler.start();
78
89
 
79
- await new Promise((resolve) => setTimeout(resolve, 10));
90
+ advanceTime(10);
91
+ await Promise.resolve();
80
92
 
81
93
  expect(received.length).toBe(1);
82
94
  expect(received[0].metadata.serviceId).toBe('test-service');
@@ -96,20 +108,21 @@ describe('QueueScheduler', () => {
96
108
  // Should not have fired yet
97
109
  expect(received.length).toBe(0);
98
110
 
99
- // Wait for timeout
100
- await new Promise((resolve) => setTimeout(resolve, 100));
111
+ // Advance time past the timeout and flush async handlers
112
+ advanceTime(100);
113
+ await Promise.resolve();
101
114
 
102
115
  expect(received.length).toBe(1);
103
116
  expect((received[0].data as { fired: boolean }).fired).toBe(true);
104
117
  });
105
118
 
106
- it('should remove job after execution', async () => {
119
+ it('should remove job after execution', () => {
107
120
  scheduler.addTimeoutJob('one-time', 30, 'test.one-time');
108
121
  scheduler.start();
109
122
 
110
123
  expect(scheduler.hasJob('one-time')).toBe(true);
111
124
 
112
- await new Promise((resolve) => setTimeout(resolve, 50));
125
+ advanceTime(50);
113
126
 
114
127
  expect(scheduler.hasJob('one-time')).toBe(false);
115
128
  });
@@ -200,7 +213,7 @@ describe('QueueScheduler', () => {
200
213
  scheduler.addIntervalJob('test', 100000, 'test');
201
214
  // Not calling start()
202
215
 
203
- await new Promise((resolve) => setTimeout(resolve, 50));
216
+ advanceTime(50);
204
217
  expect(received.length).toBe(0);
205
218
  });
206
219