@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
|
@@ -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
|
-
//
|
|
131
|
-
|
|
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,
|
|
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
|
-
//
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
216
|
+
advanceTime(50);
|
|
204
217
|
expect(received.length).toBe(0);
|
|
205
218
|
});
|
|
206
219
|
|