@hellcoder/companion 0.96.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 (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
@@ -0,0 +1,881 @@
1
+ import { vi, describe, it, expect, beforeEach } from "vitest";
2
+
3
+ // ─── Mock cron-store module ─────────────────────────────────────────────────
4
+ // Mocked before imports so every `import` of cron-store gets the mock.
5
+ vi.mock("../cron-store.js", () => ({
6
+ listJobs: vi.fn(() => []),
7
+ getJob: vi.fn(() => null),
8
+ createJob: vi.fn(),
9
+ updateJob: vi.fn(),
10
+ deleteJob: vi.fn(() => false),
11
+ }));
12
+
13
+ import { Hono } from "hono";
14
+ import * as cronStore from "../cron-store.js";
15
+ import type { CronJob, CronJobExecution } from "../cron-types.js";
16
+ import { registerCronRoutes } from "./cron-routes.js";
17
+
18
+ // ─── Helpers ────────────────────────────────────────────────────────────────
19
+
20
+ /** Minimal CronJob fixture with sensible defaults. Override fields as needed. */
21
+ function makeJob(overrides: Partial<CronJob> = {}): CronJob {
22
+ return {
23
+ id: "test-job",
24
+ name: "Test Job",
25
+ prompt: "Run a task",
26
+ schedule: "0 * * * *",
27
+ recurring: true,
28
+ backendType: "claude",
29
+ model: "claude-sonnet-4-6",
30
+ cwd: "/tmp/test",
31
+ enabled: true,
32
+ permissionMode: "bypassPermissions",
33
+ createdAt: 1000,
34
+ updatedAt: 2000,
35
+ consecutiveFailures: 0,
36
+ totalRuns: 0,
37
+ ...overrides,
38
+ };
39
+ }
40
+
41
+ /** Build a mock CronScheduler with vi.fn() stubs for every method the routes use. */
42
+ function createMockScheduler() {
43
+ return {
44
+ getNextRunTime: vi.fn((_id?: string) => null as Date | null),
45
+ scheduleJob: vi.fn(),
46
+ stopJob: vi.fn(),
47
+ executeJobManually: vi.fn(),
48
+ getExecutions: vi.fn(() => [] as CronJobExecution[]),
49
+ };
50
+ }
51
+
52
+ // ─── Test setup ─────────────────────────────────────────────────────────────
53
+
54
+ let app: Hono;
55
+ let scheduler: ReturnType<typeof createMockScheduler>;
56
+
57
+ beforeEach(() => {
58
+ vi.clearAllMocks();
59
+
60
+ scheduler = createMockScheduler();
61
+
62
+ // Create a Hono app and mount cron routes under /api
63
+ app = new Hono();
64
+ const api = new Hono();
65
+ registerCronRoutes(api, scheduler as any);
66
+ app.route("/api", api);
67
+ });
68
+
69
+ // ─── GET /api/cron/jobs ─────────────────────────────────────────────────────
70
+
71
+ describe("GET /api/cron/jobs", () => {
72
+ it("returns an empty list when no jobs exist", async () => {
73
+ // Validate that an empty store returns []
74
+ vi.mocked(cronStore.listJobs).mockReturnValue([]);
75
+
76
+ const res = await app.request("/api/cron/jobs");
77
+
78
+ expect(res.status).toBe(200);
79
+ const json = await res.json();
80
+ expect(json).toEqual([]);
81
+ });
82
+
83
+ it("returns the list of jobs enriched with nextRunAt", async () => {
84
+ // When the scheduler has a next run time, the response should include it as epoch ms
85
+ const job = makeJob();
86
+ vi.mocked(cronStore.listJobs).mockReturnValue([job]);
87
+ const nextRun = new Date("2026-03-01T00:00:00Z");
88
+ scheduler.getNextRunTime.mockReturnValue(nextRun);
89
+
90
+ const res = await app.request("/api/cron/jobs");
91
+
92
+ expect(res.status).toBe(200);
93
+ const json = await res.json();
94
+ expect(json).toHaveLength(1);
95
+ expect(json[0].id).toBe("test-job");
96
+ expect(json[0].nextRunAt).toBe(nextRun.getTime());
97
+ });
98
+
99
+ it("returns nextRunAt as null when the scheduler has no next run time", async () => {
100
+ // If the scheduler returns null (e.g. job is disabled or one-shot already ran),
101
+ // the enriched field should be null rather than omitted
102
+ const job = makeJob({ enabled: false });
103
+ vi.mocked(cronStore.listJobs).mockReturnValue([job]);
104
+ scheduler.getNextRunTime.mockReturnValue(null);
105
+
106
+ const res = await app.request("/api/cron/jobs");
107
+
108
+ const json = await res.json();
109
+ expect(json[0].nextRunAt).toBeNull();
110
+ });
111
+
112
+ it("enriches multiple jobs independently", async () => {
113
+ // Each job should get its own nextRunAt based on its id
114
+ const job1 = makeJob({ id: "job-a", name: "Job A" });
115
+ const job2 = makeJob({ id: "job-b", name: "Job B" });
116
+ vi.mocked(cronStore.listJobs).mockReturnValue([job1, job2]);
117
+
118
+ const dateA = new Date("2026-04-01T08:00:00Z");
119
+ scheduler.getNextRunTime.mockImplementation((id?: string) => {
120
+ if (id === "job-a") return dateA;
121
+ return null;
122
+ });
123
+
124
+ const res = await app.request("/api/cron/jobs");
125
+ const json = await res.json();
126
+
127
+ expect(json).toHaveLength(2);
128
+ expect(json[0].nextRunAt).toBe(dateA.getTime());
129
+ expect(json[1].nextRunAt).toBeNull();
130
+ });
131
+ });
132
+
133
+ // ─── GET /api/cron/jobs/:id ─────────────────────────────────────────────────
134
+
135
+ describe("GET /api/cron/jobs/:id", () => {
136
+ it("returns the job when it exists", async () => {
137
+ const job = makeJob({ id: "existing" });
138
+ vi.mocked(cronStore.getJob).mockReturnValue(job);
139
+
140
+ const res = await app.request("/api/cron/jobs/existing");
141
+
142
+ expect(res.status).toBe(200);
143
+ const json = await res.json();
144
+ expect(json.id).toBe("existing");
145
+ });
146
+
147
+ it("returns 404 when the job does not exist", async () => {
148
+ vi.mocked(cronStore.getJob).mockReturnValue(null);
149
+
150
+ const res = await app.request("/api/cron/jobs/nonexistent");
151
+
152
+ expect(res.status).toBe(404);
153
+ const json = await res.json();
154
+ expect(json.error).toBe("Job not found");
155
+ });
156
+
157
+ it("enriches the job with nextRunAt from the scheduler", async () => {
158
+ // The single-job endpoint should also attach the next run time
159
+ const job = makeJob({ id: "scheduled" });
160
+ vi.mocked(cronStore.getJob).mockReturnValue(job);
161
+ const nextRun = new Date("2026-06-15T12:00:00Z");
162
+ scheduler.getNextRunTime.mockReturnValue(nextRun);
163
+
164
+ const res = await app.request("/api/cron/jobs/scheduled");
165
+
166
+ const json = await res.json();
167
+ expect(json.nextRunAt).toBe(nextRun.getTime());
168
+ expect(scheduler.getNextRunTime).toHaveBeenCalledWith("scheduled");
169
+ });
170
+
171
+ it("returns nextRunAt as null when the scheduler has no timer for the job", async () => {
172
+ const job = makeJob({ id: "no-timer" });
173
+ vi.mocked(cronStore.getJob).mockReturnValue(job);
174
+ scheduler.getNextRunTime.mockReturnValue(null);
175
+
176
+ const res = await app.request("/api/cron/jobs/no-timer");
177
+
178
+ const json = await res.json();
179
+ expect(json.nextRunAt).toBeNull();
180
+ });
181
+ });
182
+
183
+ // ─── POST /api/cron/jobs ────────────────────────────────────────────────────
184
+
185
+ describe("POST /api/cron/jobs", () => {
186
+ it("creates a job and returns 201", async () => {
187
+ const created = makeJob({ id: "my-job", name: "My Job" });
188
+ vi.mocked(cronStore.createJob).mockReturnValue(created);
189
+
190
+ const res = await app.request("/api/cron/jobs", {
191
+ method: "POST",
192
+ headers: { "Content-Type": "application/json" },
193
+ body: JSON.stringify({
194
+ name: "My Job",
195
+ prompt: "Hello",
196
+ schedule: "0 * * * *",
197
+ cwd: "/tmp",
198
+ }),
199
+ });
200
+
201
+ expect(res.status).toBe(201);
202
+ const json = await res.json();
203
+ expect(json.id).toBe("my-job");
204
+ expect(cronStore.createJob).toHaveBeenCalledTimes(1);
205
+ });
206
+
207
+ it("passes all user-supplied fields to createJob", async () => {
208
+ // Ensure every field from the request body is forwarded to the store
209
+ const created = makeJob();
210
+ vi.mocked(cronStore.createJob).mockReturnValue(created);
211
+
212
+ await app.request("/api/cron/jobs", {
213
+ method: "POST",
214
+ headers: { "Content-Type": "application/json" },
215
+ body: JSON.stringify({
216
+ name: "Full Config",
217
+ prompt: "Do everything",
218
+ schedule: "*/5 * * * *",
219
+ recurring: false,
220
+ backendType: "codex",
221
+ model: "o4-mini",
222
+ cwd: "/home/user/project",
223
+ envSlug: "production",
224
+ enabled: false,
225
+ permissionMode: "default",
226
+ codexInternetAccess: true,
227
+ }),
228
+ });
229
+
230
+ expect(cronStore.createJob).toHaveBeenCalledWith({
231
+ name: "Full Config",
232
+ prompt: "Do everything",
233
+ schedule: "*/5 * * * *",
234
+ recurring: false,
235
+ backendType: "codex",
236
+ model: "o4-mini",
237
+ cwd: "/home/user/project",
238
+ envSlug: "production",
239
+ enabled: false,
240
+ permissionMode: "default",
241
+ codexInternetAccess: true,
242
+ });
243
+ });
244
+
245
+ it("schedules the job when enabled", async () => {
246
+ // When a new job is created with enabled=true, the scheduler should be called
247
+ const created = makeJob({ enabled: true });
248
+ vi.mocked(cronStore.createJob).mockReturnValue(created);
249
+
250
+ await app.request("/api/cron/jobs", {
251
+ method: "POST",
252
+ headers: { "Content-Type": "application/json" },
253
+ body: JSON.stringify({
254
+ name: "Enabled Job",
255
+ prompt: "Run",
256
+ schedule: "*/5 * * * *",
257
+ cwd: "/tmp",
258
+ }),
259
+ });
260
+
261
+ expect(scheduler.scheduleJob).toHaveBeenCalledWith(created);
262
+ });
263
+
264
+ it("does not schedule the job when disabled", async () => {
265
+ // When a new job is created with enabled=false, the scheduler should NOT be called
266
+ const created = makeJob({ enabled: false });
267
+ vi.mocked(cronStore.createJob).mockReturnValue(created);
268
+
269
+ await app.request("/api/cron/jobs", {
270
+ method: "POST",
271
+ headers: { "Content-Type": "application/json" },
272
+ body: JSON.stringify({
273
+ name: "Disabled Job",
274
+ prompt: "Run",
275
+ schedule: "*/5 * * * *",
276
+ cwd: "/tmp",
277
+ enabled: false,
278
+ }),
279
+ });
280
+
281
+ expect(scheduler.scheduleJob).not.toHaveBeenCalled();
282
+ });
283
+
284
+ it("returns 400 when the store throws a validation error", async () => {
285
+ // e.g. missing required fields
286
+ vi.mocked(cronStore.createJob).mockImplementation(() => {
287
+ throw new Error("Job name is required");
288
+ });
289
+
290
+ const res = await app.request("/api/cron/jobs", {
291
+ method: "POST",
292
+ headers: { "Content-Type": "application/json" },
293
+ body: JSON.stringify({}),
294
+ });
295
+
296
+ expect(res.status).toBe(400);
297
+ const json = await res.json();
298
+ expect(json.error).toBe("Job name is required");
299
+ });
300
+
301
+ it("returns 400 when a duplicate job name is used", async () => {
302
+ vi.mocked(cronStore.createJob).mockImplementation(() => {
303
+ throw new Error('A job with a similar name already exists ("my-job")');
304
+ });
305
+
306
+ const res = await app.request("/api/cron/jobs", {
307
+ method: "POST",
308
+ headers: { "Content-Type": "application/json" },
309
+ body: JSON.stringify({
310
+ name: "My Job",
311
+ prompt: "Do stuff",
312
+ schedule: "0 * * * *",
313
+ cwd: "/tmp",
314
+ }),
315
+ });
316
+
317
+ expect(res.status).toBe(400);
318
+ const json = await res.json();
319
+ expect(json.error).toContain("already exists");
320
+ });
321
+
322
+ it("handles invalid JSON body gracefully", async () => {
323
+ // The route catches JSON parse errors via .catch(() => ({})),
324
+ // so it should fall through to createJob with empty strings
325
+ vi.mocked(cronStore.createJob).mockImplementation(() => {
326
+ throw new Error("Job name is required");
327
+ });
328
+
329
+ const res = await app.request("/api/cron/jobs", {
330
+ method: "POST",
331
+ headers: { "Content-Type": "application/json" },
332
+ body: "not valid json",
333
+ });
334
+
335
+ expect(res.status).toBe(400);
336
+ const json = await res.json();
337
+ expect(json.error).toBeTruthy();
338
+ });
339
+
340
+ it("uses sensible defaults for optional fields", async () => {
341
+ // When optional fields are omitted, the route should fill in defaults
342
+ const created = makeJob();
343
+ vi.mocked(cronStore.createJob).mockReturnValue(created);
344
+
345
+ await app.request("/api/cron/jobs", {
346
+ method: "POST",
347
+ headers: { "Content-Type": "application/json" },
348
+ body: JSON.stringify({
349
+ name: "Minimal Job",
350
+ prompt: "Do something",
351
+ schedule: "0 * * * *",
352
+ cwd: "/tmp",
353
+ }),
354
+ });
355
+
356
+ const passedInput = vi.mocked(cronStore.createJob).mock.calls[0][0];
357
+ // Check default values for optional fields
358
+ expect(passedInput.recurring).toBe(true);
359
+ expect(passedInput.backendType).toBe("claude");
360
+ expect(passedInput.model).toBe("");
361
+ expect(passedInput.enabled).toBe(true);
362
+ expect(passedInput.permissionMode).toBe("bypassPermissions");
363
+ });
364
+
365
+ it("converts non-Error thrown values to string in the 400 response", async () => {
366
+ // Edge case: the store throws a non-Error value (e.g. a string)
367
+ vi.mocked(cronStore.createJob).mockImplementation(() => {
368
+ throw "raw string error";
369
+ });
370
+
371
+ const res = await app.request("/api/cron/jobs", {
372
+ method: "POST",
373
+ headers: { "Content-Type": "application/json" },
374
+ body: JSON.stringify({ name: "Bad Job" }),
375
+ });
376
+
377
+ expect(res.status).toBe(400);
378
+ const json = await res.json();
379
+ expect(json.error).toBe("raw string error");
380
+ });
381
+ });
382
+
383
+ // ─── PUT /api/cron/jobs/:id ─────────────────────────────────────────────────
384
+
385
+ describe("PUT /api/cron/jobs/:id", () => {
386
+ it("updates the job and returns the updated version", async () => {
387
+ const updated = makeJob({ id: "test-job", name: "Updated Name" });
388
+ vi.mocked(cronStore.updateJob).mockReturnValue(updated);
389
+
390
+ const res = await app.request("/api/cron/jobs/test-job", {
391
+ method: "PUT",
392
+ headers: { "Content-Type": "application/json" },
393
+ body: JSON.stringify({ name: "Updated Name" }),
394
+ });
395
+
396
+ expect(res.status).toBe(200);
397
+ const json = await res.json();
398
+ expect(json.name).toBe("Updated Name");
399
+ expect(cronStore.updateJob).toHaveBeenCalledWith(
400
+ "test-job",
401
+ expect.objectContaining({ name: "Updated Name" }),
402
+ );
403
+ });
404
+
405
+ it("returns 404 when job does not exist", async () => {
406
+ vi.mocked(cronStore.updateJob).mockReturnValue(null);
407
+
408
+ const res = await app.request("/api/cron/jobs/nonexistent", {
409
+ method: "PUT",
410
+ headers: { "Content-Type": "application/json" },
411
+ body: JSON.stringify({ name: "Nope" }),
412
+ });
413
+
414
+ expect(res.status).toBe(404);
415
+ const json = await res.json();
416
+ expect(json.error).toBe("Job not found");
417
+ });
418
+
419
+ it("strips non-editable fields from the update payload", async () => {
420
+ // Fields like 'id', 'createdAt', 'totalRuns', 'consecutiveFailures'
421
+ // should NOT be passed through to updateJob — only the whitelist applies
422
+ const updated = makeJob();
423
+ vi.mocked(cronStore.updateJob).mockReturnValue(updated);
424
+
425
+ await app.request("/api/cron/jobs/test-job", {
426
+ method: "PUT",
427
+ headers: { "Content-Type": "application/json" },
428
+ body: JSON.stringify({
429
+ name: "Good Field",
430
+ id: "hacked-id",
431
+ createdAt: 9999,
432
+ updatedAt: 8888,
433
+ totalRuns: 999,
434
+ consecutiveFailures: 100,
435
+ lastRunAt: 7777,
436
+ lastSessionId: "sess-hacked",
437
+ }),
438
+ });
439
+
440
+ const passedUpdates = vi.mocked(cronStore.updateJob).mock.calls[0][1];
441
+ // Allowed fields should be present
442
+ expect(passedUpdates).toHaveProperty("name", "Good Field");
443
+ // Non-editable fields should be stripped by the whitelist filter
444
+ expect(passedUpdates).not.toHaveProperty("id");
445
+ expect(passedUpdates).not.toHaveProperty("createdAt");
446
+ expect(passedUpdates).not.toHaveProperty("updatedAt");
447
+ expect(passedUpdates).not.toHaveProperty("totalRuns");
448
+ expect(passedUpdates).not.toHaveProperty("consecutiveFailures");
449
+ expect(passedUpdates).not.toHaveProperty("lastRunAt");
450
+ expect(passedUpdates).not.toHaveProperty("lastSessionId");
451
+ });
452
+
453
+ it("allows all whitelisted fields through", async () => {
454
+ // The PUT handler allows: name, prompt, schedule, recurring, backendType,
455
+ // model, cwd, envSlug, enabled, permissionMode, codexInternetAccess
456
+ const updated = makeJob();
457
+ vi.mocked(cronStore.updateJob).mockReturnValue(updated);
458
+
459
+ const allowedPayload = {
460
+ name: "New Name",
461
+ prompt: "New Prompt",
462
+ schedule: "*/10 * * * *",
463
+ recurring: false,
464
+ backendType: "codex",
465
+ model: "o4-mini",
466
+ cwd: "/new/path",
467
+ envSlug: "staging",
468
+ enabled: false,
469
+ permissionMode: "default",
470
+ codexInternetAccess: true,
471
+ };
472
+
473
+ await app.request("/api/cron/jobs/test-job", {
474
+ method: "PUT",
475
+ headers: { "Content-Type": "application/json" },
476
+ body: JSON.stringify(allowedPayload),
477
+ });
478
+
479
+ const passedUpdates = vi.mocked(cronStore.updateJob).mock.calls[0][1];
480
+ for (const [key, value] of Object.entries(allowedPayload)) {
481
+ expect(passedUpdates).toHaveProperty(key, value);
482
+ }
483
+ });
484
+
485
+ it("reschedules the job after update", async () => {
486
+ // After a successful update, the route should always call scheduleJob
487
+ const updated = makeJob({ id: "test-job" });
488
+ vi.mocked(cronStore.updateJob).mockReturnValue(updated);
489
+
490
+ await app.request("/api/cron/jobs/test-job", {
491
+ method: "PUT",
492
+ headers: { "Content-Type": "application/json" },
493
+ body: JSON.stringify({ schedule: "*/30 * * * *" }),
494
+ });
495
+
496
+ expect(scheduler.scheduleJob).toHaveBeenCalledWith(updated);
497
+ });
498
+
499
+ it("stops the old timer when the job id changes (name rename)", async () => {
500
+ // If the update results in a new ID (because name changed), the route
501
+ // should stop the old timer before scheduling the new one
502
+ const updated = makeJob({ id: "new-name" });
503
+ vi.mocked(cronStore.updateJob).mockReturnValue(updated);
504
+
505
+ await app.request("/api/cron/jobs/old-name", {
506
+ method: "PUT",
507
+ headers: { "Content-Type": "application/json" },
508
+ body: JSON.stringify({ name: "New Name" }),
509
+ });
510
+
511
+ // Should stop the old id's timer since updated.id !== param id
512
+ expect(scheduler.stopJob).toHaveBeenCalledWith("old-name");
513
+ // And schedule the updated job
514
+ expect(scheduler.scheduleJob).toHaveBeenCalledWith(updated);
515
+ });
516
+
517
+ it("does not stop the old timer when the id stays the same", async () => {
518
+ // When the name doesn't change (or changes to produce the same slug),
519
+ // stopJob should NOT be called for the old id
520
+ const updated = makeJob({ id: "same-id" });
521
+ vi.mocked(cronStore.updateJob).mockReturnValue(updated);
522
+
523
+ await app.request("/api/cron/jobs/same-id", {
524
+ method: "PUT",
525
+ headers: { "Content-Type": "application/json" },
526
+ body: JSON.stringify({ prompt: "Updated prompt" }),
527
+ });
528
+
529
+ // stopJob should not be called because updated.id === request param id
530
+ expect(scheduler.stopJob).not.toHaveBeenCalled();
531
+ expect(scheduler.scheduleJob).toHaveBeenCalledWith(updated);
532
+ });
533
+
534
+ it("returns 400 when the store throws a validation error", async () => {
535
+ vi.mocked(cronStore.updateJob).mockImplementation(() => {
536
+ throw new Error("Job name must contain alphanumeric characters");
537
+ });
538
+
539
+ const res = await app.request("/api/cron/jobs/test-job", {
540
+ method: "PUT",
541
+ headers: { "Content-Type": "application/json" },
542
+ body: JSON.stringify({ name: "!!!" }),
543
+ });
544
+
545
+ expect(res.status).toBe(400);
546
+ const json = await res.json();
547
+ expect(json.error).toBe("Job name must contain alphanumeric characters");
548
+ });
549
+
550
+ it("handles invalid JSON body gracefully", async () => {
551
+ // The route catches JSON parse errors via .catch(() => ({})),
552
+ // then passes an empty allowed-set to updateJob
553
+ vi.mocked(cronStore.updateJob).mockReturnValue(null);
554
+
555
+ const res = await app.request("/api/cron/jobs/test-job", {
556
+ method: "PUT",
557
+ headers: { "Content-Type": "application/json" },
558
+ body: "not valid json",
559
+ });
560
+
561
+ // updateJob returns null for missing job => 404
562
+ expect(res.status).toBe(404);
563
+ });
564
+
565
+ it("converts non-Error thrown values to string in the 400 response", async () => {
566
+ vi.mocked(cronStore.updateJob).mockImplementation(() => {
567
+ throw "raw string error";
568
+ });
569
+
570
+ const res = await app.request("/api/cron/jobs/test-job", {
571
+ method: "PUT",
572
+ headers: { "Content-Type": "application/json" },
573
+ body: JSON.stringify({ name: "Bad" }),
574
+ });
575
+
576
+ expect(res.status).toBe(400);
577
+ const json = await res.json();
578
+ expect(json.error).toBe("raw string error");
579
+ });
580
+ });
581
+
582
+ // ─── DELETE /api/cron/jobs/:id ──────────────────────────────────────────────
583
+
584
+ describe("DELETE /api/cron/jobs/:id", () => {
585
+ it("deletes an existing job and stops its scheduler", async () => {
586
+ vi.mocked(cronStore.deleteJob).mockReturnValue(true);
587
+
588
+ const res = await app.request("/api/cron/jobs/test-job", { method: "DELETE" });
589
+
590
+ expect(res.status).toBe(200);
591
+ const json = await res.json();
592
+ expect(json.ok).toBe(true);
593
+ // stopJob should be called BEFORE deleteJob to clean up the timer
594
+ expect(scheduler.stopJob).toHaveBeenCalledWith("test-job");
595
+ expect(cronStore.deleteJob).toHaveBeenCalledWith("test-job");
596
+ });
597
+
598
+ it("returns 404 when job does not exist", async () => {
599
+ vi.mocked(cronStore.deleteJob).mockReturnValue(false);
600
+
601
+ const res = await app.request("/api/cron/jobs/nonexistent", { method: "DELETE" });
602
+
603
+ expect(res.status).toBe(404);
604
+ const json = await res.json();
605
+ expect(json.error).toBe("Job not found");
606
+ });
607
+
608
+ it("always calls stopJob even when deleteJob returns false", async () => {
609
+ // The route calls stopJob unconditionally before checking deleteJob result.
610
+ // This is intentional — clean up any stale timer even if the file is gone.
611
+ vi.mocked(cronStore.deleteJob).mockReturnValue(false);
612
+
613
+ await app.request("/api/cron/jobs/stale-job", { method: "DELETE" });
614
+
615
+ expect(scheduler.stopJob).toHaveBeenCalledWith("stale-job");
616
+ });
617
+ });
618
+
619
+ // ─── POST /api/cron/jobs/:id/toggle ─────────────────────────────────────────
620
+
621
+ describe("POST /api/cron/jobs/:id/toggle", () => {
622
+ it("toggles an enabled job to disabled and stops the scheduler", async () => {
623
+ const job = makeJob({ id: "my-job", enabled: true });
624
+ const toggled = makeJob({ id: "my-job", enabled: false });
625
+ vi.mocked(cronStore.getJob).mockReturnValue(job);
626
+ vi.mocked(cronStore.updateJob).mockReturnValue(toggled);
627
+
628
+ const res = await app.request("/api/cron/jobs/my-job/toggle", { method: "POST" });
629
+
630
+ expect(res.status).toBe(200);
631
+ const json = await res.json();
632
+ expect(json.enabled).toBe(false);
633
+ // Should have called updateJob with enabled: false (opposite of current)
634
+ expect(cronStore.updateJob).toHaveBeenCalledWith("my-job", { enabled: false });
635
+ // When toggled off, should stop the scheduler for this job
636
+ expect(scheduler.stopJob).toHaveBeenCalledWith("my-job");
637
+ });
638
+
639
+ it("toggles a disabled job to enabled and schedules it", async () => {
640
+ const job = makeJob({ id: "my-job", enabled: false });
641
+ const toggled = makeJob({ id: "my-job", enabled: true });
642
+ vi.mocked(cronStore.getJob).mockReturnValue(job);
643
+ vi.mocked(cronStore.updateJob).mockReturnValue(toggled);
644
+
645
+ const res = await app.request("/api/cron/jobs/my-job/toggle", { method: "POST" });
646
+
647
+ expect(res.status).toBe(200);
648
+ const json = await res.json();
649
+ expect(json.enabled).toBe(true);
650
+ // Should have called updateJob with enabled: true
651
+ expect(cronStore.updateJob).toHaveBeenCalledWith("my-job", { enabled: true });
652
+ // When toggled on, should schedule the job
653
+ expect(scheduler.scheduleJob).toHaveBeenCalledWith(toggled);
654
+ });
655
+
656
+ it("returns 404 when job does not exist", async () => {
657
+ vi.mocked(cronStore.getJob).mockReturnValue(null);
658
+
659
+ const res = await app.request("/api/cron/jobs/nonexistent/toggle", { method: "POST" });
660
+
661
+ expect(res.status).toBe(404);
662
+ const json = await res.json();
663
+ expect(json.error).toBe("Job not found");
664
+ });
665
+
666
+ it("does not call scheduleJob when the toggle-on update returns a still-disabled job", async () => {
667
+ // Edge case: updateJob could theoretically return a job that's still disabled
668
+ // (e.g. if store logic overrides). The route checks updated?.enabled.
669
+ const job = makeJob({ id: "my-job", enabled: false });
670
+ const stillDisabled = makeJob({ id: "my-job", enabled: false });
671
+ vi.mocked(cronStore.getJob).mockReturnValue(job);
672
+ vi.mocked(cronStore.updateJob).mockReturnValue(stillDisabled);
673
+
674
+ await app.request("/api/cron/jobs/my-job/toggle", { method: "POST" });
675
+
676
+ // updated?.enabled is false, so stopJob is called instead of scheduleJob
677
+ expect(scheduler.stopJob).toHaveBeenCalledWith("my-job");
678
+ expect(scheduler.scheduleJob).not.toHaveBeenCalled();
679
+ });
680
+ });
681
+
682
+ // ─── POST /api/cron/jobs/:id/run ────────────────────────────────────────────
683
+
684
+ describe("POST /api/cron/jobs/:id/run", () => {
685
+ it("triggers a manual job run", async () => {
686
+ const job = makeJob({ id: "runner" });
687
+ vi.mocked(cronStore.getJob).mockReturnValue(job);
688
+
689
+ const res = await app.request("/api/cron/jobs/runner/run", { method: "POST" });
690
+
691
+ expect(res.status).toBe(200);
692
+ const json = await res.json();
693
+ expect(json.ok).toBe(true);
694
+ expect(json.message).toBe("Job triggered");
695
+ expect(scheduler.executeJobManually).toHaveBeenCalledWith("runner");
696
+ });
697
+
698
+ it("returns 404 when job does not exist", async () => {
699
+ vi.mocked(cronStore.getJob).mockReturnValue(null);
700
+
701
+ const res = await app.request("/api/cron/jobs/nonexistent/run", { method: "POST" });
702
+
703
+ expect(res.status).toBe(404);
704
+ const json = await res.json();
705
+ expect(json.error).toBe("Job not found");
706
+ });
707
+
708
+ it("triggers a run even for disabled jobs", async () => {
709
+ // Manual run should work regardless of enabled state — the scheduler's
710
+ // executeJobManually handles the force flag internally
711
+ const job = makeJob({ id: "disabled-runner", enabled: false });
712
+ vi.mocked(cronStore.getJob).mockReturnValue(job);
713
+
714
+ const res = await app.request("/api/cron/jobs/disabled-runner/run", { method: "POST" });
715
+
716
+ expect(res.status).toBe(200);
717
+ expect(scheduler.executeJobManually).toHaveBeenCalledWith("disabled-runner");
718
+ });
719
+ });
720
+
721
+ // ─── GET /api/cron/jobs/:id/executions ──────────────────────────────────────
722
+
723
+ describe("GET /api/cron/jobs/:id/executions", () => {
724
+ it("returns an empty list when no executions exist", async () => {
725
+ scheduler.getExecutions.mockReturnValue([]);
726
+
727
+ const res = await app.request("/api/cron/jobs/test-job/executions");
728
+
729
+ expect(res.status).toBe(200);
730
+ const json = await res.json();
731
+ expect(json).toEqual([]);
732
+ expect(scheduler.getExecutions).toHaveBeenCalledWith("test-job");
733
+ });
734
+
735
+ it("returns the list of executions from the scheduler", async () => {
736
+ const executions: CronJobExecution[] = [
737
+ {
738
+ sessionId: "sess-1",
739
+ jobId: "test-job",
740
+ startedAt: 1000,
741
+ success: true,
742
+ },
743
+ {
744
+ sessionId: "sess-2",
745
+ jobId: "test-job",
746
+ startedAt: 2000,
747
+ completedAt: 2500,
748
+ error: "CLI process failed",
749
+ },
750
+ ];
751
+ scheduler.getExecutions.mockReturnValue(executions);
752
+
753
+ const res = await app.request("/api/cron/jobs/test-job/executions");
754
+
755
+ expect(res.status).toBe(200);
756
+ const json = await res.json();
757
+ expect(json).toHaveLength(2);
758
+ expect(json[0].sessionId).toBe("sess-1");
759
+ expect(json[0].success).toBe(true);
760
+ expect(json[1].error).toBe("CLI process failed");
761
+ });
762
+
763
+ it("returns empty array when scheduler is undefined", async () => {
764
+ // When registerCronRoutes is called without a scheduler (undefined),
765
+ // the optional chaining should safely return []
766
+ const appNoScheduler = new Hono();
767
+ const apiNoScheduler = new Hono();
768
+ registerCronRoutes(apiNoScheduler, undefined);
769
+ appNoScheduler.route("/api", apiNoScheduler);
770
+
771
+ const res = await appNoScheduler.request("/api/cron/jobs/any-job/executions");
772
+
773
+ expect(res.status).toBe(200);
774
+ const json = await res.json();
775
+ expect(json).toEqual([]);
776
+ });
777
+ });
778
+
779
+ // ─── Scheduler-less mode (cronScheduler is undefined) ───────────────────────
780
+
781
+ describe("routes with undefined cronScheduler", () => {
782
+ let appNoScheduler: Hono;
783
+
784
+ beforeEach(() => {
785
+ appNoScheduler = new Hono();
786
+ const api = new Hono();
787
+ registerCronRoutes(api, undefined);
788
+ appNoScheduler.route("/api", api);
789
+ });
790
+
791
+ it("GET /cron/jobs returns jobs with nextRunAt: null", async () => {
792
+ // When there's no scheduler, nextRunAt should always be null
793
+ const job = makeJob();
794
+ vi.mocked(cronStore.listJobs).mockReturnValue([job]);
795
+
796
+ const res = await appNoScheduler.request("/api/cron/jobs");
797
+
798
+ const json = await res.json();
799
+ expect(json[0].nextRunAt).toBeNull();
800
+ });
801
+
802
+ it("GET /cron/jobs/:id returns job with nextRunAt: null", async () => {
803
+ const job = makeJob({ id: "test" });
804
+ vi.mocked(cronStore.getJob).mockReturnValue(job);
805
+
806
+ const res = await appNoScheduler.request("/api/cron/jobs/test");
807
+
808
+ const json = await res.json();
809
+ expect(json.nextRunAt).toBeNull();
810
+ });
811
+
812
+ it("POST /cron/jobs creates job without calling scheduleJob", async () => {
813
+ const created = makeJob({ enabled: true });
814
+ vi.mocked(cronStore.createJob).mockReturnValue(created);
815
+
816
+ const res = await appNoScheduler.request("/api/cron/jobs", {
817
+ method: "POST",
818
+ headers: { "Content-Type": "application/json" },
819
+ body: JSON.stringify({
820
+ name: "Job",
821
+ prompt: "Do stuff",
822
+ schedule: "0 * * * *",
823
+ cwd: "/tmp",
824
+ }),
825
+ });
826
+
827
+ // Should still succeed — optional chaining means no error
828
+ expect(res.status).toBe(201);
829
+ });
830
+
831
+ it("PUT /cron/jobs/:id updates job without calling scheduleJob", async () => {
832
+ const updated = makeJob({ id: "test-job" });
833
+ vi.mocked(cronStore.updateJob).mockReturnValue(updated);
834
+
835
+ const res = await appNoScheduler.request("/api/cron/jobs/test-job", {
836
+ method: "PUT",
837
+ headers: { "Content-Type": "application/json" },
838
+ body: JSON.stringify({ name: "Updated" }),
839
+ });
840
+
841
+ expect(res.status).toBe(200);
842
+ });
843
+
844
+ it("DELETE /cron/jobs/:id deletes job without calling stopJob", async () => {
845
+ vi.mocked(cronStore.deleteJob).mockReturnValue(true);
846
+
847
+ const res = await appNoScheduler.request("/api/cron/jobs/test-job", {
848
+ method: "DELETE",
849
+ });
850
+
851
+ expect(res.status).toBe(200);
852
+ });
853
+
854
+ it("POST /cron/jobs/:id/toggle works without scheduler", async () => {
855
+ const job = makeJob({ id: "test", enabled: true });
856
+ const toggled = makeJob({ id: "test", enabled: false });
857
+ vi.mocked(cronStore.getJob).mockReturnValue(job);
858
+ vi.mocked(cronStore.updateJob).mockReturnValue(toggled);
859
+
860
+ const res = await appNoScheduler.request("/api/cron/jobs/test/toggle", {
861
+ method: "POST",
862
+ });
863
+
864
+ expect(res.status).toBe(200);
865
+ const json = await res.json();
866
+ expect(json.enabled).toBe(false);
867
+ });
868
+
869
+ it("POST /cron/jobs/:id/run works without scheduler", async () => {
870
+ const job = makeJob({ id: "test" });
871
+ vi.mocked(cronStore.getJob).mockReturnValue(job);
872
+
873
+ const res = await appNoScheduler.request("/api/cron/jobs/test/run", {
874
+ method: "POST",
875
+ });
876
+
877
+ expect(res.status).toBe(200);
878
+ const json = await res.json();
879
+ expect(json.ok).toBe(true);
880
+ });
881
+ });