@quave/agenda 1.0.0

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.
Files changed (216) hide show
  1. package/LICENSE.md +25 -0
  2. package/README.md +380 -0
  3. package/dist/Job.d.ts +190 -0
  4. package/dist/Job.js +689 -0
  5. package/dist/Job.js.map +1 -0
  6. package/dist/JobProcessingQueue.d.ts +47 -0
  7. package/dist/JobProcessingQueue.js +107 -0
  8. package/dist/JobProcessingQueue.js.map +1 -0
  9. package/dist/JobProcessor.d.ts +72 -0
  10. package/dist/JobProcessor.js +512 -0
  11. package/dist/JobProcessor.js.map +1 -0
  12. package/dist/backends/index.d.ts +18 -0
  13. package/dist/backends/index.js +20 -0
  14. package/dist/backends/index.js.map +1 -0
  15. package/dist/cjs/Job.d.ts +190 -0
  16. package/dist/cjs/Job.js +696 -0
  17. package/dist/cjs/Job.js.map +1 -0
  18. package/dist/cjs/JobProcessingQueue.d.ts +47 -0
  19. package/dist/cjs/JobProcessingQueue.js +111 -0
  20. package/dist/cjs/JobProcessingQueue.js.map +1 -0
  21. package/dist/cjs/JobProcessor.d.ts +72 -0
  22. package/dist/cjs/JobProcessor.js +519 -0
  23. package/dist/cjs/JobProcessor.js.map +1 -0
  24. package/dist/cjs/backends/index.d.ts +17 -0
  25. package/dist/cjs/backends/index.js +20 -0
  26. package/dist/cjs/backends/index.js.map +1 -0
  27. package/dist/cjs/decorators/Define.d.ts +33 -0
  28. package/dist/cjs/decorators/Define.js +47 -0
  29. package/dist/cjs/decorators/Define.js.map +1 -0
  30. package/dist/cjs/decorators/Every.d.ts +43 -0
  31. package/dist/cjs/decorators/Every.js +58 -0
  32. package/dist/cjs/decorators/Every.js.map +1 -0
  33. package/dist/cjs/decorators/JobsController.d.ts +43 -0
  34. package/dist/cjs/decorators/JobsController.js +40 -0
  35. package/dist/cjs/decorators/JobsController.js.map +1 -0
  36. package/dist/cjs/decorators/index.d.ts +41 -0
  37. package/dist/cjs/decorators/index.js +57 -0
  38. package/dist/cjs/decorators/index.js.map +1 -0
  39. package/dist/cjs/decorators/metadata.d.ts +50 -0
  40. package/dist/cjs/decorators/metadata.js +41 -0
  41. package/dist/cjs/decorators/metadata.js.map +1 -0
  42. package/dist/cjs/decorators/register.d.ts +52 -0
  43. package/dist/cjs/decorators/register.js +145 -0
  44. package/dist/cjs/decorators/register.js.map +1 -0
  45. package/dist/cjs/index.d.ts +498 -0
  46. package/dist/cjs/index.js +776 -0
  47. package/dist/cjs/index.js.map +1 -0
  48. package/dist/cjs/notifications/BaseNotificationChannel.d.ts +47 -0
  49. package/dist/cjs/notifications/BaseNotificationChannel.js +112 -0
  50. package/dist/cjs/notifications/BaseNotificationChannel.js.map +1 -0
  51. package/dist/cjs/notifications/InMemoryNotificationChannel.d.ts +13 -0
  52. package/dist/cjs/notifications/InMemoryNotificationChannel.js +37 -0
  53. package/dist/cjs/notifications/InMemoryNotificationChannel.js.map +1 -0
  54. package/dist/cjs/notifications/index.d.ts +2 -0
  55. package/dist/cjs/notifications/index.js +8 -0
  56. package/dist/cjs/notifications/index.js.map +1 -0
  57. package/dist/cjs/package.json +1 -0
  58. package/dist/cjs/types/AgendaBackend.d.ts +61 -0
  59. package/dist/cjs/types/AgendaBackend.js +3 -0
  60. package/dist/cjs/types/AgendaBackend.js.map +1 -0
  61. package/dist/cjs/types/AgendaConfig.d.ts +10 -0
  62. package/dist/cjs/types/AgendaConfig.js +3 -0
  63. package/dist/cjs/types/AgendaConfig.js.map +1 -0
  64. package/dist/cjs/types/AgendaStatus.d.ts +32 -0
  65. package/dist/cjs/types/AgendaStatus.js +3 -0
  66. package/dist/cjs/types/AgendaStatus.js.map +1 -0
  67. package/dist/cjs/types/DbOptions.d.ts +4 -0
  68. package/dist/cjs/types/DbOptions.js +3 -0
  69. package/dist/cjs/types/DbOptions.js.map +1 -0
  70. package/dist/cjs/types/DrainOptions.d.ts +37 -0
  71. package/dist/cjs/types/DrainOptions.js +3 -0
  72. package/dist/cjs/types/DrainOptions.js.map +1 -0
  73. package/dist/cjs/types/JobDefinition.d.ts +49 -0
  74. package/dist/cjs/types/JobDefinition.js +3 -0
  75. package/dist/cjs/types/JobDefinition.js.map +1 -0
  76. package/dist/cjs/types/JobLogger.d.ts +111 -0
  77. package/dist/cjs/types/JobLogger.js +3 -0
  78. package/dist/cjs/types/JobLogger.js.map +1 -0
  79. package/dist/cjs/types/JobParameters.d.ts +106 -0
  80. package/dist/cjs/types/JobParameters.js +21 -0
  81. package/dist/cjs/types/JobParameters.js.map +1 -0
  82. package/dist/cjs/types/JobQuery.d.ts +79 -0
  83. package/dist/cjs/types/JobQuery.js +36 -0
  84. package/dist/cjs/types/JobQuery.js.map +1 -0
  85. package/dist/cjs/types/JobRepository.d.ts +101 -0
  86. package/dist/cjs/types/JobRepository.js +3 -0
  87. package/dist/cjs/types/JobRepository.js.map +1 -0
  88. package/dist/cjs/types/NotificationChannel.d.ts +120 -0
  89. package/dist/cjs/types/NotificationChannel.js +3 -0
  90. package/dist/cjs/types/NotificationChannel.js.map +1 -0
  91. package/dist/cjs/utils/backoff.d.ts +152 -0
  92. package/dist/cjs/utils/backoff.js +175 -0
  93. package/dist/cjs/utils/backoff.js.map +1 -0
  94. package/dist/cjs/utils/dateConstraints.d.ts +48 -0
  95. package/dist/cjs/utils/dateConstraints.js +127 -0
  96. package/dist/cjs/utils/dateConstraints.js.map +1 -0
  97. package/dist/cjs/utils/isValidDate.d.ts +1 -0
  98. package/dist/cjs/utils/isValidDate.js +8 -0
  99. package/dist/cjs/utils/isValidDate.js.map +1 -0
  100. package/dist/cjs/utils/nextRunAt.d.ts +10 -0
  101. package/dist/cjs/utils/nextRunAt.js +119 -0
  102. package/dist/cjs/utils/nextRunAt.js.map +1 -0
  103. package/dist/cjs/utils/priority.d.ts +13 -0
  104. package/dist/cjs/utils/priority.js +23 -0
  105. package/dist/cjs/utils/priority.js.map +1 -0
  106. package/dist/cjs/utils/processEvery.d.ts +1 -0
  107. package/dist/cjs/utils/processEvery.js +13 -0
  108. package/dist/cjs/utils/processEvery.js.map +1 -0
  109. package/dist/cjs/utils/stack.d.ts +1 -0
  110. package/dist/cjs/utils/stack.js +21 -0
  111. package/dist/cjs/utils/stack.js.map +1 -0
  112. package/dist/cjs/version.d.ts +9 -0
  113. package/dist/cjs/version.js +13 -0
  114. package/dist/cjs/version.js.map +1 -0
  115. package/dist/decorators/Define.d.ts +33 -0
  116. package/dist/decorators/Define.js +44 -0
  117. package/dist/decorators/Define.js.map +1 -0
  118. package/dist/decorators/Every.d.ts +43 -0
  119. package/dist/decorators/Every.js +55 -0
  120. package/dist/decorators/Every.js.map +1 -0
  121. package/dist/decorators/JobsController.d.ts +43 -0
  122. package/dist/decorators/JobsController.js +37 -0
  123. package/dist/decorators/JobsController.js.map +1 -0
  124. package/dist/decorators/index.d.ts +41 -0
  125. package/dist/decorators/index.js +46 -0
  126. package/dist/decorators/index.js.map +1 -0
  127. package/dist/decorators/metadata.d.ts +50 -0
  128. package/dist/decorators/metadata.js +35 -0
  129. package/dist/decorators/metadata.js.map +1 -0
  130. package/dist/decorators/register.d.ts +52 -0
  131. package/dist/decorators/register.js +139 -0
  132. package/dist/decorators/register.js.map +1 -0
  133. package/dist/index.d.ts +498 -0
  134. package/dist/index.js +744 -0
  135. package/dist/index.js.map +1 -0
  136. package/dist/notifications/BaseNotificationChannel.d.ts +47 -0
  137. package/dist/notifications/BaseNotificationChannel.js +108 -0
  138. package/dist/notifications/BaseNotificationChannel.js.map +1 -0
  139. package/dist/notifications/InMemoryNotificationChannel.d.ts +13 -0
  140. package/dist/notifications/InMemoryNotificationChannel.js +33 -0
  141. package/dist/notifications/InMemoryNotificationChannel.js.map +1 -0
  142. package/dist/notifications/index.d.ts +2 -0
  143. package/dist/notifications/index.js +3 -0
  144. package/dist/notifications/index.js.map +1 -0
  145. package/dist/types/AgendaBackend.d.ts +61 -0
  146. package/dist/types/AgendaBackend.js +2 -0
  147. package/dist/types/AgendaBackend.js.map +1 -0
  148. package/dist/types/AgendaConfig.d.ts +10 -0
  149. package/dist/types/AgendaConfig.js +2 -0
  150. package/dist/types/AgendaConfig.js.map +1 -0
  151. package/dist/types/AgendaStatus.d.ts +32 -0
  152. package/dist/types/AgendaStatus.js +2 -0
  153. package/dist/types/AgendaStatus.js.map +1 -0
  154. package/dist/types/DbOptions.d.ts +4 -0
  155. package/dist/types/DbOptions.js +2 -0
  156. package/dist/types/DbOptions.js.map +1 -0
  157. package/dist/types/DrainOptions.d.ts +37 -0
  158. package/dist/types/DrainOptions.js +2 -0
  159. package/dist/types/DrainOptions.js.map +1 -0
  160. package/dist/types/JobDefinition.d.ts +49 -0
  161. package/dist/types/JobDefinition.js +2 -0
  162. package/dist/types/JobDefinition.js.map +1 -0
  163. package/dist/types/JobLogger.d.ts +111 -0
  164. package/dist/types/JobLogger.js +2 -0
  165. package/dist/types/JobLogger.js.map +1 -0
  166. package/dist/types/JobParameters.d.ts +106 -0
  167. package/dist/types/JobParameters.js +17 -0
  168. package/dist/types/JobParameters.js.map +1 -0
  169. package/dist/types/JobQuery.d.ts +79 -0
  170. package/dist/types/JobQuery.js +33 -0
  171. package/dist/types/JobQuery.js.map +1 -0
  172. package/dist/types/JobRepository.d.ts +101 -0
  173. package/dist/types/JobRepository.js +2 -0
  174. package/dist/types/JobRepository.js.map +1 -0
  175. package/dist/types/NotificationChannel.d.ts +120 -0
  176. package/dist/types/NotificationChannel.js +2 -0
  177. package/dist/types/NotificationChannel.js.map +1 -0
  178. package/dist/utils/backoff.d.ts +152 -0
  179. package/dist/utils/backoff.js +167 -0
  180. package/dist/utils/backoff.js.map +1 -0
  181. package/dist/utils/dateConstraints.d.ts +48 -0
  182. package/dist/utils/dateConstraints.js +117 -0
  183. package/dist/utils/dateConstraints.js.map +1 -0
  184. package/dist/utils/isValidDate.d.ts +1 -0
  185. package/dist/utils/isValidDate.js +5 -0
  186. package/dist/utils/isValidDate.js.map +1 -0
  187. package/dist/utils/nextRunAt.d.ts +10 -0
  188. package/dist/utils/nextRunAt.js +110 -0
  189. package/dist/utils/nextRunAt.js.map +1 -0
  190. package/dist/utils/priority.d.ts +13 -0
  191. package/dist/utils/priority.js +20 -0
  192. package/dist/utils/priority.js.map +1 -0
  193. package/dist/utils/processEvery.d.ts +1 -0
  194. package/dist/utils/processEvery.js +7 -0
  195. package/dist/utils/processEvery.js.map +1 -0
  196. package/dist/utils/stack.d.ts +1 -0
  197. package/dist/utils/stack.js +18 -0
  198. package/dist/utils/stack.js.map +1 -0
  199. package/dist/version.d.ts +9 -0
  200. package/dist/version.js +10 -0
  201. package/dist/version.js.map +1 -0
  202. package/package.json +79 -0
  203. package/test/fixtures/forkHelper.js +40 -0
  204. package/test/fixtures/someJobDefinition.ts +12 -0
  205. package/test/shared/agenda-test-suite.ts +1876 -0
  206. package/test/shared/backoff-test-suite.ts +408 -0
  207. package/test/shared/debounce-test-suite.ts +377 -0
  208. package/test/shared/full-test-suite.ts +161 -0
  209. package/test/shared/index.ts +50 -0
  210. package/test/shared/joblogger-test-suite.ts +305 -0
  211. package/test/shared/jobprocessor-test-suite.ts +266 -0
  212. package/test/shared/notification-channel-test-suite.ts +471 -0
  213. package/test/shared/removeoncomplete-test-suite.ts +228 -0
  214. package/test/shared/repository-test-suite.ts +708 -0
  215. package/test/shared/retry-test-suite.ts +174 -0
  216. package/test/shared/test-utils.ts +100 -0
@@ -0,0 +1,471 @@
1
+ /**
2
+ * Shared test suite for NotificationChannel implementations
3
+ *
4
+ * This file exports test suite factories that can be used to test any
5
+ * NotificationChannel implementation (InMemory, PostgreSQL LISTEN/NOTIFY, Redis, etc.)
6
+ *
7
+ * Usage:
8
+ * ```typescript
9
+ * import { notificationChannelTestSuite } from 'agenda/test/shared';
10
+ *
11
+ * notificationChannelTestSuite({
12
+ * name: 'PostgresNotificationChannel',
13
+ * createChannel: async () => {
14
+ * const channel = new PostgresNotificationChannel(config);
15
+ * return channel;
16
+ * },
17
+ * cleanupChannel: async (channel) => {
18
+ * await channel.disconnect();
19
+ * }
20
+ * });
21
+ * ```
22
+ */
23
+
24
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
25
+ import type { NotificationChannel, JobNotification, JobId, JobStateNotification, JobStateType } from '../../src/index.js';
26
+ import { toJobId } from '../../src/index.js';
27
+ import { delay } from './test-utils.js';
28
+
29
+ export interface NotificationChannelTestConfig {
30
+ /** Name for the test suite */
31
+ name: string;
32
+ /** Factory to create a fresh channel instance (not connected) */
33
+ createChannel: () => Promise<NotificationChannel>;
34
+ /** Cleanup function called after each test */
35
+ cleanupChannel: (channel: NotificationChannel) => Promise<void>;
36
+ /** Delay in ms to wait for notifications to propagate (default: 100) */
37
+ propagationDelay?: number;
38
+ }
39
+
40
+ /**
41
+ * Helper to create a test notification
42
+ */
43
+ function createTestNotification(overrides: Partial<JobNotification> = {}): JobNotification {
44
+ return {
45
+ jobId: toJobId('test-job-id') as JobId,
46
+ jobName: 'test-job',
47
+ nextRunAt: new Date(),
48
+ priority: 0,
49
+ timestamp: new Date(),
50
+ ...overrides
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Helper to create a test state notification
56
+ */
57
+ function createTestStateNotification(
58
+ type: JobStateType,
59
+ overrides: Partial<Omit<JobStateNotification, 'type'>> = {}
60
+ ): JobStateNotification {
61
+ return {
62
+ type,
63
+ jobId: toJobId('test-job-id') as JobId,
64
+ jobName: 'test-job',
65
+ timestamp: new Date(),
66
+ ...overrides
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Creates a test suite for NotificationChannel implementations
72
+ */
73
+ export function notificationChannelTestSuite(config: NotificationChannelTestConfig): void {
74
+ const propagationDelay = config.propagationDelay ?? 100;
75
+
76
+ describe(`${config.name} - NotificationChannel`, () => {
77
+ let channel: NotificationChannel;
78
+
79
+ beforeEach(async () => {
80
+ channel = await config.createChannel();
81
+ });
82
+
83
+ afterEach(async () => {
84
+ await config.cleanupChannel(channel);
85
+ });
86
+
87
+ describe('connection lifecycle', () => {
88
+ it('should start in disconnected state', () => {
89
+ expect(channel.state).toBe('disconnected');
90
+ });
91
+
92
+ it('should transition to connected state after connect', async () => {
93
+ await channel.connect();
94
+ expect(channel.state).toBe('connected');
95
+ });
96
+
97
+ it('should transition to disconnected state after disconnect', async () => {
98
+ await channel.connect();
99
+ expect(channel.state).toBe('connected');
100
+
101
+ await channel.disconnect();
102
+ expect(channel.state).toBe('disconnected');
103
+ });
104
+
105
+ it('should emit stateChange events', async () => {
106
+ const states: string[] = [];
107
+
108
+ channel.on('stateChange', state => {
109
+ states.push(state);
110
+ });
111
+
112
+ await channel.connect();
113
+ await channel.disconnect();
114
+
115
+ expect(states).toContain('connected');
116
+ expect(states).toContain('disconnected');
117
+ });
118
+ });
119
+
120
+ describe('publish/subscribe', () => {
121
+ beforeEach(async () => {
122
+ await channel.connect();
123
+ });
124
+
125
+ it('should publish and receive notifications', async () => {
126
+ const received: JobNotification[] = [];
127
+
128
+ channel.subscribe(notification => {
129
+ received.push(notification);
130
+ });
131
+
132
+ const notification = createTestNotification({
133
+ jobName: 'publish-test',
134
+ priority: 10
135
+ });
136
+
137
+ await channel.publish(notification);
138
+ await delay(propagationDelay);
139
+
140
+ expect(received.length).toBe(1);
141
+ expect(received[0].jobName).toBe('publish-test');
142
+ expect(received[0].priority).toBe(10);
143
+ });
144
+
145
+ it('should support multiple subscribers', async () => {
146
+ const received1: JobNotification[] = [];
147
+ const received2: JobNotification[] = [];
148
+
149
+ channel.subscribe(n => { received1.push(n); });
150
+ channel.subscribe(n => { received2.push(n); });
151
+
152
+ await channel.publish(createTestNotification());
153
+ await delay(propagationDelay);
154
+
155
+ expect(received1.length).toBe(1);
156
+ expect(received2.length).toBe(1);
157
+ });
158
+
159
+ it('should allow unsubscribing', async () => {
160
+ const received: JobNotification[] = [];
161
+
162
+ const unsubscribe = channel.subscribe(notification => {
163
+ received.push(notification);
164
+ });
165
+
166
+ await channel.publish(createTestNotification({ jobName: 'first' }));
167
+ await delay(propagationDelay);
168
+
169
+ expect(received.length).toBe(1);
170
+
171
+ unsubscribe();
172
+
173
+ await channel.publish(createTestNotification({ jobName: 'second' }));
174
+ await delay(propagationDelay);
175
+
176
+ expect(received.length).toBe(1);
177
+ expect(received[0].jobName).toBe('first');
178
+ });
179
+
180
+ it('should preserve notification data through serialization', async () => {
181
+ const received: JobNotification[] = [];
182
+ channel.subscribe(n => { received.push(n); });
183
+
184
+ const originalDate = new Date('2024-01-15T10:30:00.000Z');
185
+ const notification = createTestNotification({
186
+ jobId: toJobId('specific-id') as JobId,
187
+ jobName: 'data-test',
188
+ nextRunAt: originalDate,
189
+ priority: 42,
190
+ source: 'test-source'
191
+ });
192
+
193
+ await channel.publish(notification);
194
+ await delay(propagationDelay);
195
+
196
+ expect(received.length).toBe(1);
197
+ expect(received[0].jobId.toString()).toBe('specific-id');
198
+ expect(received[0].jobName).toBe('data-test');
199
+ expect(received[0].nextRunAt?.toISOString()).toBe(originalDate.toISOString());
200
+ expect(received[0].priority).toBe(42);
201
+ expect(received[0].source).toBe('test-source');
202
+ });
203
+
204
+ it('should handle null nextRunAt', async () => {
205
+ const received: JobNotification[] = [];
206
+ channel.subscribe(n => { received.push(n); });
207
+
208
+ await channel.publish(createTestNotification({ nextRunAt: null }));
209
+ await delay(propagationDelay);
210
+
211
+ expect(received.length).toBe(1);
212
+ expect(received[0].nextRunAt).toBeNull();
213
+ });
214
+
215
+ it('should handle multiple rapid notifications', async () => {
216
+ const received: JobNotification[] = [];
217
+ channel.subscribe(n => { received.push(n); });
218
+
219
+ const count = 5;
220
+ for (let i = 0; i < count; i++) {
221
+ await channel.publish(createTestNotification({ jobName: `job-${i}` }));
222
+ }
223
+
224
+ await delay(propagationDelay * 2);
225
+
226
+ expect(received.length).toBe(count);
227
+ });
228
+ });
229
+
230
+ describe('error handling', () => {
231
+ it('should throw when publishing on disconnected channel', async () => {
232
+ await expect(channel.publish(createTestNotification())).rejects.toThrow(
233
+ /not connected/i
234
+ );
235
+ });
236
+
237
+ it('should throw when publishing after disconnect', async () => {
238
+ await channel.connect();
239
+ await channel.disconnect();
240
+
241
+ await expect(channel.publish(createTestNotification())).rejects.toThrow(
242
+ /not connected/i
243
+ );
244
+ });
245
+ });
246
+
247
+ describe('event emitter', () => {
248
+ it('should support on/off for stateChange events', async () => {
249
+ const states: string[] = [];
250
+ const handler = (state: string) => states.push(state);
251
+
252
+ channel.on('stateChange', handler);
253
+ await channel.connect();
254
+
255
+ expect(states).toContain('connected');
256
+
257
+ channel.off('stateChange', handler);
258
+ await channel.disconnect();
259
+
260
+ const disconnectedCount = states.filter(s => s === 'disconnected').length;
261
+ expect(disconnectedCount).toBeLessThanOrEqual(1);
262
+ });
263
+ });
264
+
265
+ describe('state notifications (bi-directional)', () => {
266
+ beforeEach(async () => {
267
+ await channel.connect();
268
+ });
269
+
270
+ it('should have subscribeState method', () => {
271
+ expect(typeof channel.subscribeState).toBe('function');
272
+ });
273
+
274
+ it('should have publishState method', () => {
275
+ expect(typeof channel.publishState).toBe('function');
276
+ });
277
+
278
+ it('should publish and receive state notifications', async () => {
279
+ if (!channel.publishState || !channel.subscribeState) {
280
+ // Skip if not supported
281
+ return;
282
+ }
283
+
284
+ const received: JobStateNotification[] = [];
285
+
286
+ channel.subscribeState(notification => {
287
+ received.push(notification);
288
+ });
289
+
290
+ const notification = createTestStateNotification('start', {
291
+ jobName: 'state-test',
292
+ lastRunAt: new Date()
293
+ });
294
+
295
+ await channel.publishState(notification);
296
+ await delay(propagationDelay);
297
+
298
+ expect(received.length).toBe(1);
299
+ expect(received[0].type).toBe('start');
300
+ expect(received[0].jobName).toBe('state-test');
301
+ });
302
+
303
+ it('should support multiple state subscribers', async () => {
304
+ if (!channel.publishState || !channel.subscribeState) {
305
+ return;
306
+ }
307
+
308
+ const received1: JobStateNotification[] = [];
309
+ const received2: JobStateNotification[] = [];
310
+
311
+ channel.subscribeState(n => { received1.push(n); });
312
+ channel.subscribeState(n => { received2.push(n); });
313
+
314
+ await channel.publishState(createTestStateNotification('success'));
315
+ await delay(propagationDelay);
316
+
317
+ expect(received1.length).toBe(1);
318
+ expect(received2.length).toBe(1);
319
+ });
320
+
321
+ it('should allow unsubscribing from state notifications', async () => {
322
+ if (!channel.publishState || !channel.subscribeState) {
323
+ return;
324
+ }
325
+
326
+ const received: JobStateNotification[] = [];
327
+
328
+ const unsubscribe = channel.subscribeState(notification => {
329
+ received.push(notification);
330
+ });
331
+
332
+ await channel.publishState(createTestStateNotification('start', { jobName: 'first' }));
333
+ await delay(propagationDelay);
334
+
335
+ expect(received.length).toBe(1);
336
+
337
+ unsubscribe();
338
+
339
+ await channel.publishState(createTestStateNotification('complete', { jobName: 'second' }));
340
+ await delay(propagationDelay);
341
+
342
+ expect(received.length).toBe(1);
343
+ expect(received[0].jobName).toBe('first');
344
+ });
345
+
346
+ it('should preserve state notification data through serialization', async () => {
347
+ if (!channel.publishState || !channel.subscribeState) {
348
+ return;
349
+ }
350
+
351
+ const received: JobStateNotification[] = [];
352
+ channel.subscribeState(n => { received.push(n); });
353
+
354
+ const lastRunAt = new Date('2024-01-15T10:30:00.000Z');
355
+ const lastFinishedAt = new Date('2024-01-15T10:31:00.000Z');
356
+ const notification = createTestStateNotification('complete', {
357
+ jobId: toJobId('specific-id') as JobId,
358
+ jobName: 'data-test',
359
+ source: 'test-source',
360
+ lastRunAt,
361
+ lastFinishedAt,
362
+ duration: 60000
363
+ });
364
+
365
+ await channel.publishState(notification);
366
+ await delay(propagationDelay);
367
+
368
+ expect(received.length).toBe(1);
369
+ expect(received[0].type).toBe('complete');
370
+ expect(received[0].jobId.toString()).toBe('specific-id');
371
+ expect(received[0].jobName).toBe('data-test');
372
+ expect(received[0].source).toBe('test-source');
373
+ expect(received[0].lastRunAt?.toISOString()).toBe(lastRunAt.toISOString());
374
+ expect(received[0].lastFinishedAt?.toISOString()).toBe(lastFinishedAt.toISOString());
375
+ expect(received[0].duration).toBe(60000);
376
+ });
377
+
378
+ it('should handle fail notifications with error info', async () => {
379
+ if (!channel.publishState || !channel.subscribeState) {
380
+ return;
381
+ }
382
+
383
+ const received: JobStateNotification[] = [];
384
+ channel.subscribeState(n => { received.push(n); });
385
+
386
+ await channel.publishState(createTestStateNotification('fail', {
387
+ error: 'Something went wrong',
388
+ failCount: 3
389
+ }));
390
+ await delay(propagationDelay);
391
+
392
+ expect(received.length).toBe(1);
393
+ expect(received[0].type).toBe('fail');
394
+ expect(received[0].error).toBe('Something went wrong');
395
+ expect(received[0].failCount).toBe(3);
396
+ });
397
+
398
+ it('should handle retry notifications', async () => {
399
+ if (!channel.publishState || !channel.subscribeState) {
400
+ return;
401
+ }
402
+
403
+ const received: JobStateNotification[] = [];
404
+ channel.subscribeState(n => { received.push(n); });
405
+
406
+ const retryAt = new Date('2024-01-15T11:00:00.000Z');
407
+ await channel.publishState(createTestStateNotification('retry', {
408
+ retryAt,
409
+ retryAttempt: 2,
410
+ error: 'Temporary failure'
411
+ }));
412
+ await delay(propagationDelay);
413
+
414
+ expect(received.length).toBe(1);
415
+ expect(received[0].type).toBe('retry');
416
+ expect(received[0].retryAt?.toISOString()).toBe(retryAt.toISOString());
417
+ expect(received[0].retryAttempt).toBe(2);
418
+ expect(received[0].error).toBe('Temporary failure');
419
+ });
420
+
421
+ it('should handle progress notifications', async () => {
422
+ if (!channel.publishState || !channel.subscribeState) {
423
+ return;
424
+ }
425
+
426
+ const received: JobStateNotification[] = [];
427
+ channel.subscribeState(n => { received.push(n); });
428
+
429
+ await channel.publishState(createTestStateNotification('progress', {
430
+ progress: 75
431
+ }));
432
+ await delay(propagationDelay);
433
+
434
+ expect(received.length).toBe(1);
435
+ expect(received[0].type).toBe('progress');
436
+ expect(received[0].progress).toBe(75);
437
+ });
438
+
439
+ it('should handle multiple rapid state notifications', async () => {
440
+ if (!channel.publishState || !channel.subscribeState) {
441
+ return;
442
+ }
443
+
444
+ const received: JobStateNotification[] = [];
445
+ channel.subscribeState(n => { received.push(n); });
446
+
447
+ const types: JobStateType[] = ['start', 'progress', 'success', 'complete'];
448
+ for (const type of types) {
449
+ await channel.publishState(createTestStateNotification(type));
450
+ }
451
+
452
+ await delay(propagationDelay * 2);
453
+
454
+ expect(received.length).toBe(types.length);
455
+ expect(received.map(n => n.type)).toEqual(types);
456
+ });
457
+
458
+ it('should throw when publishing state on disconnected channel', async () => {
459
+ if (!channel.publishState) {
460
+ return;
461
+ }
462
+
463
+ await channel.disconnect();
464
+
465
+ await expect(channel.publishState(createTestStateNotification('start'))).rejects.toThrow(
466
+ /not connected/i
467
+ );
468
+ });
469
+ });
470
+ });
471
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * RemoveOnComplete test factory
3
+ *
4
+ * Tests for the removeOnComplete feature that automatically removes
5
+ * one-time jobs from the database after successful completion.
6
+ *
7
+ * Usage:
8
+ * ```typescript
9
+ * import { removeOnCompleteTestSuite } from 'agenda/test/shared';
10
+ *
11
+ * removeOnCompleteTestSuite({
12
+ * name: 'RemoveOnComplete with MongoDB',
13
+ * createBackend: async () => new MongoBackend({ mongo: db }),
14
+ * cleanupBackend: async (backend) => await backend.disconnect(),
15
+ * clearJobs: async (backend) => await db.collection('agendaJobs').deleteMany({})
16
+ * });
17
+ * ```
18
+ */
19
+
20
+ import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
21
+ import type { AgendaBackend } from '../../src/index.js';
22
+ import { Agenda } from '../../src/index.js';
23
+ import { waitForEvent } from './test-utils.js';
24
+
25
+ export interface RemoveOnCompleteTestConfig {
26
+ /** Name for the test suite */
27
+ name: string;
28
+ /** Factory to create a fresh backend instance */
29
+ createBackend: () => Promise<AgendaBackend>;
30
+ /** Cleanup function called after tests */
31
+ cleanupBackend: (backend: AgendaBackend) => Promise<void>;
32
+ /** Clear all jobs between tests */
33
+ clearJobs: (backend: AgendaBackend) => Promise<void>;
34
+ }
35
+
36
+ /**
37
+ * RemoveOnComplete tests
38
+ */
39
+ export function removeOnCompleteTestSuite(config: RemoveOnCompleteTestConfig): void {
40
+ describe(`${config.name} - removeOnComplete`, () => {
41
+ let backend: AgendaBackend;
42
+ let agenda: Agenda;
43
+
44
+ beforeAll(async () => {
45
+ backend = await config.createBackend();
46
+ });
47
+
48
+ afterAll(async () => {
49
+ await config.cleanupBackend(backend);
50
+ });
51
+
52
+ beforeEach(async () => {
53
+ await config.clearJobs(backend);
54
+ });
55
+
56
+ afterEach(async () => {
57
+ if (agenda) {
58
+ await agenda.stop();
59
+ }
60
+ await config.clearJobs(backend);
61
+ });
62
+
63
+ it('should remove one-time job after successful completion when global removeOnComplete is true', async () => {
64
+ agenda = new Agenda({
65
+ backend,
66
+ processEvery: 100,
67
+ removeOnComplete: true
68
+ });
69
+ await agenda.ready;
70
+
71
+ agenda.define('removable-job', async () => {
72
+ // job succeeds
73
+ });
74
+
75
+ const completePromise = waitForEvent(agenda, 'complete:removable-job');
76
+ await agenda.start();
77
+ await agenda.now('removable-job');
78
+ await completePromise;
79
+
80
+ // Give a moment for the async removal to complete
81
+ await new Promise(resolve => setTimeout(resolve, 200));
82
+
83
+ const result = await agenda.queryJobs({ name: 'removable-job' });
84
+ expect(result.jobs.length).toBe(0);
85
+ });
86
+
87
+ it('should NOT remove one-time job when removeOnComplete is false (default)', async () => {
88
+ agenda = new Agenda({
89
+ backend,
90
+ processEvery: 100
91
+ });
92
+ await agenda.ready;
93
+
94
+ agenda.define('kept-job', async () => {
95
+ // job succeeds
96
+ });
97
+
98
+ const completePromise = waitForEvent(agenda, 'complete:kept-job');
99
+ await agenda.start();
100
+ await agenda.now('kept-job');
101
+ await completePromise;
102
+
103
+ await new Promise(resolve => setTimeout(resolve, 200));
104
+
105
+ const result = await agenda.queryJobs({ name: 'kept-job' });
106
+ expect(result.jobs.length).toBe(1);
107
+ });
108
+
109
+ it('should NOT remove recurring job even with removeOnComplete true', async () => {
110
+ agenda = new Agenda({
111
+ backend,
112
+ processEvery: 100,
113
+ removeOnComplete: true
114
+ });
115
+ await agenda.ready;
116
+
117
+ agenda.define('recurring-job', async () => {
118
+ // job succeeds
119
+ });
120
+
121
+ const completePromise = waitForEvent(agenda, 'complete:recurring-job');
122
+ await agenda.start();
123
+ await agenda.every('1 hour', 'recurring-job');
124
+ await completePromise;
125
+
126
+ await new Promise(resolve => setTimeout(resolve, 200));
127
+
128
+ const result = await agenda.queryJobs({ name: 'recurring-job' });
129
+ expect(result.jobs.length).toBe(1);
130
+ });
131
+
132
+ it('should respect per-job removeOnComplete override (true overrides global false)', async () => {
133
+ agenda = new Agenda({
134
+ backend,
135
+ processEvery: 100,
136
+ removeOnComplete: false
137
+ });
138
+ await agenda.ready;
139
+
140
+ agenda.define('per-job-remove', async () => {
141
+ // job succeeds
142
+ }, { removeOnComplete: true });
143
+
144
+ const completePromise = waitForEvent(agenda, 'complete:per-job-remove');
145
+ await agenda.start();
146
+ await agenda.now('per-job-remove');
147
+ await completePromise;
148
+
149
+ await new Promise(resolve => setTimeout(resolve, 200));
150
+
151
+ const result = await agenda.queryJobs({ name: 'per-job-remove' });
152
+ expect(result.jobs.length).toBe(0);
153
+ });
154
+
155
+ it('should respect per-job removeOnComplete override (false overrides global true)', async () => {
156
+ agenda = new Agenda({
157
+ backend,
158
+ processEvery: 100,
159
+ removeOnComplete: true
160
+ });
161
+ await agenda.ready;
162
+
163
+ agenda.define('per-job-keep', async () => {
164
+ // job succeeds
165
+ }, { removeOnComplete: false });
166
+
167
+ const completePromise = waitForEvent(agenda, 'complete:per-job-keep');
168
+ await agenda.start();
169
+ await agenda.now('per-job-keep');
170
+ await completePromise;
171
+
172
+ await new Promise(resolve => setTimeout(resolve, 200));
173
+
174
+ const result = await agenda.queryJobs({ name: 'per-job-keep' });
175
+ expect(result.jobs.length).toBe(1);
176
+ });
177
+
178
+ it('should emit complete event before removal', async () => {
179
+ agenda = new Agenda({
180
+ backend,
181
+ processEvery: 100,
182
+ removeOnComplete: true
183
+ });
184
+ await agenda.ready;
185
+
186
+ agenda.define('event-before-remove', async () => {
187
+ // job succeeds
188
+ });
189
+
190
+ let completeEventFired = false;
191
+ const completePromise = new Promise<void>(resolve => {
192
+ agenda.on('complete:event-before-remove', () => {
193
+ completeEventFired = true;
194
+ resolve();
195
+ });
196
+ });
197
+
198
+ await agenda.start();
199
+ await agenda.now('event-before-remove');
200
+ await completePromise;
201
+
202
+ expect(completeEventFired).toBe(true);
203
+ });
204
+
205
+ it('should NOT remove failed job even with removeOnComplete true', async () => {
206
+ agenda = new Agenda({
207
+ backend,
208
+ processEvery: 100,
209
+ removeOnComplete: true
210
+ });
211
+ await agenda.ready;
212
+
213
+ agenda.define('failing-job', async () => {
214
+ throw new Error('intentional failure');
215
+ });
216
+
217
+ const failPromise = waitForEvent(agenda, 'fail:failing-job');
218
+ await agenda.start();
219
+ await agenda.now('failing-job');
220
+ await failPromise;
221
+
222
+ await new Promise(resolve => setTimeout(resolve, 200));
223
+
224
+ const result = await agenda.queryJobs({ name: 'failing-job' });
225
+ expect(result.jobs.length).toBe(1);
226
+ });
227
+ });
228
+ }