@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,377 @@
1
+ /**
2
+ * Debounce test factory
3
+ *
4
+ * This file exports a test factory for job debounce tests.
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
8
+ import type { AgendaBackend } from '../../src/index.js';
9
+ import { Agenda } from '../../src/index.js';
10
+
11
+ export interface DebounceTestConfig {
12
+ /** Name for the test suite */
13
+ name: string;
14
+ /** Factory to create a fresh backend instance */
15
+ createBackend: () => Promise<AgendaBackend>;
16
+ /** Cleanup function called after tests */
17
+ cleanupBackend: (backend: AgendaBackend) => Promise<void>;
18
+ /** Clear all jobs between tests */
19
+ clearJobs: (backend: AgendaBackend) => Promise<void>;
20
+ }
21
+
22
+ /**
23
+ * Debounce tests
24
+ */
25
+ export function debounceTestSuite(config: DebounceTestConfig): void {
26
+ describe(`${config.name} - Debounce`, () => {
27
+ let backend: AgendaBackend;
28
+ let agenda: Agenda;
29
+
30
+ beforeAll(async () => {
31
+ backend = await config.createBackend();
32
+ });
33
+
34
+ afterAll(async () => {
35
+ await config.cleanupBackend(backend);
36
+ });
37
+
38
+ beforeEach(async () => {
39
+ await config.clearJobs(backend);
40
+ agenda = new Agenda({
41
+ backend,
42
+ processEvery: 50 // Fast polling for tests
43
+ });
44
+ await agenda.ready;
45
+ });
46
+
47
+ afterEach(async () => {
48
+ if (agenda) {
49
+ await agenda.stop();
50
+ }
51
+ await config.clearJobs(backend);
52
+ });
53
+
54
+ describe('trailing debounce (default)', () => {
55
+ it('should delay job execution by the debounce delay', async () => {
56
+ agenda.define('debounceTest', async () => {});
57
+
58
+ const beforeSave = Date.now();
59
+ const job = await agenda
60
+ .create('debounceTest', { key: 'test1' })
61
+ .unique({ 'data.key': 'test1' })
62
+ .debounce(1000)
63
+ .save();
64
+
65
+ const afterSave = Date.now();
66
+
67
+ // nextRunAt should be approximately now + 1000ms
68
+ expect(job.attrs.nextRunAt).toBeDefined();
69
+ const nextRunAt = job.attrs.nextRunAt!.getTime();
70
+ expect(nextRunAt).toBeGreaterThanOrEqual(beforeSave + 1000);
71
+ expect(nextRunAt).toBeLessThanOrEqual(afterSave + 1000 + 100); // Allow 100ms tolerance
72
+ });
73
+
74
+ it('should reset the timer on subsequent saves', async () => {
75
+ agenda.define('debounceTest', async () => {});
76
+
77
+ // First save
78
+ const job1 = await agenda
79
+ .create('debounceTest', { key: 'test2' })
80
+ .unique({ 'data.key': 'test2' })
81
+ .debounce(1000)
82
+ .save();
83
+
84
+ const firstNextRunAt = job1.attrs.nextRunAt!.getTime();
85
+
86
+ // Wait a bit
87
+ await new Promise(resolve => setTimeout(resolve, 200));
88
+
89
+ // Second save should push nextRunAt forward
90
+ const job2 = await agenda
91
+ .create('debounceTest', { key: 'test2', updated: true })
92
+ .unique({ 'data.key': 'test2' })
93
+ .debounce(1000)
94
+ .save();
95
+
96
+ const secondNextRunAt = job2.attrs.nextRunAt!.getTime();
97
+
98
+ // Second nextRunAt should be later than first
99
+ expect(secondNextRunAt).toBeGreaterThan(firstNextRunAt);
100
+ // And should be approximately 1000ms from now (not from first save)
101
+ expect(secondNextRunAt).toBeGreaterThanOrEqual(Date.now() + 900);
102
+ });
103
+
104
+ it('should update job data on subsequent saves', async () => {
105
+ agenda.define('debounceTest', async () => {});
106
+
107
+ // First save
108
+ await agenda
109
+ .create('debounceTest', { key: 'test3', value: 'first' })
110
+ .unique({ 'data.key': 'test3' })
111
+ .debounce(1000)
112
+ .save();
113
+
114
+ // Second save with updated data
115
+ const job2 = await agenda
116
+ .create('debounceTest', { key: 'test3', value: 'second' })
117
+ .unique({ 'data.key': 'test3' })
118
+ .debounce(1000)
119
+ .save();
120
+
121
+ // Data should be updated to the latest
122
+ expect(job2.attrs.data).toEqual({ key: 'test3', value: 'second' });
123
+ });
124
+
125
+ it('should only create one job in the database', async () => {
126
+ agenda.define('debounceTest', async () => {});
127
+
128
+ // Multiple rapid saves
129
+ for (let i = 0; i < 5; i++) {
130
+ await agenda
131
+ .create('debounceTest', { key: 'test4', iteration: i })
132
+ .unique({ 'data.key': 'test4' })
133
+ .debounce(500)
134
+ .save();
135
+ }
136
+
137
+ // Query jobs
138
+ const result = await agenda.queryJobs({ name: 'debounceTest' });
139
+ expect(result.jobs.length).toBe(1);
140
+ expect(result.jobs[0].data).toEqual({ key: 'test4', iteration: 4 });
141
+ });
142
+ });
143
+
144
+ describe('leading debounce', () => {
145
+ it('should execute immediately on first call', async () => {
146
+ agenda.define('debounceLeading', async () => {});
147
+
148
+ const beforeSave = Date.now();
149
+ const job = await agenda
150
+ .create('debounceLeading', { key: 'lead1' })
151
+ .unique({ 'data.key': 'lead1' })
152
+ .debounce(5000, { strategy: 'leading' })
153
+ .save();
154
+
155
+ const afterSave = Date.now();
156
+
157
+ // nextRunAt should be approximately now (immediate execution)
158
+ expect(job.attrs.nextRunAt).toBeDefined();
159
+ const nextRunAt = job.attrs.nextRunAt!.getTime();
160
+ expect(nextRunAt).toBeGreaterThanOrEqual(beforeSave - 100);
161
+ expect(nextRunAt).toBeLessThanOrEqual(afterSave + 100);
162
+ });
163
+
164
+ it('should keep original nextRunAt on subsequent saves', async () => {
165
+ agenda.define('debounceLeading', async () => {});
166
+
167
+ // First save
168
+ const job1 = await agenda
169
+ .create('debounceLeading', { key: 'lead2' })
170
+ .unique({ 'data.key': 'lead2' })
171
+ .debounce(5000, { strategy: 'leading' })
172
+ .save();
173
+
174
+ const firstNextRunAt = job1.attrs.nextRunAt!.getTime();
175
+
176
+ // Wait a bit
177
+ await new Promise(resolve => setTimeout(resolve, 200));
178
+
179
+ // Second save should NOT change nextRunAt
180
+ const job2 = await agenda
181
+ .create('debounceLeading', { key: 'lead2', updated: true })
182
+ .unique({ 'data.key': 'lead2' })
183
+ .debounce(5000, { strategy: 'leading' })
184
+ .save();
185
+
186
+ const secondNextRunAt = job2.attrs.nextRunAt!.getTime();
187
+
188
+ // nextRunAt should remain the same
189
+ expect(secondNextRunAt).toBe(firstNextRunAt);
190
+ });
191
+
192
+ it('should still update job data on subsequent saves', async () => {
193
+ agenda.define('debounceLeading', async () => {});
194
+
195
+ // First save
196
+ await agenda
197
+ .create('debounceLeading', { key: 'lead3', value: 'first' })
198
+ .unique({ 'data.key': 'lead3' })
199
+ .debounce(5000, { strategy: 'leading' })
200
+ .save();
201
+
202
+ // Second save with updated data
203
+ const job2 = await agenda
204
+ .create('debounceLeading', { key: 'lead3', value: 'second' })
205
+ .unique({ 'data.key': 'lead3' })
206
+ .debounce(5000, { strategy: 'leading' })
207
+ .save();
208
+
209
+ // Data should be updated even though nextRunAt isn't
210
+ expect(job2.attrs.data).toEqual({ key: 'lead3', value: 'second' });
211
+ });
212
+ });
213
+
214
+ describe('maxWait', () => {
215
+ it('should force execution when maxWait is exceeded', async () => {
216
+ agenda.define('debounceMaxWait', async () => {});
217
+
218
+ // First save with short maxWait
219
+ const job1 = await agenda
220
+ .create('debounceMaxWait', { key: 'max1' })
221
+ .unique({ 'data.key': 'max1' })
222
+ .debounce(2000, { maxWait: 500 })
223
+ .save();
224
+
225
+ // debounceStartedAt should be set
226
+ expect(job1.attrs.debounceStartedAt).toBeDefined();
227
+
228
+ // Wait for maxWait to be exceeded
229
+ await new Promise(resolve => setTimeout(resolve, 600));
230
+
231
+ // Second save should trigger immediate execution
232
+ const job2 = await agenda
233
+ .create('debounceMaxWait', { key: 'max1', updated: true })
234
+ .unique({ 'data.key': 'max1' })
235
+ .debounce(2000, { maxWait: 500 })
236
+ .save();
237
+
238
+ // nextRunAt should be now (not delayed by 2000ms)
239
+ const now = Date.now();
240
+ expect(job2.attrs.nextRunAt!.getTime()).toBeLessThanOrEqual(now + 100);
241
+
242
+ // debounceStartedAt should be cleared for next cycle
243
+ // (may be undefined or null depending on backend)
244
+ expect(job2.attrs.debounceStartedAt ?? undefined).toBeUndefined();
245
+ });
246
+
247
+ it('should keep debouncing while within maxWait', async () => {
248
+ agenda.define('debounceMaxWait', async () => {});
249
+
250
+ // First save
251
+ const job1 = await agenda
252
+ .create('debounceMaxWait', { key: 'max2' })
253
+ .unique({ 'data.key': 'max2' })
254
+ .debounce(500, { maxWait: 5000 })
255
+ .save();
256
+
257
+ const firstNextRunAt = job1.attrs.nextRunAt!.getTime();
258
+
259
+ // Wait less than maxWait
260
+ await new Promise(resolve => setTimeout(resolve, 200));
261
+
262
+ // Second save should still debounce normally
263
+ const job2 = await agenda
264
+ .create('debounceMaxWait', { key: 'max2', updated: true })
265
+ .unique({ 'data.key': 'max2' })
266
+ .debounce(500, { maxWait: 5000 })
267
+ .save();
268
+
269
+ const secondNextRunAt = job2.attrs.nextRunAt!.getTime();
270
+
271
+ // Should be pushed forward (normal debounce behavior)
272
+ expect(secondNextRunAt).toBeGreaterThan(firstNextRunAt);
273
+ // Should still be delayed by ~500ms from now
274
+ expect(secondNextRunAt).toBeGreaterThanOrEqual(Date.now() + 400);
275
+ });
276
+ });
277
+
278
+ describe('debounce with different unique keys', () => {
279
+ it('should debounce independently per unique key', async () => {
280
+ agenda.define('debounceMulti', async () => {});
281
+
282
+ // Save with key 1
283
+ const job1 = await agenda
284
+ .create('debounceMulti', { entityId: 1 })
285
+ .unique({ 'data.entityId': 1 })
286
+ .debounce(1000)
287
+ .save();
288
+
289
+ // Save with key 2
290
+ const job2 = await agenda
291
+ .create('debounceMulti', { entityId: 2 })
292
+ .unique({ 'data.entityId': 2 })
293
+ .debounce(1000)
294
+ .save();
295
+
296
+ // Query jobs
297
+ const result = await agenda.queryJobs({ name: 'debounceMulti' });
298
+ expect(result.jobs.length).toBe(2);
299
+
300
+ // Each should have its own debounce timer
301
+ expect(job1.attrs._id).not.toBe(job2.attrs._id);
302
+ });
303
+ });
304
+
305
+ describe('debounce requires unique constraint', () => {
306
+ it('should work only when unique constraint is set', async () => {
307
+ agenda.define('debounceNoUnique', async () => {});
308
+
309
+ // Without unique - creates new jobs each time
310
+ const job1 = await agenda
311
+ .create('debounceNoUnique', { value: 1 })
312
+ .debounce(1000)
313
+ .save();
314
+
315
+ const job2 = await agenda
316
+ .create('debounceNoUnique', { value: 2 })
317
+ .debounce(1000)
318
+ .save();
319
+
320
+ // Should create separate jobs since no unique constraint
321
+ expect(job1.attrs._id).not.toBe(job2.attrs._id);
322
+
323
+ const result = await agenda.queryJobs({ name: 'debounceNoUnique' });
324
+ expect(result.jobs.length).toBe(2);
325
+ });
326
+ });
327
+
328
+ describe('job execution with debounce', () => {
329
+ it('should execute job after debounce delay', async () => {
330
+ let executed = false;
331
+ let executedData: unknown;
332
+
333
+ agenda.define('debounceExec', async (job) => {
334
+ executed = true;
335
+ executedData = job.attrs.data;
336
+ });
337
+
338
+ // Multiple saves within debounce window
339
+ await agenda
340
+ .create('debounceExec', { key: 'exec1', value: 1 })
341
+ .unique({ 'data.key': 'exec1' })
342
+ .debounce(200)
343
+ .save();
344
+
345
+ await new Promise(resolve => setTimeout(resolve, 50));
346
+
347
+ await agenda
348
+ .create('debounceExec', { key: 'exec1', value: 2 })
349
+ .unique({ 'data.key': 'exec1' })
350
+ .debounce(200)
351
+ .save();
352
+
353
+ await new Promise(resolve => setTimeout(resolve, 50));
354
+
355
+ await agenda
356
+ .create('debounceExec', { key: 'exec1', value: 3 })
357
+ .unique({ 'data.key': 'exec1' })
358
+ .debounce(200)
359
+ .save();
360
+
361
+ // Start processing
362
+ await agenda.start();
363
+
364
+ // Wait for debounce delay + processing time
365
+ await new Promise(resolve => setTimeout(resolve, 400));
366
+
367
+ // Job should have executed once with the latest data
368
+ expect(executed).toBe(true);
369
+ expect(executedData).toEqual({ key: 'exec1', value: 3 });
370
+
371
+ // Only one job should exist
372
+ const result = await agenda.queryJobs({ name: 'debounceExec' });
373
+ expect(result.jobs.length).toBe(1);
374
+ });
375
+ });
376
+ });
377
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Full Agenda test suite that runs all shared test suites
3
+ *
4
+ * This is the recommended way to test a backend implementation.
5
+ * It runs all the individual test suites (repository, agenda, job, etc.)
6
+ * with a single configuration.
7
+ *
8
+ * Usage:
9
+ * ```typescript
10
+ * import { fullAgendaTestSuite } from 'agenda/test/shared';
11
+ *
12
+ * fullAgendaTestSuite({
13
+ * name: 'MongoDB',
14
+ * createBackend: async () => {
15
+ * const backend = new MongoBackend({ mongo: db });
16
+ * await backend.connect();
17
+ * return backend;
18
+ * },
19
+ * cleanupBackend: async (backend) => {
20
+ * await backend.disconnect();
21
+ * },
22
+ * clearJobs: async (backend) => {
23
+ * await db.collection('agendaJobs').deleteMany({});
24
+ * }
25
+ * });
26
+ * ```
27
+ */
28
+
29
+ import type { AgendaBackend, NotificationChannel } from '../../src/index.js';
30
+ import { repositoryTestSuite } from './repository-test-suite.js';
31
+ import { agendaTestSuite, type ForkHelperConfig } from './agenda-test-suite.js';
32
+ import { jobProcessorTestSuite } from './jobprocessor-test-suite.js';
33
+ import { retryTestSuite } from './retry-test-suite.js';
34
+ import { backoffTestSuite } from './backoff-test-suite.js';
35
+ import { debounceTestSuite } from './debounce-test-suite.js';
36
+ import { removeOnCompleteTestSuite } from './removeoncomplete-test-suite.js';
37
+
38
+ export interface FullAgendaTestConfig {
39
+ /** Name for the test suite (e.g., 'MongoDB', 'PostgreSQL') */
40
+ name: string;
41
+ /** Factory to create a fresh backend instance */
42
+ createBackend: () => Promise<AgendaBackend>;
43
+ /** Cleanup function called after tests */
44
+ cleanupBackend: (backend: AgendaBackend) => Promise<void>;
45
+ /** Clear all jobs between tests */
46
+ clearJobs: (backend: AgendaBackend) => Promise<void>;
47
+ /** Optional notification channel factory for testing notification integration */
48
+ createNotificationChannel?: () => Promise<NotificationChannel>;
49
+ /** Cleanup notification channel */
50
+ cleanupNotificationChannel?: (channel: NotificationChannel) => Promise<void>;
51
+ /** Fork mode configuration (backend-specific fork helper) */
52
+ forkHelper?: ForkHelperConfig;
53
+ /** Skip specific test suites */
54
+ skip?: {
55
+ repository?: boolean;
56
+ agenda?: boolean;
57
+ jobProcessor?: boolean;
58
+ retry?: boolean;
59
+ backoff?: boolean;
60
+ debounce?: boolean;
61
+ removeOnComplete?: boolean;
62
+ forkMode?: boolean;
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Runs the full Agenda test suite for a backend implementation
68
+ *
69
+ * This includes:
70
+ * - Repository tests (IJobRepository interface compliance)
71
+ * - Agenda integration tests (scheduling, processing, events, Job class)
72
+ * - JobProcessor tests (concurrency, locking, stats)
73
+ * - Retry tests (job retry behavior)
74
+ * - Backoff tests (automatic retry with backoff strategies)
75
+ * - Debounce tests (job debouncing with unique constraints)
76
+ * - RemoveOnComplete tests (auto-removal of completed one-time jobs)
77
+ */
78
+ export function fullAgendaTestSuite(config: FullAgendaTestConfig): void {
79
+ const skip = config.skip ?? {};
80
+
81
+ // Repository tests
82
+ if (!skip.repository) {
83
+ repositoryTestSuite({
84
+ name: `${config.name} Repository`,
85
+ createRepository: async () => {
86
+ const backend = await config.createBackend();
87
+ return backend.repository;
88
+ },
89
+ cleanupRepository: async () => {
90
+ // Backend cleanup handles this
91
+ }
92
+ // clearJobs not passed - repositoryTestSuite uses removeJobs as default
93
+ });
94
+ }
95
+
96
+ // Main Agenda integration tests
97
+ if (!skip.agenda) {
98
+ agendaTestSuite({
99
+ name: config.name,
100
+ createBackend: config.createBackend,
101
+ cleanupBackend: config.cleanupBackend,
102
+ clearJobs: config.clearJobs,
103
+ createNotificationChannel: config.createNotificationChannel,
104
+ cleanupNotificationChannel: config.cleanupNotificationChannel,
105
+ forkHelper: config.forkHelper,
106
+ skip: {
107
+ forkMode: skip.forkMode
108
+ }
109
+ });
110
+ }
111
+
112
+ // JobProcessor tests
113
+ if (!skip.jobProcessor) {
114
+ jobProcessorTestSuite({
115
+ name: config.name,
116
+ createBackend: config.createBackend,
117
+ cleanupBackend: config.cleanupBackend,
118
+ clearJobs: config.clearJobs
119
+ });
120
+ }
121
+
122
+ // Retry tests
123
+ if (!skip.retry) {
124
+ retryTestSuite({
125
+ name: config.name,
126
+ createBackend: config.createBackend,
127
+ cleanupBackend: config.cleanupBackend,
128
+ clearJobs: config.clearJobs
129
+ });
130
+ }
131
+
132
+ // Backoff tests
133
+ if (!skip.backoff) {
134
+ backoffTestSuite({
135
+ name: config.name,
136
+ createBackend: config.createBackend,
137
+ cleanupBackend: config.cleanupBackend,
138
+ clearJobs: config.clearJobs
139
+ });
140
+ }
141
+
142
+ // Debounce tests
143
+ if (!skip.debounce) {
144
+ debounceTestSuite({
145
+ name: config.name,
146
+ createBackend: config.createBackend,
147
+ cleanupBackend: config.cleanupBackend,
148
+ clearJobs: config.clearJobs
149
+ });
150
+ }
151
+
152
+ // RemoveOnComplete tests
153
+ if (!skip.removeOnComplete) {
154
+ removeOnCompleteTestSuite({
155
+ name: config.name,
156
+ createBackend: config.createBackend,
157
+ cleanupBackend: config.cleanupBackend,
158
+ clearJobs: config.clearJobs
159
+ });
160
+ }
161
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Shared test suites for Agenda backend implementations
3
+ *
4
+ * The recommended way to test a backend is to use fullAgendaTestSuite,
5
+ * which runs all the individual test suites with a single configuration.
6
+ *
7
+ * Usage in a backend package:
8
+ * ```typescript
9
+ * import { fullAgendaTestSuite } from 'agenda/test/shared';
10
+ *
11
+ * fullAgendaTestSuite({
12
+ * name: 'MongoDB',
13
+ * createBackend: async () => {
14
+ * const backend = new MongoBackend({ mongo: db });
15
+ * await backend.connect();
16
+ * return backend;
17
+ * },
18
+ * cleanupBackend: async (backend) => await backend.disconnect(),
19
+ * clearJobs: async (backend) => await db.collection('agendaJobs').deleteMany({})
20
+ * });
21
+ * ```
22
+ *
23
+ * Individual test suites are also available if you need more control:
24
+ * - repositoryTestSuite - Tests IJobRepository interface compliance
25
+ * - agendaTestSuite - Main Agenda integration tests (includes Job class tests)
26
+ * - notificationChannelTestSuite - Tests INotificationChannel interface
27
+ * - jobProcessorTestSuite - JobProcessor concurrency and stats tests
28
+ * - retryTestSuite - Job retry behavior tests
29
+ * - debounceTestSuite - Job debouncing with unique constraints
30
+ */
31
+
32
+ // Test utilities
33
+ export { delay, waitForEvent, waitForEvents, runJobAndWait } from './test-utils.js';
34
+
35
+ // Main entry point - runs all test suites
36
+ export { fullAgendaTestSuite, type FullAgendaTestConfig } from './full-test-suite.js';
37
+
38
+ // Individual test suites for fine-grained control
39
+ export { repositoryTestSuite, type RepositoryTestConfig } from './repository-test-suite.js';
40
+ export {
41
+ notificationChannelTestSuite,
42
+ type NotificationChannelTestConfig
43
+ } from './notification-channel-test-suite.js';
44
+ export { agendaTestSuite, type AgendaTestConfig, type ForkHelperConfig } from './agenda-test-suite.js';
45
+ export { jobProcessorTestSuite, type JobProcessorTestConfig } from './jobprocessor-test-suite.js';
46
+ export { retryTestSuite, type RetryTestConfig } from './retry-test-suite.js';
47
+ export { backoffTestSuite, type BackoffTestConfig } from './backoff-test-suite.js';
48
+ export { debounceTestSuite, type DebounceTestConfig } from './debounce-test-suite.js';
49
+ export { removeOnCompleteTestSuite, type RemoveOnCompleteTestConfig } from './removeoncomplete-test-suite.js';
50
+ export { jobLoggerTestSuite, type JobLoggerTestConfig } from './joblogger-test-suite.js';