@rebasepro/server-core 0.0.1-canary.4d4fb3e → 0.0.1-canary.ca2cb6e

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 (176) hide show
  1. package/dist/common/src/collections/CollectionRegistry.d.ts +8 -0
  2. package/dist/common/src/util/entities.d.ts +22 -0
  3. package/dist/common/src/util/relations.d.ts +14 -4
  4. package/dist/common/src/util/resolutions.d.ts +1 -1
  5. package/dist/index-DXVBFp5V.js +37 -0
  6. package/dist/index-DXVBFp5V.js.map +1 -0
  7. package/dist/index.es.js +49931 -55
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/index.umd.js +28475 -34569
  10. package/dist/index.umd.js.map +1 -1
  11. package/dist/server-core/src/api/openapi-generator.d.ts +15 -1
  12. package/dist/server-core/src/api/types.d.ts +1 -1
  13. package/dist/server-core/src/auth/admin-routes.d.ts +10 -1
  14. package/dist/server-core/src/auth/apple-oauth.d.ts +30 -0
  15. package/dist/server-core/src/auth/bitbucket-oauth.d.ts +11 -0
  16. package/dist/server-core/src/auth/discord-oauth.d.ts +14 -0
  17. package/dist/server-core/src/auth/facebook-oauth.d.ts +14 -0
  18. package/dist/server-core/src/auth/github-oauth.d.ts +15 -0
  19. package/dist/server-core/src/auth/gitlab-oauth.d.ts +13 -0
  20. package/dist/server-core/src/auth/google-oauth.d.ts +5 -11
  21. package/dist/server-core/src/auth/index.d.ts +13 -2
  22. package/dist/server-core/src/auth/interfaces.d.ts +45 -6
  23. package/dist/server-core/src/auth/jwt.d.ts +1 -0
  24. package/dist/server-core/src/auth/linkedin-oauth.d.ts +18 -0
  25. package/dist/server-core/src/auth/microsoft-oauth.d.ts +16 -0
  26. package/dist/server-core/src/auth/middleware.d.ts +36 -11
  27. package/dist/server-core/src/auth/rate-limiter.d.ts +2 -2
  28. package/dist/server-core/src/auth/routes.d.ts +13 -3
  29. package/dist/server-core/src/auth/slack-oauth.d.ts +12 -0
  30. package/dist/server-core/src/auth/spotify-oauth.d.ts +12 -0
  31. package/dist/server-core/src/auth/twitter-oauth.d.ts +18 -0
  32. package/dist/server-core/src/cron/cron-loader.d.ts +17 -0
  33. package/dist/server-core/src/cron/cron-routes.d.ts +14 -0
  34. package/dist/server-core/src/cron/cron-scheduler.d.ts +61 -0
  35. package/dist/server-core/src/cron/cron-store.d.ts +32 -0
  36. package/dist/server-core/src/cron/index.d.ts +6 -0
  37. package/dist/server-core/src/email/index.d.ts +2 -2
  38. package/dist/server-core/src/email/templates.d.ts +9 -0
  39. package/dist/server-core/src/email/types.d.ts +20 -23
  40. package/dist/server-core/src/history/history-routes.d.ts +2 -2
  41. package/dist/server-core/src/index.d.ts +5 -0
  42. package/dist/server-core/src/init.d.ts +112 -2
  43. package/dist/server-core/src/singleton.d.ts +35 -0
  44. package/dist/server-core/src/storage/LocalStorageController.d.ts +9 -9
  45. package/dist/server-core/src/storage/S3StorageController.d.ts +8 -8
  46. package/dist/server-core/src/storage/index.d.ts +18 -11
  47. package/dist/server-core/src/storage/routes.d.ts +3 -3
  48. package/dist/server-core/src/storage/types.d.ts +28 -16
  49. package/dist/server-core/src/utils/dev-port.d.ts +35 -0
  50. package/dist/server-core/src/utils/logger.d.ts +31 -0
  51. package/dist/server-core/src/utils/request-logger.d.ts +19 -0
  52. package/dist/types/src/controllers/auth.d.ts +2 -0
  53. package/dist/types/src/controllers/client.d.ts +119 -7
  54. package/dist/types/src/controllers/collection_registry.d.ts +4 -3
  55. package/dist/types/src/controllers/customization_controller.d.ts +7 -1
  56. package/dist/types/src/controllers/data.d.ts +34 -7
  57. package/dist/types/src/controllers/data_driver.d.ts +20 -28
  58. package/dist/types/src/controllers/database_admin.d.ts +2 -2
  59. package/dist/types/src/controllers/email.d.ts +34 -0
  60. package/dist/types/src/controllers/index.d.ts +1 -0
  61. package/dist/types/src/controllers/local_config_persistence.d.ts +4 -4
  62. package/dist/types/src/controllers/navigation.d.ts +5 -5
  63. package/dist/types/src/controllers/registry.d.ts +6 -3
  64. package/dist/types/src/controllers/side_entity_controller.d.ts +7 -6
  65. package/dist/types/src/controllers/storage.d.ts +24 -26
  66. package/dist/types/src/rebase_context.d.ts +8 -4
  67. package/dist/types/src/types/backend.d.ts +4 -1
  68. package/dist/types/src/types/builders.d.ts +5 -4
  69. package/dist/types/src/types/chips.d.ts +1 -1
  70. package/dist/types/src/types/collections.d.ts +169 -125
  71. package/dist/types/src/types/cron.d.ts +102 -0
  72. package/dist/types/src/types/data_source.d.ts +1 -1
  73. package/dist/types/src/types/entity_actions.d.ts +8 -8
  74. package/dist/types/src/types/entity_callbacks.d.ts +15 -15
  75. package/dist/types/src/types/entity_link_builder.d.ts +1 -1
  76. package/dist/types/src/types/entity_overrides.d.ts +2 -1
  77. package/dist/types/src/types/entity_views.d.ts +8 -8
  78. package/dist/types/src/types/export_import.d.ts +3 -3
  79. package/dist/types/src/types/index.d.ts +1 -0
  80. package/dist/types/src/types/plugins.d.ts +72 -18
  81. package/dist/types/src/types/properties.d.ts +118 -33
  82. package/dist/types/src/types/relations.d.ts +1 -1
  83. package/dist/types/src/types/slots.d.ts +30 -6
  84. package/dist/types/src/types/translations.d.ts +44 -0
  85. package/dist/types/src/types/user_management_delegate.d.ts +1 -0
  86. package/package.json +84 -84
  87. package/scratch.ts +6 -5
  88. package/src/api/ast-schema-editor.ts +11 -11
  89. package/src/api/collections_for_test/callbacks_test_collection.ts +5 -2
  90. package/src/api/errors.ts +38 -14
  91. package/src/api/graphql/graphql-schema-generator.ts +5 -3
  92. package/src/api/openapi-generator.ts +613 -58
  93. package/src/api/rest/api-generator.ts +17 -11
  94. package/src/api/rest/query-parser.ts +1 -1
  95. package/src/api/schema-editor-routes.ts +2 -0
  96. package/src/api/server.ts +8 -5
  97. package/src/api/types.ts +1 -1
  98. package/src/auth/admin-routes.ts +58 -17
  99. package/src/auth/apple-oauth.ts +130 -0
  100. package/src/auth/bitbucket-oauth.ts +82 -0
  101. package/src/auth/discord-oauth.ts +83 -0
  102. package/src/auth/facebook-oauth.ts +72 -0
  103. package/src/auth/github-oauth.ts +110 -0
  104. package/src/auth/gitlab-oauth.ts +70 -0
  105. package/src/auth/google-oauth.ts +34 -46
  106. package/src/auth/index.ts +15 -2
  107. package/src/auth/interfaces.ts +54 -7
  108. package/src/auth/jwt.ts +22 -5
  109. package/src/auth/linkedin-oauth.ts +81 -0
  110. package/src/auth/microsoft-oauth.ts +88 -0
  111. package/src/auth/middleware.ts +194 -45
  112. package/src/auth/password.ts +3 -1
  113. package/src/auth/rate-limiter.ts +4 -4
  114. package/src/auth/routes.ts +197 -139
  115. package/src/auth/slack-oauth.ts +71 -0
  116. package/src/auth/spotify-oauth.ts +67 -0
  117. package/src/auth/twitter-oauth.ts +120 -0
  118. package/src/collections/loader.ts +6 -6
  119. package/src/cron/cron-loader.ts +89 -0
  120. package/src/cron/cron-routes.test.ts +265 -0
  121. package/src/cron/cron-routes.ts +85 -0
  122. package/src/cron/cron-scheduler.test.ts +421 -0
  123. package/src/cron/cron-scheduler.ts +413 -0
  124. package/src/cron/cron-store.ts +163 -0
  125. package/src/cron/index.ts +6 -0
  126. package/src/db/interfaces.ts +2 -2
  127. package/src/email/index.ts +3 -2
  128. package/src/email/smtp-email-service.ts +5 -2
  129. package/src/email/templates.ts +93 -6
  130. package/src/email/types.ts +17 -24
  131. package/src/functions/function-loader.ts +34 -6
  132. package/src/functions/function-routes.ts +2 -2
  133. package/src/history/history-routes.ts +4 -3
  134. package/src/index.ts +10 -0
  135. package/src/init.ts +475 -57
  136. package/src/services/driver-registry.ts +1 -1
  137. package/src/singleton.test.ts +28 -0
  138. package/src/singleton.ts +70 -0
  139. package/src/storage/LocalStorageController.ts +50 -53
  140. package/src/storage/S3StorageController.ts +49 -46
  141. package/src/storage/index.ts +27 -16
  142. package/src/storage/routes.ts +76 -59
  143. package/src/storage/storage-registry.ts +5 -5
  144. package/src/storage/types.ts +44 -32
  145. package/src/types/index.ts +4 -4
  146. package/src/utils/dev-port.ts +176 -0
  147. package/src/utils/logger.ts +143 -0
  148. package/src/utils/logging.ts +5 -2
  149. package/src/utils/request-logger.ts +66 -0
  150. package/test/admin-routes.test.ts +114 -65
  151. package/test/api-generator.test.ts +73 -30
  152. package/test/ast-schema-editor.test.ts +5 -3
  153. package/test/auth-middleware-hono.test.ts +268 -33
  154. package/test/auth-routes.test.ts +325 -146
  155. package/test/driver-registry.test.ts +4 -2
  156. package/test/error-propagation.test.ts +226 -0
  157. package/test/errors.test.ts +12 -7
  158. package/test/jwt-security.test.ts +18 -9
  159. package/test/jwt.test.ts +26 -13
  160. package/test/middleware.test.ts +13 -8
  161. package/test/query-parser.test.ts +13 -8
  162. package/test/rate-limiter.test.ts +7 -7
  163. package/test/safe-compare.test.ts +66 -0
  164. package/test/singleton.test.ts +59 -0
  165. package/test/storage-local.test.ts +36 -43
  166. package/test/storage-registry.test.ts +38 -36
  167. package/test/storage-routes.test.ts +9 -5
  168. package/test/storage-s3.test.ts +51 -48
  169. package/test.ts +6 -0
  170. package/tsconfig.json +1 -1
  171. package/vite.config.ts +7 -5
  172. package/dist/index-BeMqpmfQ.js +0 -239
  173. package/dist/index-BeMqpmfQ.js.map +0 -1
  174. package/dist/index-bl4J3lNb.js +0 -55823
  175. package/dist/index-bl4J3lNb.js.map +0 -1
  176. package/vite.config.ts.timestamp-1775065397568-8a853255edf6e.mjs +0 -46
@@ -0,0 +1,421 @@
1
+ import { describe, it, expect, beforeEach, afterEach, jest } from "@jest/globals";
2
+ import { CronScheduler } from "./cron-scheduler";
3
+ import type { CronJobDefinition } from "@rebasepro/types";
4
+ import type { LoadedCronJob } from "./cron-loader";
5
+
6
+ // ─── Helpers ────────────────────────────────────────────────────────
7
+
8
+ function makeJob(
9
+ id: string,
10
+ overrides: Partial<CronJobDefinition> = {}
11
+ ): LoadedCronJob {
12
+ return {
13
+ id,
14
+ definition: {
15
+ schedule: "0 * * * *", // every hour
16
+ name: `Job ${id}`,
17
+ description: `Description for ${id}`,
18
+ enabled: true,
19
+ timeoutSeconds: 5,
20
+ handler: async (ctx) => {
21
+ ctx.log("hello from", id);
22
+ return { ok: true };
23
+ },
24
+ ...overrides
25
+ }
26
+ };
27
+ }
28
+
29
+ function makeFailingJob(id: string, errorMsg = "boom"): LoadedCronJob {
30
+ return makeJob(id, {
31
+ handler: async () => {
32
+ throw new Error(errorMsg);
33
+ }
34
+ });
35
+ }
36
+
37
+ function makeSlowJob(id: string, delayMs: number): LoadedCronJob {
38
+ return makeJob(id, {
39
+ timeoutSeconds: 1, // 1s timeout
40
+ handler: () =>
41
+ new Promise((resolve) => setTimeout(() => resolve({ done: true }), delayMs))
42
+ });
43
+ }
44
+
45
+ // ─── Tests ──────────────────────────────────────────────────────────
46
+
47
+ describe("CronScheduler", () => {
48
+ let scheduler: CronScheduler;
49
+
50
+ beforeEach(() => {
51
+ scheduler = new CronScheduler();
52
+ jest.useFakeTimers();
53
+ });
54
+
55
+ afterEach(() => {
56
+ scheduler.stop();
57
+ jest.useRealTimers();
58
+ });
59
+
60
+ // ── Registration ────────────────────────────────────────────────
61
+
62
+ describe("registerJobs", () => {
63
+ it("registers jobs and they appear in listJobs", () => {
64
+ scheduler.registerJobs([makeJob("alpha"), makeJob("beta")]);
65
+
66
+ const jobs = scheduler.listJobs();
67
+ expect(jobs).toHaveLength(2);
68
+ expect(jobs.map((j) => j.id).sort()).toEqual(["alpha", "beta"]);
69
+ });
70
+
71
+ it("sets initial state to idle for enabled jobs", () => {
72
+ scheduler.registerJobs([makeJob("enabled-job")]);
73
+ const job = scheduler.getJob("enabled-job");
74
+ expect(job?.state).toBe("idle");
75
+ expect(job?.enabled).toBe(true);
76
+ });
77
+
78
+ it("sets initial state to disabled for disabled jobs", () => {
79
+ scheduler.registerJobs([makeJob("disabled-job", { enabled: false })]);
80
+ const job = scheduler.getJob("disabled-job");
81
+ expect(job?.state).toBe("disabled");
82
+ expect(job?.enabled).toBe(false);
83
+ });
84
+
85
+ it("initializes counters to zero", () => {
86
+ scheduler.registerJobs([makeJob("fresh")]);
87
+ const job = scheduler.getJob("fresh");
88
+ expect(job?.totalRuns).toBe(0);
89
+ expect(job?.totalFailures).toBe(0);
90
+ });
91
+
92
+ it("overwrites duplicate job IDs", () => {
93
+ scheduler.registerJobs([makeJob("dup", { name: "First" })]);
94
+ scheduler.registerJobs([makeJob("dup", { name: "Second" })]);
95
+
96
+ const jobs = scheduler.listJobs();
97
+ expect(jobs).toHaveLength(1);
98
+ expect(jobs[0].name).toBe("Second");
99
+ });
100
+
101
+ it("preserves definition metadata", () => {
102
+ scheduler.registerJobs([
103
+ makeJob("meta", {
104
+ name: "My Job",
105
+ description: "Does stuff",
106
+ schedule: "30 2 * * 1"
107
+ })
108
+ ]);
109
+
110
+ const job = scheduler.getJob("meta");
111
+ expect(job?.name).toBe("My Job");
112
+ expect(job?.description).toBe("Does stuff");
113
+ expect(job?.schedule).toBe("30 2 * * 1");
114
+ });
115
+ });
116
+
117
+ // ── getJob ───────────────────────────────────────────────────────
118
+
119
+ describe("getJob", () => {
120
+ it("returns undefined for nonexistent ID", () => {
121
+ expect(scheduler.getJob("nope")).toBeUndefined();
122
+ });
123
+
124
+ it("returns the correct job by ID", () => {
125
+ scheduler.registerJobs([makeJob("a"), makeJob("b")]);
126
+ const job = scheduler.getJob("b");
127
+ expect(job?.id).toBe("b");
128
+ });
129
+ });
130
+
131
+ // ── triggerJob (manual execution) ───────────────────────────────
132
+
133
+ describe("triggerJob", () => {
134
+ beforeEach(() => {
135
+ jest.useRealTimers(); // triggerJob uses real async
136
+ });
137
+
138
+ it("returns undefined for nonexistent job", async () => {
139
+ const result = await scheduler.triggerJob("ghost");
140
+ expect(result).toBeUndefined();
141
+ });
142
+
143
+ it("executes the handler and returns a log entry", async () => {
144
+ scheduler.registerJobs([makeJob("trigger-me")]);
145
+ const log = await scheduler.triggerJob("trigger-me");
146
+
147
+ expect(log).toBeDefined();
148
+ expect(log!.jobId).toBe("trigger-me");
149
+ expect(log!.success).toBe(true);
150
+ expect(log!.manual).toBe(true);
151
+ expect(log!.durationMs).toBeGreaterThanOrEqual(0);
152
+ expect(log!.logs).toContain("hello from trigger-me");
153
+ expect(log!.result).toEqual({ ok: true });
154
+ });
155
+
156
+ it("increments totalRuns after trigger", async () => {
157
+ scheduler.registerJobs([makeJob("count-me")]);
158
+
159
+ await scheduler.triggerJob("count-me");
160
+ expect(scheduler.getJob("count-me")?.totalRuns).toBe(1);
161
+
162
+ await scheduler.triggerJob("count-me");
163
+ expect(scheduler.getJob("count-me")?.totalRuns).toBe(2);
164
+ });
165
+
166
+ it("records failure and increments totalFailures", async () => {
167
+ scheduler.registerJobs([makeFailingJob("fail-me", "something broke")]);
168
+
169
+ const log = await scheduler.triggerJob("fail-me");
170
+
171
+ expect(log!.success).toBe(false);
172
+ expect(log!.error).toBe("something broke");
173
+
174
+ const status = scheduler.getJob("fail-me");
175
+ expect(status?.totalFailures).toBe(1);
176
+ expect(status?.state).toBe("error");
177
+ expect(status?.lastError).toBe("something broke");
178
+ });
179
+
180
+ it("captures ctx.log output", async () => {
181
+ scheduler.registerJobs([
182
+ makeJob("logger", {
183
+ handler: async (ctx) => {
184
+ ctx.log("line 1");
185
+ ctx.log("line 2", { nested: true });
186
+ ctx.log(42);
187
+ }
188
+ })
189
+ ]);
190
+
191
+ const log = await scheduler.triggerJob("logger");
192
+ expect(log!.logs).toEqual([
193
+ "line 1",
194
+ 'line 2 {"nested":true}',
195
+ "42"
196
+ ]);
197
+ });
198
+
199
+ it("sets lastRunAt after execution", async () => {
200
+ scheduler.registerJobs([makeJob("timed")]);
201
+
202
+ const before = new Date();
203
+ await scheduler.triggerJob("timed");
204
+ const after = new Date();
205
+
206
+ const job = scheduler.getJob("timed");
207
+ expect(job?.lastRunAt).toBeDefined();
208
+ const lastRun = new Date(job!.lastRunAt!);
209
+ expect(lastRun.getTime()).toBeGreaterThanOrEqual(before.getTime());
210
+ expect(lastRun.getTime()).toBeLessThanOrEqual(after.getTime());
211
+ });
212
+
213
+ it("sets lastDurationMs after execution", async () => {
214
+ scheduler.registerJobs([makeJob("duration-check")]);
215
+ await scheduler.triggerJob("duration-check");
216
+
217
+ const job = scheduler.getJob("duration-check");
218
+ expect(job?.lastDurationMs).toBeDefined();
219
+ expect(job!.lastDurationMs!).toBeGreaterThanOrEqual(0);
220
+ });
221
+
222
+ it("handles handler that returns undefined (void)", async () => {
223
+ scheduler.registerJobs([
224
+ makeJob("void-handler", {
225
+ handler: async () => {
226
+ // returns void
227
+ }
228
+ })
229
+ ]);
230
+
231
+ const log = await scheduler.triggerJob("void-handler");
232
+ expect(log!.success).toBe(true);
233
+ expect(log!.result).toBeUndefined();
234
+ });
235
+
236
+ it("handles synchronous handler", async () => {
237
+ scheduler.registerJobs([
238
+ makeJob("sync-handler", {
239
+ handler: (ctx) => {
240
+ ctx.log("sync");
241
+ return { sync: true };
242
+ }
243
+ })
244
+ ]);
245
+
246
+ const log = await scheduler.triggerJob("sync-handler");
247
+ expect(log!.success).toBe(true);
248
+ expect(log!.result).toEqual({ sync: true });
249
+ });
250
+ });
251
+
252
+ // ── Timeout ─────────────────────────────────────────────────────
253
+
254
+ describe("timeout", () => {
255
+ it("times out a slow handler", async () => {
256
+ jest.useRealTimers();
257
+ scheduler.registerJobs([
258
+ makeJob("slow", {
259
+ timeoutSeconds: 1,
260
+ handler: () =>
261
+ new Promise((resolve) =>
262
+ setTimeout(() => resolve("late"), 3000)
263
+ )
264
+ })
265
+ ]);
266
+
267
+ const log = await scheduler.triggerJob("slow");
268
+ expect(log!.success).toBe(false);
269
+ expect(log!.error).toContain("timed out");
270
+ }, 10000);
271
+ });
272
+
273
+ // ── Logs ring buffer ────────────────────────────────────────────
274
+
275
+ describe("getJobLogs", () => {
276
+ beforeEach(() => {
277
+ jest.useRealTimers();
278
+ });
279
+
280
+ it("returns empty array for nonexistent job", () => {
281
+ expect(scheduler.getJobLogs("nope")).toEqual([]);
282
+ });
283
+
284
+ it("returns logs in reverse order (newest first)", async () => {
285
+ scheduler.registerJobs([
286
+ makeJob("ordered", {
287
+ handler: async (ctx) => {
288
+ return { run: ctx.jobId };
289
+ }
290
+ })
291
+ ]);
292
+
293
+ await scheduler.triggerJob("ordered");
294
+ await scheduler.triggerJob("ordered");
295
+ await scheduler.triggerJob("ordered");
296
+
297
+ const logs = scheduler.getJobLogs("ordered");
298
+ expect(logs).toHaveLength(3);
299
+ // newest first means the last entry's startedAt >= first entry's startedAt
300
+ const t0 = new Date(logs[0].startedAt).getTime();
301
+ const t2 = new Date(logs[2].startedAt).getTime();
302
+ expect(t0).toBeGreaterThanOrEqual(t2);
303
+ });
304
+
305
+ it("respects limit parameter", async () => {
306
+ scheduler.registerJobs([makeJob("limited")]);
307
+
308
+ for (let i = 0; i < 5; i++) {
309
+ await scheduler.triggerJob("limited");
310
+ }
311
+
312
+ expect(scheduler.getJobLogs("limited", 2)).toHaveLength(2);
313
+ expect(scheduler.getJobLogs("limited", 10)).toHaveLength(5);
314
+ expect(scheduler.getJobLogs("limited")).toHaveLength(5);
315
+ });
316
+
317
+ it("caps at 50 entries (ring buffer)", async () => {
318
+ scheduler.registerJobs([makeJob("ring")]);
319
+
320
+ for (let i = 0; i < 60; i++) {
321
+ await scheduler.triggerJob("ring");
322
+ }
323
+
324
+ expect(scheduler.getJobLogs("ring")).toHaveLength(50);
325
+ expect(scheduler.getJob("ring")?.totalRuns).toBe(60);
326
+ });
327
+ });
328
+
329
+ // ── Enable / Disable ────────────────────────────────────────────
330
+
331
+ describe("setJobEnabled", () => {
332
+ it("returns undefined for nonexistent job", () => {
333
+ expect(scheduler.setJobEnabled("nope", true)).toBeUndefined();
334
+ });
335
+
336
+ it("disables a job", () => {
337
+ scheduler.registerJobs([makeJob("togglable")]);
338
+ const result = scheduler.setJobEnabled("togglable", false);
339
+
340
+ expect(result?.enabled).toBe(false);
341
+ expect(result?.state).toBe("disabled");
342
+ });
343
+
344
+ it("re-enables a disabled job", () => {
345
+ scheduler.registerJobs([makeJob("togglable")]);
346
+ scheduler.setJobEnabled("togglable", false);
347
+ scheduler.start();
348
+
349
+ const result = scheduler.setJobEnabled("togglable", true);
350
+ expect(result?.enabled).toBe(true);
351
+ expect(result?.state).toBe("idle");
352
+ });
353
+ });
354
+
355
+ // ── start / stop ────────────────────────────────────────────────
356
+
357
+ describe("start / stop", () => {
358
+ it("start is idempotent", () => {
359
+ scheduler.registerJobs([makeJob("idem")]);
360
+ scheduler.start();
361
+ scheduler.start(); // should not throw
362
+ expect(scheduler.listJobs()).toHaveLength(1);
363
+ });
364
+
365
+ it("stop clears nextRunAt", () => {
366
+ scheduler.registerJobs([makeJob("stoppable")]);
367
+ scheduler.start();
368
+
369
+ // After start, nextRunAt should be set
370
+ expect(scheduler.getJob("stoppable")?.nextRunAt).toBeDefined();
371
+
372
+ scheduler.stop();
373
+ expect(scheduler.getJob("stoppable")?.nextRunAt).toBeUndefined();
374
+ });
375
+
376
+ it("does not schedule disabled jobs on start", () => {
377
+ scheduler.registerJobs([makeJob("off", { enabled: false })]);
378
+ scheduler.start();
379
+
380
+ expect(scheduler.getJob("off")?.nextRunAt).toBeUndefined();
381
+ });
382
+ });
383
+
384
+ // ── toStatus shape ──────────────────────────────────────────────
385
+
386
+ describe("status shape", () => {
387
+ it("returns all expected fields", () => {
388
+ scheduler.registerJobs([
389
+ makeJob("shape", {
390
+ name: "Shape Test",
391
+ description: "Desc",
392
+ schedule: "15 3 * * *"
393
+ })
394
+ ]);
395
+
396
+ const job = scheduler.getJob("shape")!;
397
+ expect(job).toMatchObject({
398
+ id: "shape",
399
+ name: "Shape Test",
400
+ description: "Desc",
401
+ schedule: "15 3 * * *",
402
+ enabled: true,
403
+ state: "idle",
404
+ totalRuns: 0,
405
+ totalFailures: 0
406
+ });
407
+ });
408
+
409
+ it("lastRunAt and nextRunAt are ISO strings or undefined", () => {
410
+ scheduler.registerJobs([makeJob("iso-check")]);
411
+ const before = scheduler.getJob("iso-check")!;
412
+ expect(before.lastRunAt).toBeUndefined();
413
+
414
+ scheduler.start();
415
+ const after = scheduler.getJob("iso-check")!;
416
+ // nextRunAt should be set as an ISO string after start
417
+ expect(after.nextRunAt).toBeDefined();
418
+ expect(() => new Date(after.nextRunAt!)).not.toThrow();
419
+ });
420
+ });
421
+ });