@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,1876 @@
1
+ import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
2
+ import type { ForkOptions } from 'child_process';
3
+ import type { AgendaBackend, NotificationChannel } from '../../src/index.js';
4
+ import { Agenda } from '../../src/index.js';
5
+ import { Job } from '../../src/Job.js';
6
+ import { delay, waitFor, waitForEvent, waitForEvents, runJobAndWait } from './test-utils.js';
7
+ import someJobDefinition from '../fixtures/someJobDefinition.js';
8
+
9
+ export interface ForkHelperConfig {
10
+ /** Path to the fork helper script (relative to cwd) */
11
+ path: string;
12
+ /** Fork options (e.g., execArgv for tsx loader, env for environment variables) */
13
+ options?: ForkOptions;
14
+ }
15
+
16
+ export interface AgendaTestConfig {
17
+ /** Name for the test suite */
18
+ name: string;
19
+ /** Factory to create a fresh backend instance */
20
+ createBackend: () => Promise<AgendaBackend>;
21
+ /** Cleanup function called after tests */
22
+ cleanupBackend: (backend: AgendaBackend) => Promise<void>;
23
+ /** Clear all jobs between tests */
24
+ clearJobs: (backend: AgendaBackend) => Promise<void>;
25
+ /** Optional notification channel factory */
26
+ createNotificationChannel?: () => Promise<NotificationChannel>;
27
+ /** Cleanup notification channel */
28
+ cleanupNotificationChannel?: (channel: NotificationChannel) => Promise<void>;
29
+ /** Fork mode configuration (required if forkMode tests are enabled) */
30
+ forkHelper?: ForkHelperConfig;
31
+ /** Skip specific tests if not supported */
32
+ skip?: {
33
+ forkMode?: boolean; // Skip fork mode tests (require specific file paths)
34
+ };
35
+ }
36
+
37
+ export function agendaTestSuite(config: AgendaTestConfig): void {
38
+ describe(`${config.name} - Agenda Integration`, () => {
39
+ let backend: AgendaBackend;
40
+ let agenda: Agenda;
41
+
42
+ const jobProcessor = () => {};
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
+ agenda = new Agenda({
55
+ backend,
56
+ processEvery: 100
57
+ });
58
+ await agenda.ready;
59
+ });
60
+
61
+ afterEach(async () => {
62
+ if (agenda) {
63
+ await agenda.stop();
64
+ }
65
+ await config.clearJobs(backend);
66
+ });
67
+
68
+ describe('every()', () => {
69
+ it('should schedule a job to run at an interval', async () => {
70
+ await agenda.every('5 minutes', 'test-interval-job');
71
+
72
+ const result = await agenda.queryJobs({ name: 'test-interval-job' });
73
+ expect(result.jobs.length).toBe(1);
74
+ expect(result.jobs[0].repeatInterval).toBe('5 minutes');
75
+ });
76
+
77
+ it('should schedule a job to run at a cron expression', async () => {
78
+ await agenda.every('0 6 * * *', 'test-cron-job');
79
+
80
+ const result = await agenda.queryJobs({ name: 'test-cron-job' });
81
+ expect(result.jobs.length).toBe(1);
82
+ expect(result.jobs[0].repeatInterval).toBe('0 6 * * *');
83
+ });
84
+
85
+ it('should update a job that was previously scheduled', async () => {
86
+ await agenda.every('1 minute', 'update-interval-job');
87
+ await agenda.every('5 minutes', 'update-interval-job');
88
+
89
+ const result = await agenda.queryJobs({ name: 'update-interval-job' });
90
+ expect(result.jobs.length).toBe(1);
91
+ expect(result.jobs[0].repeatInterval).toBe('5 minutes');
92
+ });
93
+
94
+ it('should accept options object with timezone', async () => {
95
+ agenda.define('timezoneJob', jobProcessor);
96
+ const job = await agenda.every('5 minutes', 'timezoneJob', {}, { timezone: 'America/New_York' });
97
+ expect(job.attrs.repeatTimezone).toBe('America/New_York');
98
+ });
99
+
100
+ it('should set skipImmediate option', async () => {
101
+ agenda.define('skipImmediateJob', jobProcessor);
102
+ agenda.define('noSkipJob', jobProcessor);
103
+ // Without skipImmediate, job should run immediately (nextRunAt ~ now)
104
+ const jobWithoutSkip = await agenda.every('5 minutes', 'noSkipJob', {});
105
+ const immediateDiff = Math.abs(
106
+ (jobWithoutSkip.attrs.nextRunAt?.getTime() || 0) - Date.now()
107
+ );
108
+
109
+ // With skipImmediate, job should run in ~5 minutes (nextRunAt > now + 4 minutes)
110
+ const jobWithSkip = await agenda.every('5 minutes', 'skipImmediateJob', {}, { skipImmediate: true });
111
+ const skipDiff = (jobWithSkip.attrs.nextRunAt?.getTime() || 0) - Date.now();
112
+
113
+ // Without skip, should run very soon (within 1 second)
114
+ expect(immediateDiff).toBeLessThan(1000);
115
+ // With skip, should run in ~5 minutes (at least 4 minutes from now)
116
+ expect(skipDiff).toBeGreaterThan(4 * 60 * 1000);
117
+ });
118
+
119
+ it('should create multiple jobs with array of names', async () => {
120
+ agenda.define('arrayJob1', jobProcessor);
121
+ agenda.define('arrayJob2', jobProcessor);
122
+ const jobs = await agenda.every('5 minutes', ['arrayJob1', 'arrayJob2']) as Job[];
123
+ expect(jobs).toHaveLength(2);
124
+ expect(jobs.map(j => j.attrs.name)).toContain('arrayJob1');
125
+ expect(jobs.map(j => j.attrs.name)).toContain('arrayJob2');
126
+ });
127
+ });
128
+
129
+ describe('schedule()', () => {
130
+ it('should schedule a job for a specific time', async () => {
131
+ const when = new Date(Date.now() + 60000);
132
+ const job = await agenda.schedule(when, 'test-schedule-job');
133
+
134
+ expect(job.attrs.nextRunAt?.getTime()).toBe(when.getTime());
135
+ });
136
+
137
+ it('should schedule a job with human-readable time', async () => {
138
+ const job = await agenda.schedule('in 1 hour', 'test-human-schedule');
139
+
140
+ expect(job.attrs.nextRunAt).toBeDefined();
141
+ expect(job.attrs.nextRunAt!.getTime()).toBeGreaterThan(Date.now());
142
+ });
143
+
144
+ it('should create multiple jobs with array of names', async () => {
145
+ agenda.define('scheduleArrayJob1', jobProcessor);
146
+ agenda.define('scheduleArrayJob2', jobProcessor);
147
+ const when = new Date(Date.now() + 60000);
148
+ const jobs = await agenda.schedule(when, ['scheduleArrayJob1', 'scheduleArrayJob2']) as Job[];
149
+ expect(jobs).toHaveLength(2);
150
+ });
151
+ });
152
+
153
+ describe('now()', () => {
154
+ it('should schedule a job to run immediately', async () => {
155
+ const job = await agenda.now('test-now-job');
156
+
157
+ expect(job.attrs.nextRunAt).toBeDefined();
158
+ expect(job.attrs.nextRunAt!.getTime()).toBeLessThanOrEqual(Date.now() + 1000);
159
+ });
160
+
161
+ it('should schedule a job with data', async () => {
162
+ const job = await agenda.now('test-now-data', { key: 'value' });
163
+
164
+ expect(job.attrs.data).toEqual({ key: 'value' });
165
+ });
166
+ });
167
+
168
+ describe('queryJobs()', () => {
169
+ it('should return all jobs matching query', async () => {
170
+ await agenda.now('query-job-1');
171
+ await agenda.now('query-job-2');
172
+ await agenda.now('query-job-1');
173
+
174
+ const result = await agenda.queryJobs({ name: 'query-job-1' });
175
+ expect(result.jobs.length).toBe(2);
176
+ });
177
+
178
+ it('should return empty array when no jobs match', async () => {
179
+ const result = await agenda.queryJobs({ name: 'non-existent' });
180
+ expect(result.jobs).toEqual([]);
181
+ });
182
+
183
+ it('should limit results', async () => {
184
+ await agenda.now('limit-test');
185
+ await agenda.now('limit-test');
186
+ await agenda.now('limit-test');
187
+
188
+ const result = await agenda.queryJobs({ name: 'limit-test', limit: 2 });
189
+ expect(result.jobs.length).toBe(2);
190
+ expect(result.total).toBe(3);
191
+ });
192
+
193
+ it('should skip results', async () => {
194
+ await agenda.now('skip-test');
195
+ await agenda.now('skip-test');
196
+ await agenda.now('skip-test');
197
+
198
+ const result = await agenda.queryJobs({ name: 'skip-test', skip: 2 });
199
+ expect(result.jobs.length).toBe(1);
200
+ });
201
+
202
+ it('should combine limit and skip', async () => {
203
+ await agenda.now('limit-skip-test');
204
+ await agenda.now('limit-skip-test');
205
+ await agenda.now('limit-skip-test');
206
+ await agenda.now('limit-skip-test');
207
+ await agenda.now('limit-skip-test');
208
+
209
+ const result = await agenda.queryJobs({ name: 'limit-skip-test', limit: 2, skip: 2 });
210
+ expect(result.jobs.length).toBe(2);
211
+ });
212
+
213
+ it('should sort results', async () => {
214
+ const j1 = await agenda.now('sort-test', { order: 3 });
215
+ await j1.priority(3).save();
216
+ const j2 = await agenda.now('sort-test', { order: 1 });
217
+ await j2.priority(1).save();
218
+ const j3 = await agenda.now('sort-test', { order: 2 });
219
+ await j3.priority(2).save();
220
+
221
+ const result = await agenda.queryJobs({
222
+ name: 'sort-test',
223
+ sort: { priority: 'desc' }
224
+ });
225
+
226
+ expect(result.jobs.length).toBe(3);
227
+ expect(result.jobs[0].priority).toBe(3);
228
+ expect(result.jobs[1].priority).toBe(2);
229
+ expect(result.jobs[2].priority).toBe(1);
230
+ });
231
+ });
232
+
233
+ describe('cancel()', () => {
234
+ it('should cancel jobs matching query', async () => {
235
+ await agenda.now('cancel-test');
236
+ await agenda.now('cancel-test');
237
+ await agenda.now('keep-test');
238
+
239
+ const cancelled = await agenda.cancel({ name: 'cancel-test' });
240
+ expect(cancelled).toBe(2);
241
+
242
+ const result = await agenda.queryJobs({});
243
+ expect(result.jobs.length).toBe(1);
244
+ expect(result.jobs[0].name).toBe('keep-test');
245
+ });
246
+
247
+ it('should cancel only jobs matching both name and data filter', async () => {
248
+ await agenda.now('cancel-data-test', { userId: 1 });
249
+ await agenda.now('cancel-data-test', { userId: 2 });
250
+ await agenda.now('cancel-data-test', { userId: 3 });
251
+
252
+ const cancelled = await agenda.cancel({ name: 'cancel-data-test', data: { userId: 2 } });
253
+ expect(cancelled).toBe(1);
254
+
255
+ const result = await agenda.queryJobs({ name: 'cancel-data-test' });
256
+ expect(result.jobs.length).toBe(2);
257
+ const remaining = result.jobs.map(j => (j.data as { userId: number }).userId).sort();
258
+ expect(remaining).toEqual([1, 3]);
259
+ });
260
+
261
+ it('should cancel jobs matching data filter with nested objects', async () => {
262
+ await agenda.now('cancel-nested-test', { org: { id: 'abc' }, type: 'sync' });
263
+ await agenda.now('cancel-nested-test', { org: { id: 'def' }, type: 'sync' });
264
+ await agenda.now('cancel-nested-test', { org: { id: 'abc' }, type: 'backup' });
265
+
266
+ const cancelled = await agenda.cancel({ name: 'cancel-nested-test', data: { org: { id: 'abc' } } });
267
+ expect(cancelled).toBe(2);
268
+
269
+ const result = await agenda.queryJobs({ name: 'cancel-nested-test' });
270
+ expect(result.jobs.length).toBe(1);
271
+ expect((result.jobs[0].data as { org: { id: string } }).org.id).toBe('def');
272
+ });
273
+ });
274
+
275
+ describe('job disable/enable', () => {
276
+ it('should disable a job via Job.disable()', async () => {
277
+ const job = await agenda.now('disable-test');
278
+ await job.disable().save();
279
+
280
+ const result = await agenda.queryJobs({ name: 'disable-test' });
281
+ expect(result.jobs[0].disabled).toBe(true);
282
+ });
283
+
284
+ it('should enable a disabled job via Job.enable()', async () => {
285
+ const job = await agenda.now('enable-test');
286
+ await job.disable().save();
287
+
288
+ // Re-fetch and enable
289
+ const jobs = (await agenda.queryJobs({ name: 'enable-test' })).jobs;
290
+ const jobToEnable = agenda.create(jobs[0].name, jobs[0].data);
291
+ Object.assign(jobToEnable.attrs, jobs[0]);
292
+ await jobToEnable.enable().save();
293
+
294
+ const result = await agenda.queryJobs({ name: 'enable-test' });
295
+ expect(result.jobs[0].disabled).toBeFalsy();
296
+ });
297
+
298
+ it('should not run disabled jobs', async () => {
299
+ let processed = false;
300
+ agenda.define('disabled-process-test', async () => {
301
+ processed = true;
302
+ });
303
+
304
+ const job = await agenda.now('disabled-process-test');
305
+ await job.disable().save();
306
+ await agenda.start();
307
+
308
+ // Wait a bit to ensure job would have been picked up
309
+ await new Promise(resolve => setTimeout(resolve, 500));
310
+ expect(processed).toBe(false);
311
+ });
312
+
313
+ it('should disable jobs matching query via agenda.disable()', async () => {
314
+ await agenda.now('bulk-disable-test');
315
+ await agenda.now('bulk-disable-test');
316
+
317
+ const disabled = await agenda.disable({ name: 'bulk-disable-test' });
318
+ expect(disabled).toBe(2);
319
+
320
+ const result = await agenda.queryJobs({ name: 'bulk-disable-test' });
321
+ result.jobs.forEach(job => expect(job.disabled).toBe(true));
322
+ });
323
+
324
+ it('should enable disabled jobs via agenda.enable()', async () => {
325
+ const job = await agenda.now('bulk-enable-test');
326
+ await job.disable().save();
327
+
328
+ const enabled = await agenda.enable({ name: 'bulk-enable-test' });
329
+ expect(enabled).toBe(1);
330
+
331
+ const result = await agenda.queryJobs({ name: 'bulk-enable-test' });
332
+ expect(result.jobs[0].disabled).toBeFalsy();
333
+ });
334
+
335
+ it('should return 0 when no jobs match disable query', async () => {
336
+ const disabled = await agenda.disable({ name: 'non-existent-job' });
337
+ expect(disabled).toBe(0);
338
+ });
339
+
340
+ it('should return 0 when no options provided to disable', async () => {
341
+ const disabled = await agenda.disable({});
342
+ expect(disabled).toBe(0);
343
+ });
344
+ });
345
+
346
+ describe('job processing', () => {
347
+ it('should process a job', async () => {
348
+ let processed = false;
349
+ agenda.define('process-test', async () => {
350
+ processed = true;
351
+ });
352
+
353
+ await agenda.start();
354
+ await runJobAndWait(agenda, 'process-test', 'complete:process-test');
355
+ expect(processed).toBe(true);
356
+ });
357
+
358
+ it('should pass job instance to processor', async () => {
359
+ let receivedJob: Job | undefined;
360
+ agenda.define('job-instance-test', async (job: Job) => {
361
+ receivedJob = job;
362
+ });
363
+
364
+ await agenda.start();
365
+ await runJobAndWait(agenda, 'job-instance-test', 'complete:job-instance-test', { testData: 123 });
366
+ expect(receivedJob).toBeDefined();
367
+ expect(receivedJob?.attrs.name).toBe('job-instance-test');
368
+ expect(receivedJob?.attrs.data).toEqual({ testData: 123 });
369
+ });
370
+
371
+ it('should emit success event on completion', async () => {
372
+ let successEmitted = false;
373
+ agenda.define('success-event-test', async () => {});
374
+
375
+ agenda.on('success:success-event-test', () => {
376
+ successEmitted = true;
377
+ });
378
+
379
+ await agenda.start();
380
+ await runJobAndWait(agenda, 'success-event-test', 'complete:success-event-test');
381
+ expect(successEmitted).toBe(true);
382
+ });
383
+
384
+ it('should emit fail event on error', async () => {
385
+ let failEmitted = false;
386
+ let failError: Error | undefined;
387
+ agenda.define('fail-event-test', async () => {
388
+ throw new Error('Test error');
389
+ });
390
+
391
+ agenda.on('fail:fail-event-test', (err: Error) => {
392
+ failEmitted = true;
393
+ failError = err;
394
+ });
395
+
396
+ await agenda.start();
397
+ await runJobAndWait(agenda, 'fail-event-test', 'complete:fail-event-test');
398
+ expect(failEmitted).toBe(true);
399
+ expect(failError?.message).toBe('Test error');
400
+ });
401
+ });
402
+
403
+ describe('job locking', () => {
404
+ it('should lock jobs during processing', async () => {
405
+ let lockedDuringProcessing = false;
406
+ agenda.define('lock-test', async () => {
407
+ const result = await agenda.queryJobs({ name: 'lock-test' });
408
+ lockedDuringProcessing =
409
+ result.jobs[0].lockedAt !== null && result.jobs[0].lockedAt !== undefined;
410
+ });
411
+
412
+ await agenda.start();
413
+ await runJobAndWait(agenda, 'lock-test', 'complete:lock-test');
414
+ expect(lockedDuringProcessing).toBe(true);
415
+ });
416
+
417
+ it('should clear locks on stop', async () => {
418
+ agenda.define('clear-lock-test', async () => {
419
+ await new Promise(resolve => setTimeout(resolve, 5000));
420
+ });
421
+
422
+ await agenda.start();
423
+ // Wait for job to start (set up listener before scheduling to avoid race)
424
+ await runJobAndWait(agenda, 'clear-lock-test', 'start:clear-lock-test');
425
+ await agenda.stop();
426
+
427
+ const result = await agenda.queryJobs({ name: 'clear-lock-test' });
428
+ expect(result.jobs[0].lockedAt).toBeFalsy();
429
+ });
430
+ });
431
+
432
+ describe('concurrency', () => {
433
+ it('should respect maxConcurrency', async () => {
434
+ let running = 0;
435
+ let maxRunning = 0;
436
+
437
+ agenda.maxConcurrency(2);
438
+ agenda.define('concurrency-test', async () => {
439
+ running++;
440
+ maxRunning = Math.max(maxRunning, running);
441
+ await new Promise(resolve => setTimeout(resolve, 100));
442
+ running--;
443
+ });
444
+
445
+ await agenda.start();
446
+ await Promise.all([
447
+ agenda.now('concurrency-test'),
448
+ agenda.now('concurrency-test'),
449
+ agenda.now('concurrency-test'),
450
+ agenda.now('concurrency-test')
451
+ ]);
452
+
453
+ await waitForEvents(agenda, 'complete:concurrency-test', 4);
454
+ expect(maxRunning).toBeLessThanOrEqual(2);
455
+ });
456
+
457
+ it('should respect per-job concurrency', async () => {
458
+ let running = 0;
459
+ let maxRunning = 0;
460
+
461
+ agenda.define(
462
+ 'per-job-concurrency',
463
+ async () => {
464
+ running++;
465
+ maxRunning = Math.max(maxRunning, running);
466
+ await new Promise(resolve => setTimeout(resolve, 100));
467
+ running--;
468
+ },
469
+ { concurrency: 1 }
470
+ );
471
+
472
+ await agenda.start();
473
+ await Promise.all([
474
+ agenda.now('per-job-concurrency'),
475
+ agenda.now('per-job-concurrency'),
476
+ agenda.now('per-job-concurrency')
477
+ ]);
478
+
479
+ await waitForEvents(agenda, 'complete:per-job-concurrency', 3);
480
+ expect(maxRunning).toBe(1);
481
+ });
482
+ });
483
+
484
+ describe('priority', () => {
485
+ it('should process higher priority jobs first', async () => {
486
+ const processOrder: number[] = [];
487
+
488
+ agenda.define('priority-test', async (job: Job) => {
489
+ processOrder.push(job.attrs.priority || 0);
490
+ });
491
+
492
+ // Use the same nextRunAt for all jobs so priority is the deciding factor
493
+ const sameTime = new Date(Date.now() - 1000); // 1 second in the past
494
+
495
+ // Create jobs with same nextRunAt but different priorities
496
+ const j1 = agenda.create('priority-test');
497
+ j1.attrs.nextRunAt = sameTime;
498
+ await j1.priority(-10).save();
499
+
500
+ const j2 = agenda.create('priority-test');
501
+ j2.attrs.nextRunAt = sameTime;
502
+ await j2.priority(10).save();
503
+
504
+ const j3 = agenda.create('priority-test');
505
+ j3.attrs.nextRunAt = sameTime;
506
+ await j3.priority(0).save();
507
+
508
+ agenda.maxConcurrency(1); // Process one at a time
509
+ await agenda.start();
510
+
511
+ await waitForEvents(agenda, 'complete:priority-test', 3);
512
+
513
+ // Higher priority (10) should be processed first
514
+ expect(processOrder[0]).toBe(10);
515
+ });
516
+ });
517
+
518
+ describe('lock limits', () => {
519
+ it('should not lock more than agenda lockLimit jobs', async () => {
520
+ agenda.lockLimit(1);
521
+ agenda.processEvery(100);
522
+
523
+ agenda.define('lock-limit-job', (_job, _cb) => {
524
+ // This job never finishes
525
+ });
526
+
527
+ await agenda.start();
528
+
529
+ await Promise.all([
530
+ agenda.now('lock-limit-job', { i: 1 }),
531
+ agenda.now('lock-limit-job', { i: 2 })
532
+ ]);
533
+
534
+ // Wait for the process interval to pick up the jobs
535
+ await delay(500);
536
+
537
+ expect((await agenda.getRunningStats()).lockedJobs).toBe(1);
538
+ });
539
+
540
+ it('should not lock more mixed jobs than agenda lockLimit', async () => {
541
+ agenda.lockLimit(1);
542
+ agenda.processEvery(100);
543
+
544
+ agenda.define('lock-mixed-1', (_job, _cb) => {});
545
+ agenda.define('lock-mixed-2', (_job, _cb) => {});
546
+ agenda.define('lock-mixed-3', (_job, _cb) => {});
547
+
548
+ await agenda.start();
549
+
550
+ await Promise.all([
551
+ agenda.now('lock-mixed-1', { i: 1 }),
552
+ agenda.now('lock-mixed-2', { i: 2 }),
553
+ agenda.now('lock-mixed-3', { i: 3 })
554
+ ]);
555
+
556
+ await delay(500);
557
+ expect((await agenda.getRunningStats()).lockedJobs).toBe(1);
558
+ });
559
+
560
+ it('should not lock more than definition lockLimit jobs', async () => {
561
+ agenda.processEvery(100);
562
+ agenda.define('def-lock-limit', (_job, _cb) => {}, { lockLimit: 1 });
563
+
564
+ await agenda.start();
565
+
566
+ await Promise.all([
567
+ agenda.now('def-lock-limit', { i: 1 }),
568
+ agenda.now('def-lock-limit', { i: 2 })
569
+ ]);
570
+
571
+ await delay(500);
572
+ expect((await agenda.getRunningStats()).lockedJobs).toBe(1);
573
+ });
574
+
575
+ it('should not lock more than agenda lockLimit during processing interval', async () => {
576
+ agenda.lockLimit(1);
577
+ agenda.processEvery(200);
578
+
579
+ agenda.define('lock-interval', (_job, _cb) => {});
580
+
581
+ await agenda.start();
582
+
583
+ const when = new Date(Date.now() + 300);
584
+
585
+ await Promise.all([
586
+ agenda.schedule(when, 'lock-interval', { i: 1 }),
587
+ agenda.schedule(when, 'lock-interval', { i: 2 })
588
+ ]);
589
+
590
+ // Wait until the first job gets locked, then let further processing
591
+ // cycles run and verify the lock limit is still respected.
592
+ const lockedCount = async () => {
593
+ const { lockedJobs } = await agenda.getRunningStats();
594
+ return Array.isArray(lockedJobs) ? lockedJobs.length : lockedJobs;
595
+ };
596
+ expect(await waitFor(async () => (await lockedCount()) >= 1)).toBe(true);
597
+ await delay(500);
598
+ expect(await lockedCount()).toBe(1);
599
+ });
600
+
601
+ it('should not lock more than definition lockLimit during processing interval', async () => {
602
+ agenda.processEvery(200);
603
+ agenda.define('def-lock-interval', (_job, _cb) => {}, { lockLimit: 1 });
604
+
605
+ await agenda.start();
606
+
607
+ const when = new Date(Date.now() + 300);
608
+
609
+ await Promise.all([
610
+ agenda.schedule(when, 'def-lock-interval', { i: 1 }),
611
+ agenda.schedule(when, 'def-lock-interval', { i: 2 })
612
+ ]);
613
+
614
+ // Wait until the first job gets locked, then let further processing
615
+ // cycles run and verify the lock limit is still respected.
616
+ const lockedCount = async () => {
617
+ const { lockedJobs } = await agenda.getRunningStats();
618
+ return Array.isArray(lockedJobs) ? lockedJobs.length : lockedJobs;
619
+ };
620
+ expect(await waitFor(async () => (await lockedCount()) >= 1)).toBe(true);
621
+ await delay(500);
622
+ expect(await lockedCount()).toBe(1);
623
+ });
624
+ });
625
+
626
+ // Note: getRunningStats tests are in jobprocessor-test-suite.ts (more comprehensive)
627
+
628
+ describe('purge', () => {
629
+ it('should remove orphaned jobs (jobs without definitions)', async () => {
630
+ // Define and create jobs that should be KEPT
631
+ agenda.define('defined-job', jobProcessor);
632
+ await agenda.now('defined-job');
633
+
634
+ // Create orphaned jobs directly in DB (no definition)
635
+ // We need to temporarily define them to create, then "orphan" them
636
+ agenda.define('orphan-1', jobProcessor);
637
+ agenda.define('orphan-2', jobProcessor);
638
+ await agenda.now('orphan-1');
639
+ await agenda.now('orphan-2');
640
+
641
+ // Remove the definitions to make them orphans
642
+ delete agenda.definitions['orphan-1'];
643
+ delete agenda.definitions['orphan-2'];
644
+
645
+ const beforePurge = await agenda.queryJobs({});
646
+ expect(beforePurge.total).toBe(3);
647
+
648
+ // Purge should only remove orphaned jobs (not defined)
649
+ const purged = await agenda.purge();
650
+ expect(purged).toBe(2);
651
+
652
+ const afterPurge = await agenda.queryJobs({});
653
+ expect(afterPurge.total).toBe(1);
654
+ expect(afterPurge.jobs[0].name).toBe('defined-job');
655
+ });
656
+ });
657
+
658
+ describe('cancelAll()', () => {
659
+ it('should remove all jobs from the database', async () => {
660
+ await agenda.now('cancelAll-test-1');
661
+ await agenda.now('cancelAll-test-2');
662
+ await agenda.now('cancelAll-test-3');
663
+
664
+ const before = await agenda.queryJobs({});
665
+ expect(before.total).toBe(3);
666
+
667
+ const removed = await agenda.cancelAll();
668
+ expect(removed).toBe(3);
669
+
670
+ const after = await agenda.queryJobs({});
671
+ expect(after.total).toBe(0);
672
+ });
673
+
674
+ it('should return 0 when no jobs exist', async () => {
675
+ const removed = await agenda.cancelAll();
676
+ expect(removed).toBe(0);
677
+ });
678
+ });
679
+
680
+ describe('unique constraint', () => {
681
+ it('should modify one job when unique matches', async () => {
682
+ const job1 = await agenda
683
+ .create('unique-job', {
684
+ type: 'active',
685
+ userId: '123',
686
+ other: true
687
+ })
688
+ .unique({
689
+ 'data.type': 'active',
690
+ 'data.userId': '123'
691
+ })
692
+ .schedule('now')
693
+ .save();
694
+
695
+ const job2 = await agenda
696
+ .create('unique-job', {
697
+ type: 'active',
698
+ userId: '123',
699
+ other: false
700
+ })
701
+ .unique({
702
+ 'data.type': 'active',
703
+ 'data.userId': '123'
704
+ })
705
+ .schedule('now')
706
+ .save();
707
+
708
+ // Both should have the same ID (upserted)
709
+ expect(job1.attrs._id?.toString()).toBe(job2.attrs._id?.toString());
710
+
711
+ const result = await agenda.queryJobs({ name: 'unique-job' });
712
+ expect(result.jobs.length).toBe(1);
713
+ });
714
+
715
+ it('should not modify job when unique matches and insertOnly is set', async () => {
716
+ const job1 = await agenda
717
+ .create('unique-insert-only', {
718
+ type: 'active',
719
+ userId: '123',
720
+ value: 'first'
721
+ })
722
+ .unique(
723
+ {
724
+ 'data.type': 'active',
725
+ 'data.userId': '123'
726
+ },
727
+ { insertOnly: true }
728
+ )
729
+ .schedule('now')
730
+ .save();
731
+
732
+ const job2 = await agenda
733
+ .create('unique-insert-only', {
734
+ type: 'active',
735
+ userId: '123',
736
+ value: 'second'
737
+ })
738
+ .unique(
739
+ {
740
+ 'data.type': 'active',
741
+ 'data.userId': '123'
742
+ },
743
+ { insertOnly: true }
744
+ )
745
+ .schedule('now')
746
+ .save();
747
+
748
+ // Same ID but data should not be updated
749
+ expect(job1.attrs._id?.toString()).toBe(job2.attrs._id?.toString());
750
+
751
+ const result = await agenda.queryJobs({ name: 'unique-insert-only' });
752
+ expect(result.jobs.length).toBe(1);
753
+ // Original value should be preserved
754
+ expect((result.jobs[0].data as { value: string }).value).toBe('first');
755
+ });
756
+
757
+ it('should create two jobs when unique does not match', async () => {
758
+ await agenda
759
+ .create('unique-no-match', {
760
+ type: 'active',
761
+ userId: '123'
762
+ })
763
+ .unique({
764
+ 'data.type': 'active',
765
+ 'data.userId': '123'
766
+ })
767
+ .schedule('now')
768
+ .save();
769
+
770
+ await agenda
771
+ .create('unique-no-match', {
772
+ type: 'active',
773
+ userId: '456' // Different userId
774
+ })
775
+ .unique({
776
+ 'data.type': 'active',
777
+ 'data.userId': '456'
778
+ })
779
+ .schedule('now')
780
+ .save();
781
+
782
+ const result = await agenda.queryJobs({ name: 'unique-no-match' });
783
+ expect(result.jobs.length).toBe(2);
784
+ });
785
+ });
786
+
787
+ describe('Job class', () => {
788
+ describe('toJson', () => {
789
+ it('should return job attributes as JSON', async () => {
790
+ const job = await agenda.now('json-test', { foo: 'bar' });
791
+ const json = job.toJson();
792
+
793
+ expect(json.name).toBe('json-test');
794
+ expect(json.data).toEqual({ foo: 'bar' });
795
+ expect(json._id).toBeDefined();
796
+ });
797
+ });
798
+
799
+ describe('repeatEvery', () => {
800
+ it('should set repeatInterval', async () => {
801
+ const job = agenda.create('repeat-every-test');
802
+ job.repeatEvery('5 minutes');
803
+
804
+ expect(job.attrs.repeatInterval).toBe('5 minutes');
805
+ });
806
+
807
+ it('should accept cron expression', async () => {
808
+ const job = agenda.create('repeat-cron-test');
809
+ job.repeatEvery('0 6 * * *');
810
+
811
+ expect(job.attrs.repeatInterval).toBe('0 6 * * *');
812
+ });
813
+
814
+ it('should clear repeatInterval when set to null', async () => {
815
+ agenda.define('repeat-clear-test', jobProcessor);
816
+ const job = await agenda.create('repeat-clear-test');
817
+ job.repeatEvery('5 minutes');
818
+ await job.save();
819
+ expect(job.attrs.repeatInterval).toBe('5 minutes');
820
+
821
+ job.repeatEvery('');
822
+ await job.save();
823
+ expect(job.attrs.repeatInterval).toBe('');
824
+ });
825
+ });
826
+
827
+ describe('repeatAt', () => {
828
+ it('should set repeatAt time', async () => {
829
+ const job = agenda.create('repeat-at-test');
830
+ job.repeatAt('3:30pm');
831
+
832
+ expect(job.attrs.repeatAt).toBe('3:30pm');
833
+ });
834
+ });
835
+
836
+ describe('schedule', () => {
837
+ it('should set nextRunAt from Date', async () => {
838
+ const job = agenda.create('schedule-date-test');
839
+ const when = new Date(Date.now() + 60000);
840
+ job.schedule(when);
841
+
842
+ expect(job.attrs.nextRunAt?.getTime()).toBe(when.getTime());
843
+ });
844
+
845
+ it('should set nextRunAt from string', async () => {
846
+ const job = agenda.create('schedule-string-test');
847
+ job.schedule('in 5 minutes');
848
+
849
+ expect(job.attrs.nextRunAt).toBeDefined();
850
+ expect(job.attrs.nextRunAt!.getTime()).toBeGreaterThan(Date.now());
851
+ });
852
+ });
853
+
854
+ describe('priority', () => {
855
+ it('should set priority number', async () => {
856
+ const job = agenda.create('priority-num-test');
857
+ job.priority(10);
858
+
859
+ expect(job.attrs.priority).toBe(10);
860
+ });
861
+
862
+ it('should parse priority string', async () => {
863
+ const job = agenda.create('priority-str-test');
864
+ job.priority('high');
865
+
866
+ expect(job.attrs.priority).toBe(10);
867
+ });
868
+
869
+ it('should support negative priority', async () => {
870
+ const job = agenda.create('priority-neg-test');
871
+ job.priority(-5);
872
+
873
+ expect(job.attrs.priority).toBe(-5);
874
+ });
875
+ });
876
+
877
+ describe('remove', () => {
878
+ it('should remove job from database', async () => {
879
+ const job = await agenda.now('remove-test');
880
+ const id = job.attrs._id;
881
+
882
+ await job.remove();
883
+
884
+ const result = await agenda.queryJobs({ id: id?.toString() });
885
+ expect(result.jobs.length).toBe(0);
886
+ });
887
+ });
888
+
889
+ describe('run', () => {
890
+ it('should call the job processor directly', async () => {
891
+ let called = false;
892
+ agenda.define('run-direct-test', async () => {
893
+ called = true;
894
+ });
895
+
896
+ const job = agenda.create('run-direct-test');
897
+ await job.save(); // Save first to get an ID
898
+ await job.run();
899
+
900
+ expect(called).toBe(true);
901
+ });
902
+
903
+ it('should pass the job instance to the processor', async () => {
904
+ let receivedJob: Job | undefined;
905
+ agenda.define('run-job-instance-test', async (j: Job) => {
906
+ receivedJob = j;
907
+ });
908
+
909
+ const job = agenda.create('run-job-instance-test', { foo: 'bar' });
910
+ await job.save(); // Save first to get an ID
911
+ await job.run();
912
+
913
+ expect(receivedJob).toBeDefined();
914
+ expect(receivedJob?.attrs.data).toEqual({ foo: 'bar' });
915
+ });
916
+ });
917
+
918
+ describe('touch', () => {
919
+ it('should update lockedAt', async () => {
920
+ let touchedLockedAt: Date | undefined;
921
+ let initialLockedAt: Date | undefined;
922
+
923
+ agenda.define('touch-test', async (job: Job) => {
924
+ initialLockedAt = job.attrs.lockedAt;
925
+ await new Promise(resolve => setTimeout(resolve, 50));
926
+ await job.touch();
927
+ touchedLockedAt = job.attrs.lockedAt;
928
+ });
929
+
930
+ await agenda.start();
931
+ await runJobAndWait(agenda, 'touch-test', 'complete:touch-test');
932
+ expect(initialLockedAt).toBeDefined();
933
+ expect(touchedLockedAt).toBeDefined();
934
+ expect(touchedLockedAt!.getTime()).toBeGreaterThan(initialLockedAt!.getTime());
935
+ });
936
+
937
+ it('should persist progress value', async () => {
938
+ let jobId: string | undefined;
939
+
940
+ agenda.define('touch-progress-test', async (job: Job) => {
941
+ jobId = job.attrs._id?.toString();
942
+ await job.touch(50);
943
+ expect(job.attrs.progress).toBe(50);
944
+ });
945
+
946
+ await agenda.start();
947
+ await agenda.now('touch-progress-test');
948
+
949
+ await waitForEvent(agenda, 'complete:touch-progress-test');
950
+ expect(jobId).toBeDefined();
951
+
952
+ const result = await agenda.queryJobs({ id: jobId });
953
+ expect(result.jobs[0].progress).toBe(50);
954
+ });
955
+
956
+ it('should prevent long-running job from being re-locked after touch', async () => {
957
+ const lockLifetime = 500; // 500ms lock lifetime
958
+ let startCount = 0;
959
+
960
+ agenda.define(
961
+ 'touch-relock-test',
962
+ async (job: Job) => {
963
+ startCount++;
964
+ // Run for 3x the lockLifetime, touching every 100ms
965
+ const iterations = Math.ceil((lockLifetime * 3) / 100);
966
+ for (let i = 0; i < iterations; i++) {
967
+ await delay(100);
968
+ await job.touch();
969
+ }
970
+ },
971
+ { lockLifetime }
972
+ );
973
+
974
+ agenda.processEvery(200);
975
+ await agenda.start();
976
+ await agenda.now('touch-relock-test');
977
+
978
+ // Wait for 3x lockLifetime + buffer for the job to finish
979
+ await delay(lockLifetime * 3 + 500);
980
+ await agenda.stop();
981
+
982
+ // The job should have started exactly once — touch() keeps the lock alive
983
+ expect(startCount).toBe(1);
984
+ });
985
+ });
986
+
987
+ describe('fail', () => {
988
+ it('should set failure fields in memory', async () => {
989
+ const job = await agenda.now('fail-mark-test');
990
+ job.fail(new Error('Test failure'));
991
+
992
+ // fail() sets the in-memory attrs
993
+ expect(job.attrs.failReason).toBe('Test failure');
994
+ expect(job.attrs.failedAt).toBeDefined();
995
+ expect(job.attrs.failCount).toBe(1);
996
+ });
997
+
998
+ it('should increment failCount on multiple failures', async () => {
999
+ const job = await agenda.now('fail-count-test');
1000
+ job.fail(new Error('First failure'));
1001
+ job.fail(new Error('Second failure'));
1002
+
1003
+ expect(job.attrs.failCount).toBe(2);
1004
+ expect(job.attrs.failReason).toBe('Second failure');
1005
+ });
1006
+ });
1007
+
1008
+ describe('save', () => {
1009
+ it('should persist job to database', async () => {
1010
+ const job = agenda.create('save-test', { value: 42 });
1011
+ await job.save();
1012
+
1013
+ expect(job.attrs._id).toBeDefined();
1014
+
1015
+ const result = await agenda.queryJobs({ name: 'save-test' });
1016
+ expect(result.jobs.length).toBe(1);
1017
+ expect((result.jobs[0].data as { value: number }).value).toBe(42);
1018
+ });
1019
+
1020
+ it('should update existing job', async () => {
1021
+ const job = await agenda.now('save-update-test', { value: 1 });
1022
+ job.attrs.data = { value: 2 };
1023
+ await job.save();
1024
+
1025
+ const result = await agenda.queryJobs({ name: 'save-update-test' });
1026
+ expect(result.jobs.length).toBe(1);
1027
+ expect((result.jobs[0].data as { value: number }).value).toBe(2);
1028
+ });
1029
+
1030
+ it('should return the job', async () => {
1031
+ const job = agenda.create('save-return-test');
1032
+ const result = await job.save();
1033
+ expect(result).toBe(job);
1034
+ });
1035
+ });
1036
+ });
1037
+
1038
+ describe('async/callback job handling', () => {
1039
+ it('should allow async functions', async () => {
1040
+ let finished = false;
1041
+ let successCalled = false;
1042
+
1043
+ agenda.define('async-job', async () => {
1044
+ await delay(5);
1045
+ finished = true;
1046
+ });
1047
+
1048
+ agenda.once('success:async-job', () => {
1049
+ successCalled = true;
1050
+ });
1051
+
1052
+ const job = agenda.create('async-job');
1053
+ await job.save();
1054
+
1055
+ expect(finished).toBe(false);
1056
+ await job.run();
1057
+ expect(successCalled).toBe(true);
1058
+ expect(finished).toBe(true);
1059
+ });
1060
+
1061
+ it('should handle errors from async functions', async () => {
1062
+ let failCalled = false;
1063
+ let failError: Error | undefined;
1064
+ const err = new Error('async failure');
1065
+
1066
+ agenda.define('async-fail-job', async () => {
1067
+ await delay(5);
1068
+ throw err;
1069
+ });
1070
+
1071
+ agenda.once('fail:async-fail-job', (error: Error) => {
1072
+ failCalled = true;
1073
+ failError = error;
1074
+ });
1075
+
1076
+ const job = agenda.create('async-fail-job');
1077
+ await job.save();
1078
+ await job.run();
1079
+
1080
+ expect(failCalled).toBe(true);
1081
+ expect(failError?.message).toBe('async failure');
1082
+ });
1083
+
1084
+ it('should wait for callback even if function is async', async () => {
1085
+ let finishedCb = false;
1086
+ let successCalled = false;
1087
+
1088
+ agenda.define('async-cb-job', async (_job, cb) => {
1089
+ (async () => {
1090
+ await delay(5);
1091
+ finishedCb = true;
1092
+ cb();
1093
+ })();
1094
+ });
1095
+
1096
+ agenda.once('success:async-cb-job', () => {
1097
+ successCalled = true;
1098
+ });
1099
+
1100
+ const job = agenda.create('async-cb-job');
1101
+ await job.save();
1102
+ await job.run();
1103
+
1104
+ expect(finishedCb).toBe(true);
1105
+ expect(successCalled).toBe(true);
1106
+ });
1107
+
1108
+ it('should use callback error if async function did not reject', async () => {
1109
+ let failCalled = false;
1110
+ let failError: Error | undefined;
1111
+ const err = new Error('callback failure');
1112
+
1113
+ agenda.define('async-cb-error-job', async (_job, cb) => {
1114
+ (async () => {
1115
+ await delay(5);
1116
+ cb(err);
1117
+ })();
1118
+ });
1119
+
1120
+ agenda.once('fail:async-cb-error-job', (error: Error) => {
1121
+ failCalled = true;
1122
+ failError = error;
1123
+ });
1124
+
1125
+ const job = agenda.create('async-cb-error-job');
1126
+ await job.save();
1127
+ await job.run();
1128
+
1129
+ expect(failCalled).toBe(true);
1130
+ expect(failError?.message).toBe('callback failure');
1131
+ });
1132
+
1133
+ it('should favor async error over callback error if async comes first', async () => {
1134
+ let failCalled = false;
1135
+ let failError: Error | undefined;
1136
+ const fnErr = new Error('functionFailure');
1137
+ const cbErr = new Error('callbackFailure');
1138
+
1139
+ agenda.define('async-first-error-job', async (_job, cb) => {
1140
+ (async () => {
1141
+ await delay(5);
1142
+ cb(cbErr);
1143
+ })();
1144
+ throw fnErr;
1145
+ });
1146
+
1147
+ agenda.on('fail:async-first-error-job', (error: Error) => {
1148
+ failCalled = true;
1149
+ failError = error;
1150
+ });
1151
+
1152
+ const job = agenda.create('async-first-error-job');
1153
+ await job.save();
1154
+ await job.run();
1155
+
1156
+ expect(failCalled).toBe(true);
1157
+ expect(failError?.message).toBe('functionFailure');
1158
+ });
1159
+
1160
+ it('should favor callback error over async error if callback comes first', async () => {
1161
+ let failCalled = false;
1162
+ let failError: Error | undefined;
1163
+ const fnErr = new Error('functionFailure');
1164
+ const cbErr = new Error('callbackFailure');
1165
+
1166
+ agenda.define('cb-first-error-job', async (_job, cb) => {
1167
+ cb(cbErr);
1168
+ await delay(5);
1169
+ throw fnErr;
1170
+ });
1171
+
1172
+ agenda.on('fail:cb-first-error-job', (error: Error) => {
1173
+ failCalled = true;
1174
+ failError = error;
1175
+ });
1176
+
1177
+ const job = agenda.create('cb-first-error-job');
1178
+ await job.save();
1179
+ await job.run();
1180
+
1181
+ expect(failCalled).toBe(true);
1182
+ expect(failError?.message).toBe('callbackFailure');
1183
+ });
1184
+ });
1185
+
1186
+ describe('events', () => {
1187
+ it('should emit start event when job begins', async () => {
1188
+ let startEmitted = false;
1189
+ agenda.define('start-event-test', async () => {});
1190
+
1191
+ agenda.on('start:start-event-test', () => {
1192
+ startEmitted = true;
1193
+ });
1194
+
1195
+ await agenda.start();
1196
+ await runJobAndWait(agenda, 'start-event-test', 'complete:start-event-test');
1197
+ expect(startEmitted).toBe(true);
1198
+ });
1199
+
1200
+ it('should emit complete event when job finishes', async () => {
1201
+ let completeEmitted = false;
1202
+ agenda.define('complete-event-test', async () => {});
1203
+
1204
+ agenda.on('complete:complete-event-test', () => {
1205
+ completeEmitted = true;
1206
+ });
1207
+
1208
+ await agenda.start();
1209
+ await runJobAndWait(agenda, 'complete-event-test', 'complete:complete-event-test');
1210
+ expect(completeEmitted).toBe(true);
1211
+ });
1212
+
1213
+ it('should emit generic start event', async () => {
1214
+ let genericStartEmitted = false;
1215
+ agenda.define('generic-start-test', async () => {});
1216
+
1217
+ agenda.on('start', () => {
1218
+ genericStartEmitted = true;
1219
+ });
1220
+
1221
+ await agenda.start();
1222
+ await runJobAndWait(agenda, 'generic-start-test', 'complete:generic-start-test');
1223
+ expect(genericStartEmitted).toBe(true);
1224
+ });
1225
+ });
1226
+
1227
+ describe('drain', () => {
1228
+ it('should wait for all running jobs to complete', async () => {
1229
+ const completedJobs: string[] = [];
1230
+
1231
+ agenda.define('drain-test', async (job: Job) => {
1232
+ await new Promise(resolve => setTimeout(resolve, 200));
1233
+ completedJobs.push(job.attrs._id?.toString() || 'unknown');
1234
+ });
1235
+
1236
+ await agenda.start();
1237
+
1238
+ // Set up listener before scheduling to avoid race condition
1239
+ const startEvent = waitForEvent(agenda, 'start:drain-test');
1240
+
1241
+ // Schedule multiple jobs
1242
+ await Promise.all([
1243
+ agenda.now('drain-test'),
1244
+ agenda.now('drain-test'),
1245
+ agenda.now('drain-test')
1246
+ ]);
1247
+
1248
+ // Wait for at least one job to start
1249
+ await startEvent;
1250
+
1251
+ // Drain should wait for all to complete
1252
+ const result = await agenda.drain();
1253
+
1254
+ expect(completedJobs.length).toBe(3);
1255
+ expect(result.completed).toBe(3);
1256
+ expect(result.running).toBe(0);
1257
+ expect(result.timedOut).toBe(false);
1258
+ expect(result.aborted).toBe(false);
1259
+ });
1260
+
1261
+ it('should wait for running job to finish', async () => {
1262
+ let jobStarted = false;
1263
+ let jobFinished = false;
1264
+
1265
+ agenda.define('drainWaitJob', async () => {
1266
+ jobStarted = true;
1267
+ await new Promise(resolve => setTimeout(resolve, 300));
1268
+ jobFinished = true;
1269
+ });
1270
+
1271
+ await agenda.start();
1272
+ // Set up listener before scheduling to avoid race condition
1273
+ await runJobAndWait(agenda, 'drainWaitJob', 'start:drainWaitJob');
1274
+
1275
+ expect(jobStarted).toBe(true);
1276
+ expect(jobFinished).toBe(false);
1277
+
1278
+ // Call drain - should wait for job to complete
1279
+ const result = await agenda.drain();
1280
+ expect(jobFinished).toBe(true);
1281
+ expect(result.completed).toBe(1);
1282
+ expect(result.running).toBe(0);
1283
+ });
1284
+
1285
+ it('should resolve immediately if no jobs are running', async () => {
1286
+ await agenda.start();
1287
+ // No jobs scheduled, drain should resolve immediately
1288
+ const result = await agenda.drain();
1289
+ expect(result.completed).toBe(0);
1290
+ expect(result.running).toBe(0);
1291
+ expect(result.timedOut).toBe(false);
1292
+ expect(result.aborted).toBe(false);
1293
+ });
1294
+
1295
+ it('should timeout and return running jobs count', async () => {
1296
+ let jobStarted = false;
1297
+
1298
+ agenda.define('drain-timeout-test', async () => {
1299
+ jobStarted = true;
1300
+ // Job takes longer than timeout
1301
+ await new Promise(resolve => setTimeout(resolve, 5000));
1302
+ });
1303
+
1304
+ await agenda.start();
1305
+ await agenda.now('drain-timeout-test');
1306
+
1307
+ // Wait for job to start
1308
+ await waitForEvent(agenda, 'start:drain-timeout-test');
1309
+ expect(jobStarted).toBe(true);
1310
+
1311
+ // Drain with short timeout
1312
+ const result = await agenda.drain(100);
1313
+
1314
+ expect(result.timedOut).toBe(true);
1315
+ expect(result.aborted).toBe(false);
1316
+ expect(result.running).toBe(1);
1317
+ expect(result.completed).toBe(0);
1318
+ });
1319
+
1320
+ it('should accept timeout as options object', async () => {
1321
+ agenda.define('drain-timeout-opts-test', async () => {
1322
+ await new Promise(resolve => setTimeout(resolve, 5000));
1323
+ });
1324
+
1325
+ await agenda.start();
1326
+ await agenda.now('drain-timeout-opts-test');
1327
+
1328
+ await waitForEvent(agenda, 'start:drain-timeout-opts-test');
1329
+
1330
+ const result = await agenda.drain({ timeout: 100 });
1331
+
1332
+ expect(result.timedOut).toBe(true);
1333
+ expect(result.running).toBe(1);
1334
+ });
1335
+
1336
+ it('should abort when signal is triggered', async () => {
1337
+ let jobStarted = false;
1338
+
1339
+ agenda.define('drain-abort-test', async () => {
1340
+ jobStarted = true;
1341
+ await new Promise(resolve => setTimeout(resolve, 5000));
1342
+ });
1343
+
1344
+ await agenda.start();
1345
+ await agenda.now('drain-abort-test');
1346
+
1347
+ await waitForEvent(agenda, 'start:drain-abort-test');
1348
+ expect(jobStarted).toBe(true);
1349
+
1350
+ const controller = new AbortController();
1351
+
1352
+ // Abort after 100ms
1353
+ setTimeout(() => controller.abort(), 100);
1354
+
1355
+ const result = await agenda.drain({ signal: controller.signal });
1356
+
1357
+ expect(result.aborted).toBe(true);
1358
+ expect(result.timedOut).toBe(false);
1359
+ expect(result.running).toBe(1);
1360
+ expect(result.completed).toBe(0);
1361
+ });
1362
+
1363
+ it('should resolve immediately if signal is already aborted', async () => {
1364
+ agenda.define('drain-pre-aborted-test', async () => {
1365
+ await new Promise(resolve => setTimeout(resolve, 5000));
1366
+ });
1367
+
1368
+ await agenda.start();
1369
+ await agenda.now('drain-pre-aborted-test');
1370
+
1371
+ await waitForEvent(agenda, 'start:drain-pre-aborted-test');
1372
+
1373
+ const controller = new AbortController();
1374
+ controller.abort(); // Abort before calling drain
1375
+
1376
+ const result = await agenda.drain({ signal: controller.signal });
1377
+
1378
+ expect(result.aborted).toBe(true);
1379
+ expect(result.running).toBe(1);
1380
+ });
1381
+
1382
+ it('should support both timeout and signal together', async () => {
1383
+ agenda.define('drain-both-test', async () => {
1384
+ await new Promise(resolve => setTimeout(resolve, 5000));
1385
+ });
1386
+
1387
+ await agenda.start();
1388
+ await agenda.now('drain-both-test');
1389
+
1390
+ await waitForEvent(agenda, 'start:drain-both-test');
1391
+
1392
+ const controller = new AbortController();
1393
+
1394
+ // Signal aborts first (50ms) before timeout (200ms)
1395
+ setTimeout(() => controller.abort(), 50);
1396
+
1397
+ const result = await agenda.drain({ timeout: 200, signal: controller.signal });
1398
+
1399
+ expect(result.aborted).toBe(true);
1400
+ expect(result.timedOut).toBe(false);
1401
+ });
1402
+ });
1403
+
1404
+ describe('stop', () => {
1405
+ it('should stop agenda', async () => {
1406
+ await agenda.start();
1407
+ await agenda.stop();
1408
+ // Agenda should be stopped after stop - if we get here, it worked
1409
+ });
1410
+ });
1411
+
1412
+ describe('repeating jobs', () => {
1413
+ it('should reschedule job after completion when using repeatEvery', async () => {
1414
+ let runCount = 0;
1415
+ agenda.define('repeat-test', async () => {
1416
+ runCount++;
1417
+ });
1418
+
1419
+ // Use 1 second interval (humanInterval recognizes "1 second")
1420
+ await agenda.every('1 second', 'repeat-test');
1421
+ await agenda.start();
1422
+
1423
+ // Wait for multiple runs (need >2 seconds for 2+ runs with 1 second interval)
1424
+ await new Promise(resolve => setTimeout(resolve, 2500));
1425
+
1426
+ await agenda.stop();
1427
+ expect(runCount).toBeGreaterThanOrEqual(2);
1428
+ });
1429
+
1430
+ it('should run job at scheduled cron time', async () => {
1431
+ let processed = false;
1432
+ agenda.define('cron-test', async () => {
1433
+ processed = true;
1434
+ });
1435
+
1436
+ // Schedule to run every second
1437
+ await agenda.every('* * * * * *', 'cron-test');
1438
+ await agenda.start();
1439
+
1440
+ await waitForEvent(agenda, 'complete:cron-test', 3000);
1441
+ expect(processed).toBe(true);
1442
+ });
1443
+ });
1444
+
1445
+ describe('error handling', () => {
1446
+ it('should emit error event when job throws', async () => {
1447
+ let errorEmitted = false;
1448
+ let errorJob: Job | undefined;
1449
+
1450
+ agenda.define('error-test', async () => {
1451
+ throw new Error('Job error');
1452
+ });
1453
+
1454
+ agenda.on('fail:error-test', (err: Error, job: Job) => {
1455
+ errorEmitted = true;
1456
+ errorJob = job;
1457
+ });
1458
+
1459
+ await agenda.start();
1460
+ await runJobAndWait(agenda, 'error-test', 'complete:error-test');
1461
+ expect(errorEmitted).toBe(true);
1462
+ expect(errorJob?.attrs.name).toBe('error-test');
1463
+ });
1464
+
1465
+ it('should save failReason and failedAt when job fails', async () => {
1466
+ agenda.define('fail-save-test', async () => {
1467
+ throw new Error('Failure reason');
1468
+ });
1469
+
1470
+ await agenda.start();
1471
+ await runJobAndWait(agenda, 'fail-save-test', 'complete:fail-save-test');
1472
+
1473
+ const result = await agenda.queryJobs({ name: 'fail-save-test' });
1474
+ expect(result.jobs[0].failReason).toBe('Failure reason');
1475
+ expect(result.jobs[0].failedAt).toBeDefined();
1476
+ });
1477
+
1478
+ it('should increment failCount on repeated failures', async () => {
1479
+ agenda.define('fail-count-test', async () => {
1480
+ throw new Error('Repeated failure');
1481
+ });
1482
+ // The fail event emits (error, job), so second param is the job
1483
+ agenda.on('fail:fail-count-test', (_err, job) => {
1484
+ job.attrs.nextRunAt = new Date();
1485
+ });
1486
+
1487
+ await agenda.start();
1488
+ // Set up listeners before scheduling to avoid race condition
1489
+ const failEvents = waitForEvents(agenda, 'fail:fail-count-test', 2, 10000);
1490
+ const completeEvents = waitForEvents(agenda, 'complete:fail-count-test', 2, 10000);
1491
+ await agenda.now('fail-count-test');
1492
+
1493
+ // Wait for 2 failures (give enough time for job pickup and retries)
1494
+ await Promise.all([
1495
+ failEvents, // fail is called before we save the result to the db
1496
+ completeEvents // therefore we also wait for complete events (emitted after the db commit)
1497
+ ]);
1498
+
1499
+ const result = await agenda.queryJobs({ name: 'fail-count-test' });
1500
+ expect(result.jobs[0].failCount).toBeGreaterThanOrEqual(2);
1501
+ });
1502
+
1503
+ it('should not run failed jobs again unless rescheduled', async () => {
1504
+ let runCount = 0;
1505
+
1506
+ agenda.define('no-rerun-failed', async () => {
1507
+ runCount++;
1508
+ throw new Error('Intentional failure');
1509
+ });
1510
+
1511
+ await agenda.start();
1512
+ const event = waitForEvent(agenda, 'fail:no-rerun-failed', 10000);
1513
+ await agenda.now('no-rerun-failed');
1514
+
1515
+ await event;
1516
+ // Wait a bit to ensure job is not rerun
1517
+ await new Promise(resolve => setTimeout(resolve, 500));
1518
+
1519
+ expect(runCount).toBe(1);
1520
+
1521
+ // Verify job still exists but has no nextRunAt (won't run again)
1522
+ const result = await agenda.queryJobs({ name: 'no-rerun-failed' });
1523
+ expect(result.jobs.length).toBe(1);
1524
+ expect(result.jobs[0].failReason).toBe('Intentional failure');
1525
+ // nextRunAt should be null/undefined after failure (job is done, not rescheduled)
1526
+ expect(result.jobs[0].nextRunAt).toBeFalsy();
1527
+ });
1528
+
1529
+ it('should not cause unhandled promise rejection on job timeout', async () => {
1530
+ const unhandledRejections: Error[] = [];
1531
+ const rejectionHandler = (err: Error) => {
1532
+ console.error('unhandledRejection', err);
1533
+ unhandledRejections.push(err);
1534
+ };
1535
+ process.on('unhandledRejection', rejectionHandler);
1536
+
1537
+ try {
1538
+ agenda.define(
1539
+ 'timeout-test',
1540
+ async () => {
1541
+ // Job runs longer than lockLifetime
1542
+ await new Promise(resolve => setTimeout(resolve, 500));
1543
+ },
1544
+ { lockLifetime: 100 } // Very short lock lifetime
1545
+ );
1546
+
1547
+ agenda.on('error', (_err) => {
1548
+ // err handler is required
1549
+ })
1550
+ await agenda.start();
1551
+ // Set up listener before scheduling to avoid race condition
1552
+ await runJobAndWait(agenda, 'timeout-test', 'start:timeout-test');
1553
+ await new Promise(resolve => setTimeout(resolve, 700));
1554
+
1555
+ // Check no unhandled rejections occurred
1556
+ expect(unhandledRejections.length).toBe(0);
1557
+ } finally {
1558
+ process.off('unhandledRejection', rejectionHandler);
1559
+ }
1560
+ });
1561
+ });
1562
+
1563
+ // Notification channel integration tests - only run if channel is provided
1564
+ if (config.createNotificationChannel) {
1565
+ describe('notification channel integration', () => {
1566
+ let notificationChannel: NotificationChannel;
1567
+ let agendaWithChannel: Agenda;
1568
+
1569
+ beforeEach(async () => {
1570
+ if (config.createNotificationChannel) {
1571
+ notificationChannel = await config.createNotificationChannel();
1572
+ }
1573
+ });
1574
+
1575
+ afterEach(async () => {
1576
+ if (agendaWithChannel) {
1577
+ await agendaWithChannel.stop();
1578
+ }
1579
+ if (config.cleanupNotificationChannel && notificationChannel) {
1580
+ await config.cleanupNotificationChannel(notificationChannel);
1581
+ }
1582
+ });
1583
+
1584
+ it('should accept notification channel in constructor', async () => {
1585
+ agendaWithChannel = new Agenda({
1586
+ backend,
1587
+ notificationChannel
1588
+ });
1589
+
1590
+ expect(agendaWithChannel.hasNotificationChannel()).toBe(true);
1591
+ });
1592
+
1593
+ it('should accept notification channel via notifyVia method', async ({ skip }) => {
1594
+ // Skip if backend already provides a notification channel,
1595
+ // since Agenda picks it up automatically
1596
+ if (backend.notificationChannel) {
1597
+ skip();
1598
+ return;
1599
+ }
1600
+
1601
+ agendaWithChannel = new Agenda({ backend });
1602
+ expect(agendaWithChannel.hasNotificationChannel()).toBe(false);
1603
+
1604
+ agendaWithChannel.notifyVia(notificationChannel);
1605
+ expect(agendaWithChannel.hasNotificationChannel()).toBe(true);
1606
+ });
1607
+
1608
+ it('should throw when setting notification channel after start', async () => {
1609
+ agendaWithChannel = new Agenda({ backend });
1610
+ agendaWithChannel.define('test', async () => {});
1611
+ await agendaWithChannel.start();
1612
+
1613
+ expect(() => {
1614
+ agendaWithChannel.notifyVia(notificationChannel);
1615
+ }).toThrow(/already running/i);
1616
+ });
1617
+
1618
+ it('should connect and disconnect notification channel on start/stop', async () => {
1619
+ agendaWithChannel = new Agenda({
1620
+ backend,
1621
+ notificationChannel
1622
+ });
1623
+
1624
+ expect(notificationChannel.state).toBe('disconnected');
1625
+
1626
+ agendaWithChannel.define('test', async () => {});
1627
+ await agendaWithChannel.start();
1628
+
1629
+ expect(notificationChannel.state).toBe('connected');
1630
+
1631
+ await agendaWithChannel.stop();
1632
+
1633
+ expect(notificationChannel.state).toBe('disconnected');
1634
+ });
1635
+
1636
+ it('should process jobs faster with notification channel', async () => {
1637
+ let jobProcessed = false;
1638
+
1639
+ // Create agenda with long processEvery but with notification channel
1640
+ agendaWithChannel = new Agenda({
1641
+ backend,
1642
+ processEvery: 10000, // 10 seconds - way longer than our test
1643
+ notificationChannel
1644
+ });
1645
+ await agendaWithChannel.ready;
1646
+
1647
+ agendaWithChannel.define('fast-job', async () => {
1648
+ jobProcessed = true;
1649
+ });
1650
+
1651
+ await agendaWithChannel.start();
1652
+
1653
+ // Schedule a job - notification should trigger immediate processing
1654
+ await agendaWithChannel.now('fast-job');
1655
+
1656
+ // Wait a short time for notification-based processing
1657
+ await new Promise(resolve => setTimeout(resolve, 500));
1658
+
1659
+ expect(jobProcessed).toBe(true);
1660
+ });
1661
+ });
1662
+ }
1663
+
1664
+ // Backend-provided notification channel tests
1665
+ // Runs automatically when the backend provides a built-in notification channel
1666
+ // (e.g., PostgresBackend with LISTEN/NOTIFY, RedisBackend with Pub/Sub)
1667
+ describe('backend notification channel integration', () => {
1668
+ let agendaWithNotify: Agenda;
1669
+
1670
+ afterEach(async () => {
1671
+ if (agendaWithNotify) {
1672
+ await agendaWithNotify.stop();
1673
+ agendaWithNotify = undefined!;
1674
+ }
1675
+ await config.clearJobs(backend);
1676
+ });
1677
+
1678
+ it('should automatically use the backend notification channel', async ({ skip }) => {
1679
+ if (!backend.notificationChannel) {
1680
+ skip();
1681
+ return;
1682
+ }
1683
+
1684
+ agendaWithNotify = new Agenda({ backend });
1685
+ await agendaWithNotify.ready;
1686
+
1687
+ expect(agendaWithNotify.hasNotificationChannel()).toBe(true);
1688
+ });
1689
+
1690
+ it('should process job immediately via backend notification channel with long processEvery', async ({ skip }) => {
1691
+ if (!backend.notificationChannel) {
1692
+ skip();
1693
+ return;
1694
+ }
1695
+
1696
+ // Simulate a real-world setup: 5 minute polling interval
1697
+ // The notification channel should bypass the interval entirely
1698
+ agendaWithNotify = new Agenda({
1699
+ backend,
1700
+ processEvery: 5 * 60 * 1000 // 5 minutes
1701
+ });
1702
+ await agendaWithNotify.ready;
1703
+
1704
+ let jobRan = false;
1705
+ agendaWithNotify.define('notify-test-job', async () => {
1706
+ jobRan = true;
1707
+ });
1708
+
1709
+ await agendaWithNotify.start();
1710
+
1711
+ // Wait for the initial process() scan to complete so the processor is idle
1712
+ await delay(200);
1713
+
1714
+ // Now schedule a job — the notification channel should trigger immediate pickup
1715
+ const successPromise = waitForEvent(agendaWithNotify, 'success:notify-test-job', 5000);
1716
+ await agendaWithNotify.now('notify-test-job');
1717
+ await successPromise;
1718
+
1719
+ expect(jobRan).toBe(true);
1720
+ });
1721
+
1722
+ it('should process job scheduled from a separate agenda instance via notification', async ({ skip }) => {
1723
+ if (!backend.notificationChannel) {
1724
+ skip();
1725
+ return;
1726
+ }
1727
+
1728
+ // Simulates the publisher/worker pattern with separate backends
1729
+ // (as in a real multi-process deployment)
1730
+ const publisherBackend = await config.createBackend();
1731
+
1732
+ const worker = new Agenda({
1733
+ backend,
1734
+ processEvery: 5 * 60 * 1000 // 5 minutes
1735
+ });
1736
+ await worker.ready;
1737
+
1738
+ let jobRan = false;
1739
+ worker.define('cross-instance-job', async () => {
1740
+ jobRan = true;
1741
+ });
1742
+
1743
+ await worker.start();
1744
+
1745
+ // Wait for initial scan
1746
+ await delay(200);
1747
+
1748
+ // Separate publisher instance — only schedules jobs, does NOT process them.
1749
+ // This mirrors a common pattern: API server schedules, worker processes.
1750
+ // The publisher does NOT define the job, so it won't try to lock/run it.
1751
+ const publisher = new Agenda({ backend: publisherBackend });
1752
+ await publisher.ready;
1753
+
1754
+ // Start publisher to connect its notification channel
1755
+ await publisher.start();
1756
+
1757
+ // Schedule from the publisher — worker should pick it up via notification
1758
+ const successPromise = waitForEvent(worker, 'success:cross-instance-job', 5000);
1759
+ await publisher.now('cross-instance-job');
1760
+ await successPromise;
1761
+
1762
+ expect(jobRan).toBe(true);
1763
+
1764
+ await publisher.stop();
1765
+ await config.cleanupBackend(publisherBackend);
1766
+ agendaWithNotify = worker; // let afterEach clean up
1767
+ });
1768
+ });
1769
+
1770
+ // Fork mode tests - skip if configured to skip or if forkHelper is not provided
1771
+ if (!config.skip?.forkMode && config.forkHelper) {
1772
+ describe('fork mode', () => {
1773
+ it('should run a job in fork mode', async () => {
1774
+ const agendaFork = new Agenda({
1775
+ backend,
1776
+ forkHelper: config.forkHelper
1777
+ });
1778
+ await agendaFork.ready;
1779
+
1780
+ expect(agendaFork.forkHelper?.path).toBe(config.forkHelper!.path);
1781
+
1782
+ const job = agendaFork.create('some job');
1783
+ job.forkMode(true);
1784
+ job.schedule('now');
1785
+ await job.save();
1786
+
1787
+ const jobData = await backend.repository.getJobById(job.attrs._id!);
1788
+ expect(jobData).toBeDefined();
1789
+ expect(jobData?.fork).toBe(true);
1790
+
1791
+ // Initialize job definition
1792
+ someJobDefinition(agendaFork);
1793
+
1794
+ await agendaFork.start();
1795
+
1796
+ do {
1797
+ await delay(50);
1798
+ } while (await job.isRunning());
1799
+
1800
+ const jobDataFinished = await backend.repository.getJobById(job.attrs._id!);
1801
+ expect(jobDataFinished?.lastFinishedAt).toBeDefined();
1802
+ expect(jobDataFinished?.failReason).toBeUndefined();
1803
+ expect(jobDataFinished?.failCount).toBeUndefined();
1804
+
1805
+ await agendaFork.stop();
1806
+ });
1807
+
1808
+ it('should handle job failure in fork mode', async () => {
1809
+ const agendaFork = new Agenda({
1810
+ backend,
1811
+ forkHelper: config.forkHelper
1812
+ });
1813
+ await agendaFork.ready;
1814
+
1815
+ const job = agendaFork.create('some job', { failIt: 'error' });
1816
+ job.forkMode(true);
1817
+ job.schedule('now');
1818
+ await job.save();
1819
+
1820
+ const jobData = await backend.repository.getJobById(job.attrs._id!);
1821
+ expect(jobData).toBeDefined();
1822
+ expect(jobData?.fork).toBe(true);
1823
+
1824
+ // Initialize job definition
1825
+ someJobDefinition(agendaFork);
1826
+
1827
+ await agendaFork.start();
1828
+
1829
+ do {
1830
+ await delay(50);
1831
+ } while (await job.isRunning());
1832
+
1833
+ const jobDataFinished = await backend.repository.getJobById(job.attrs._id!);
1834
+ expect(jobDataFinished?.lastFinishedAt).toBeDefined();
1835
+ expect(jobDataFinished?.failReason).not.toBeUndefined();
1836
+ expect(jobDataFinished?.failCount).toBe(1);
1837
+
1838
+ await agendaFork.stop();
1839
+ });
1840
+
1841
+ it('should handle process exit in fork mode', async () => {
1842
+ const agendaFork = new Agenda({
1843
+ backend,
1844
+ forkHelper: config.forkHelper
1845
+ });
1846
+ await agendaFork.ready;
1847
+
1848
+ const job = agendaFork.create('some job', { failIt: 'die' });
1849
+ job.forkMode(true);
1850
+ job.schedule('now');
1851
+ await job.save();
1852
+
1853
+ const jobData = await backend.repository.getJobById(job.attrs._id!);
1854
+ expect(jobData).toBeDefined();
1855
+ expect(jobData?.fork).toBe(true);
1856
+
1857
+ // Initialize job definition
1858
+ someJobDefinition(agendaFork);
1859
+
1860
+ await agendaFork.start();
1861
+
1862
+ do {
1863
+ await delay(50);
1864
+ } while (await job.isRunning());
1865
+
1866
+ const jobDataFinished = await backend.repository.getJobById(job.attrs._id!);
1867
+ expect(jobDataFinished?.lastFinishedAt).toBeDefined();
1868
+ expect(jobDataFinished?.failReason).not.toBeUndefined();
1869
+ expect(jobDataFinished?.failCount).toBe(1);
1870
+
1871
+ await agendaFork.stop();
1872
+ });
1873
+ });
1874
+ }
1875
+ });
1876
+ }