talon-agent 1.0.0 → 1.2.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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -0
  3. package/package.json +15 -11
  4. package/prompts/dream.md +7 -3
  5. package/prompts/heartbeat.md +30 -0
  6. package/prompts/identity.md +1 -0
  7. package/prompts/teams.md +3 -0
  8. package/prompts/telegram.md +1 -0
  9. package/src/__tests__/chat-settings.test.ts +108 -2
  10. package/src/__tests__/cleanup-registry.test.ts +58 -0
  11. package/src/__tests__/config.test.ts +118 -52
  12. package/src/__tests__/cron-store-extended.test.ts +661 -0
  13. package/src/__tests__/cron-store.test.ts +145 -11
  14. package/src/__tests__/daily-log.test.ts +224 -13
  15. package/src/__tests__/dispatcher.test.ts +424 -23
  16. package/src/__tests__/dream.test.ts +1028 -0
  17. package/src/__tests__/errors-extended.test.ts +428 -0
  18. package/src/__tests__/errors.test.ts +95 -3
  19. package/src/__tests__/fuzz.test.ts +87 -15
  20. package/src/__tests__/gateway-actions.test.ts +1174 -433
  21. package/src/__tests__/gateway-http.test.ts +210 -19
  22. package/src/__tests__/gateway-retry.test.ts +359 -0
  23. package/src/__tests__/gateway-withRetry-extended.test.ts +343 -0
  24. package/src/__tests__/graph.test.ts +830 -0
  25. package/src/__tests__/handlers-stream.test.ts +208 -0
  26. package/src/__tests__/handlers.test.ts +2539 -70
  27. package/src/__tests__/heartbeat.test.ts +364 -0
  28. package/src/__tests__/history-extended.test.ts +775 -0
  29. package/src/__tests__/history-persistence.test.ts +74 -19
  30. package/src/__tests__/history.test.ts +113 -79
  31. package/src/__tests__/integration.test.ts +43 -8
  32. package/src/__tests__/log-init.test.ts +129 -0
  33. package/src/__tests__/log.test.ts +23 -5
  34. package/src/__tests__/media-index.test.ts +317 -35
  35. package/src/__tests__/plugin.test.ts +314 -0
  36. package/src/__tests__/prompt-builder-extended.test.ts +296 -0
  37. package/src/__tests__/prompt-builder.test.ts +44 -9
  38. package/src/__tests__/sessions.test.ts +258 -4
  39. package/src/__tests__/storage-save-errors.test.ts +342 -0
  40. package/src/__tests__/teams-frontend.test.ts +526 -31
  41. package/src/__tests__/telegram-formatting.test.ts +82 -0
  42. package/src/__tests__/terminal-commands.test.ts +208 -1
  43. package/src/__tests__/terminal-renderer.test.ts +223 -0
  44. package/src/__tests__/time.test.ts +107 -0
  45. package/src/__tests__/workspace-migrate.test.ts +256 -0
  46. package/src/__tests__/workspace.test.ts +63 -1
  47. package/src/backend/claude-sdk/tools.ts +64 -18
  48. package/src/bootstrap.ts +14 -14
  49. package/src/cli.ts +440 -125
  50. package/src/core/cron.ts +20 -5
  51. package/src/core/dispatcher.ts +27 -9
  52. package/src/core/dream.ts +79 -24
  53. package/src/core/errors.ts +12 -2
  54. package/src/core/gateway-actions.ts +182 -46
  55. package/src/core/gateway.ts +93 -41
  56. package/src/core/heartbeat.ts +515 -0
  57. package/src/core/plugin.ts +1 -1
  58. package/src/core/prompt-builder.ts +1 -4
  59. package/src/core/pulse.ts +4 -3
  60. package/src/frontend/teams/actions.ts +3 -1
  61. package/src/frontend/teams/formatting.ts +47 -8
  62. package/src/frontend/teams/graph.ts +35 -11
  63. package/src/frontend/teams/index.ts +155 -57
  64. package/src/frontend/teams/tools.ts +4 -6
  65. package/src/frontend/telegram/actions.ts +358 -82
  66. package/src/frontend/telegram/admin.ts +162 -72
  67. package/src/frontend/telegram/callbacks.ts +16 -10
  68. package/src/frontend/telegram/commands.ts +37 -21
  69. package/src/frontend/telegram/formatting.ts +2 -4
  70. package/src/frontend/telegram/handlers.ts +262 -66
  71. package/src/frontend/telegram/index.ts +39 -14
  72. package/src/frontend/telegram/middleware.ts +14 -4
  73. package/src/frontend/telegram/userbot.ts +16 -4
  74. package/src/frontend/terminal/renderer.ts +1 -4
  75. package/src/index.ts +28 -4
  76. package/src/storage/chat-settings.ts +32 -9
  77. package/src/storage/cron-store.ts +53 -11
  78. package/src/storage/daily-log.ts +72 -19
  79. package/src/storage/history.ts +39 -21
  80. package/src/storage/media-index.ts +37 -12
  81. package/src/storage/sessions.ts +3 -2
  82. package/src/util/cleanup-registry.ts +34 -0
  83. package/src/util/config.ts +85 -23
  84. package/src/util/log.ts +47 -17
  85. package/src/util/paths.ts +10 -0
  86. package/src/util/time.ts +29 -6
  87. package/src/util/watchdog.ts +5 -1
  88. package/src/util/workspace.ts +51 -10
@@ -0,0 +1,661 @@
1
+ /**
2
+ * Extended tests for src/storage/cron-store.ts
3
+ *
4
+ * Covers edge cases not exercised by cron-store.test.ts:
5
+ * - validateCronExpression edge cases (special expressions, bad timezone,
6
+ * next-run ISO string correctness)
7
+ * - generateCronId uniqueness and format
8
+ * - addCronJob / getCronJob roundtrip with all fields
9
+ * - getCronJobsForChat isolation
10
+ * - updateCronJob happy path and missing-ID guard
11
+ * - deleteCronJob missing-ID guard
12
+ * - recordCronRun increment semantics and missing-ID no-op
13
+ * - getAllCronJobs
14
+ * - loadCronJobs array (legacy) → object conversion
15
+ * - loadCronJobs corrupt primary → backup fallback
16
+ */
17
+
18
+ import { describe, it, expect, vi, beforeEach } from "vitest";
19
+
20
+ // ── Mocks (before any dynamic import) ─────────────────────────────────────
21
+
22
+ vi.mock("../util/log.js", () => ({
23
+ log: vi.fn(),
24
+ logError: vi.fn(),
25
+ logWarn: vi.fn(),
26
+ }));
27
+
28
+ const existsSyncMock = vi.fn(() => false);
29
+ const readFileSyncMock = vi.fn(() => "{}");
30
+ const mkdirSyncMock = vi.fn();
31
+
32
+ vi.mock("node:fs", () => ({
33
+ existsSync: existsSyncMock,
34
+ readFileSync: readFileSyncMock,
35
+ writeFileSync: vi.fn(),
36
+ mkdirSync: mkdirSyncMock,
37
+ }));
38
+
39
+ const writeFileSyncMock = vi.fn();
40
+ vi.mock("write-file-atomic", () => ({
41
+ default: { sync: writeFileSyncMock },
42
+ }));
43
+
44
+ vi.mock("../util/cleanup-registry.js", () => ({
45
+ registerCleanup: vi.fn(),
46
+ }));
47
+
48
+ vi.mock("../util/paths.js", () => ({
49
+ files: { cron: "/mock/data/cron.json" },
50
+ dirs: {},
51
+ }));
52
+
53
+ // ── Dynamic import ─────────────────────────────────────────────────────────
54
+
55
+ import type { CronJob } from "../storage/cron-store.js";
56
+
57
+ const {
58
+ loadCronJobs,
59
+ addCronJob,
60
+ getCronJob,
61
+ getCronJobsForChat,
62
+ getAllCronJobs,
63
+ updateCronJob,
64
+ deleteCronJob,
65
+ recordCronRun,
66
+ generateCronId,
67
+ validateCronExpression,
68
+ } = await import("../storage/cron-store.js");
69
+
70
+ // ── Helpers ────────────────────────────────────────────────────────────────
71
+
72
+ let _seq = 0;
73
+ function uniqueId(): string {
74
+ return `ext-cron-${++_seq}-${Math.random().toString(36).slice(2, 6)}`;
75
+ }
76
+
77
+ function makeJob(overrides: Partial<CronJob> = {}): CronJob {
78
+ return {
79
+ id: uniqueId(),
80
+ chatId: "default-chat",
81
+ schedule: "0 9 * * *",
82
+ type: "message",
83
+ content: "Hello!",
84
+ name: "Test job",
85
+ enabled: true,
86
+ createdAt: Date.now(),
87
+ runCount: 0,
88
+ ...overrides,
89
+ };
90
+ }
91
+
92
+ beforeEach(() => {
93
+ vi.clearAllMocks();
94
+ });
95
+
96
+ // ── validateCronExpression ─────────────────────────────────────────────────
97
+
98
+ describe("validateCronExpression", () => {
99
+ it("standard 5-field expression is valid", () => {
100
+ const r = validateCronExpression("0 9 * * *");
101
+ expect(r.valid).toBe(true);
102
+ expect(r.error).toBeUndefined();
103
+ });
104
+
105
+ it("next-run date is a valid ISO string in the future", () => {
106
+ const r = validateCronExpression("0 9 * * *");
107
+ expect(r.next).toBeDefined();
108
+ const next = new Date(r.next!);
109
+ expect(isNaN(next.getTime())).toBe(false);
110
+ expect(next.getTime()).toBeGreaterThan(Date.now());
111
+ });
112
+
113
+ it("every-minute expression '* * * * *' is valid", () => {
114
+ const r = validateCronExpression("* * * * *");
115
+ expect(r.valid).toBe(true);
116
+ });
117
+
118
+ it("every-5-minutes expression '*/5 * * * *' is valid", () => {
119
+ const r = validateCronExpression("*/5 * * * *");
120
+ expect(r.valid).toBe(true);
121
+ });
122
+
123
+ it("weekday-only expression '0 12 * * 1-5' is valid", () => {
124
+ const r = validateCronExpression("0 12 * * 1-5");
125
+ expect(r.valid).toBe(true);
126
+ });
127
+
128
+ it("first-of-month expression '0 0 1 * *' is valid", () => {
129
+ const r = validateCronExpression("0 0 1 * *");
130
+ expect(r.valid).toBe(true);
131
+ });
132
+
133
+ it("random string is invalid and returns error message", () => {
134
+ const r = validateCronExpression("not a cron");
135
+ expect(r.valid).toBe(false);
136
+ expect(typeof r.error).toBe("string");
137
+ expect(r.error!.length).toBeGreaterThan(0);
138
+ });
139
+
140
+ it("empty string is invalid", () => {
141
+ const r = validateCronExpression("");
142
+ expect(r.valid).toBe(false);
143
+ expect(r.error).toBeDefined();
144
+ });
145
+
146
+ it("expression with too few fields is invalid", () => {
147
+ const r = validateCronExpression("* * *");
148
+ expect(r.valid).toBe(false);
149
+ });
150
+
151
+ it("valid expression with valid timezone is accepted", () => {
152
+ const r = validateCronExpression("0 9 * * *", "America/New_York");
153
+ expect(r.valid).toBe(true);
154
+ expect(r.next).toBeDefined();
155
+ });
156
+
157
+ it("valid expression with Europe/Warsaw timezone is accepted", () => {
158
+ expect(validateCronExpression("30 8 * * *", "Europe/Warsaw").valid).toBe(
159
+ true,
160
+ );
161
+ });
162
+
163
+ it("valid expression with Asia/Tokyo timezone is accepted", () => {
164
+ expect(validateCronExpression("0 6 * * *", "Asia/Tokyo").valid).toBe(true);
165
+ });
166
+
167
+ it("invalid timezone returns valid: false", () => {
168
+ const r = validateCronExpression("0 9 * * *", "Not/A/Real/Timezone");
169
+ expect(r.valid).toBe(false);
170
+ expect(r.error).toBeDefined();
171
+ });
172
+
173
+ it("invalid expression returns no 'next' field", () => {
174
+ const r = validateCronExpression("garbage");
175
+ expect(r.next).toBeUndefined();
176
+ });
177
+
178
+ it("valid expression without timezone still returns next", () => {
179
+ const r = validateCronExpression("0 0 * * *");
180
+ expect(r.valid).toBe(true);
181
+ expect(r.next).toBeDefined();
182
+ });
183
+ });
184
+
185
+ // ── generateCronId ─────────────────────────────────────────────────────────
186
+
187
+ describe("generateCronId", () => {
188
+ it("produces IDs starting with 'cron_'", () => {
189
+ for (let i = 0; i < 20; i++) {
190
+ expect(generateCronId()).toMatch(/^cron_/);
191
+ }
192
+ });
193
+
194
+ it("produces IDs in cron_<uuid> format", () => {
195
+ const id = generateCronId();
196
+ const uuid = id.slice("cron_".length);
197
+ expect(uuid).toMatch(
198
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
199
+ );
200
+ });
201
+
202
+ it("UUID part is version 4 (random)", () => {
203
+ const id = generateCronId();
204
+ const uuid = id.slice("cron_".length);
205
+ // Version 4 UUID: 13th char is '4'
206
+ expect(uuid[14]).toBe("4");
207
+ });
208
+
209
+ it("produces 100 unique IDs across rapid calls", () => {
210
+ const ids = new Set<string>();
211
+ for (let i = 0; i < 100; i++) {
212
+ ids.add(generateCronId());
213
+ }
214
+ expect(ids.size).toBe(100);
215
+ });
216
+ });
217
+
218
+ // ── addCronJob / getCronJob roundtrip ──────────────────────────────────────
219
+
220
+ describe("addCronJob and getCronJob roundtrip", () => {
221
+ it("stores and retrieves a job with all fields intact", () => {
222
+ const id = uniqueId();
223
+ const job = makeJob({
224
+ id,
225
+ chatId: "roundtrip-chat",
226
+ schedule: "*/15 * * * *",
227
+ type: "query",
228
+ content: "What is the weather today?",
229
+ name: "Hourly weather",
230
+ enabled: false,
231
+ timezone: "Europe/London",
232
+ runCount: 5,
233
+ });
234
+ addCronJob(job);
235
+
236
+ const got = getCronJob(id)!;
237
+ expect(got.id).toBe(id);
238
+ expect(got.chatId).toBe("roundtrip-chat");
239
+ expect(got.schedule).toBe("*/15 * * * *");
240
+ expect(got.type).toBe("query");
241
+ expect(got.content).toBe("What is the weather today?");
242
+ expect(got.name).toBe("Hourly weather");
243
+ expect(got.enabled).toBe(false);
244
+ expect(got.timezone).toBe("Europe/London");
245
+ expect(got.runCount).toBe(5);
246
+ });
247
+
248
+ it("getCronJob returns undefined for an ID that was never added", () => {
249
+ expect(getCronJob("absolutely-not-a-real-id-xyz")).toBeUndefined();
250
+ });
251
+
252
+ it("adding a job with the same ID overwrites the previous one", () => {
253
+ const id = uniqueId();
254
+ addCronJob(makeJob({ id, name: "First version" }));
255
+ addCronJob(makeJob({ id, name: "Second version" }));
256
+ expect(getCronJob(id)!.name).toBe("Second version");
257
+ });
258
+
259
+ it("addCronJob triggers a sync write (dirty flag is set)", () => {
260
+ writeFileSyncMock.mockClear();
261
+ existsSyncMock.mockReturnValue(false);
262
+ addCronJob(makeJob());
263
+ expect(writeFileSyncMock).toHaveBeenCalled();
264
+ });
265
+ });
266
+
267
+ // ── getCronJobsForChat ─────────────────────────────────────────────────────
268
+
269
+ describe("getCronJobsForChat", () => {
270
+ it("returns only jobs belonging to the specified chatId", () => {
271
+ const chatA = `chat-A-${uniqueId()}`;
272
+ const chatB = `chat-B-${uniqueId()}`;
273
+
274
+ const idA1 = uniqueId();
275
+ const idA2 = uniqueId();
276
+ const idB1 = uniqueId();
277
+
278
+ addCronJob(makeJob({ id: idA1, chatId: chatA }));
279
+ addCronJob(makeJob({ id: idA2, chatId: chatA }));
280
+ addCronJob(makeJob({ id: idB1, chatId: chatB }));
281
+
282
+ const resultA = getCronJobsForChat(chatA);
283
+ expect(resultA.map((j) => j.id)).toContain(idA1);
284
+ expect(resultA.map((j) => j.id)).toContain(idA2);
285
+ expect(resultA.map((j) => j.id)).not.toContain(idB1);
286
+
287
+ const resultB = getCronJobsForChat(chatB);
288
+ expect(resultB).toHaveLength(1);
289
+ expect(resultB[0].id).toBe(idB1);
290
+ });
291
+
292
+ it("returns an empty array for a chat with no jobs", () => {
293
+ expect(getCronJobsForChat("chat-with-zero-jobs-ext-xyz")).toEqual([]);
294
+ });
295
+
296
+ it("does not return a deleted job", () => {
297
+ const chat = `del-chat-${uniqueId()}`;
298
+ const id = uniqueId();
299
+ addCronJob(makeJob({ id, chatId: chat }));
300
+ deleteCronJob(id);
301
+ expect(getCronJobsForChat(chat)).toEqual([]);
302
+ });
303
+ });
304
+
305
+ // ── updateCronJob ──────────────────────────────────────────────────────────
306
+
307
+ describe("updateCronJob", () => {
308
+ it("updates individual fields and returns the updated job", () => {
309
+ const id = uniqueId();
310
+ addCronJob(
311
+ makeJob({ id, name: "Old name", enabled: true, schedule: "0 9 * * *" }),
312
+ );
313
+
314
+ const result = updateCronJob(id, { name: "New name", enabled: false });
315
+ expect(result).toBeDefined();
316
+ expect(result!.name).toBe("New name");
317
+ expect(result!.enabled).toBe(false);
318
+ // unchanged fields remain
319
+ expect(result!.schedule).toBe("0 9 * * *");
320
+ });
321
+
322
+ it("returned job reference is the same object stored in getCronJob", () => {
323
+ const id = uniqueId();
324
+ addCronJob(makeJob({ id }));
325
+ const updated = updateCronJob(id, { name: "Check ref" });
326
+ expect(updated).toBe(getCronJob(id));
327
+ });
328
+
329
+ it("returns undefined for a non-existent ID", () => {
330
+ expect(
331
+ updateCronJob("no-such-id-ext", { name: "irrelevant" }),
332
+ ).toBeUndefined();
333
+ });
334
+
335
+ it("can update schedule and content simultaneously", () => {
336
+ const id = uniqueId();
337
+ addCronJob(makeJob({ id }));
338
+ const result = updateCronJob(id, {
339
+ schedule: "*/10 * * * *",
340
+ content: "updated content",
341
+ });
342
+ expect(result!.schedule).toBe("*/10 * * * *");
343
+ expect(result!.content).toBe("updated content");
344
+ });
345
+
346
+ it("can update timezone", () => {
347
+ const id = uniqueId();
348
+ addCronJob(makeJob({ id }));
349
+ const result = updateCronJob(id, { timezone: "Pacific/Auckland" });
350
+ expect(result!.timezone).toBe("Pacific/Auckland");
351
+ });
352
+
353
+ it("triggers a sync write after updating", () => {
354
+ const id = uniqueId();
355
+ addCronJob(makeJob({ id }));
356
+ writeFileSyncMock.mockClear();
357
+ existsSyncMock.mockReturnValue(false);
358
+ updateCronJob(id, { name: "write test" });
359
+ expect(writeFileSyncMock).toHaveBeenCalled();
360
+ });
361
+ });
362
+
363
+ // ── deleteCronJob ──────────────────────────────────────────────────────────
364
+
365
+ describe("deleteCronJob", () => {
366
+ it("removes the job and returns true", () => {
367
+ const id = uniqueId();
368
+ addCronJob(makeJob({ id }));
369
+ expect(deleteCronJob(id)).toBe(true);
370
+ expect(getCronJob(id)).toBeUndefined();
371
+ });
372
+
373
+ it("returns false for a non-existent ID", () => {
374
+ expect(deleteCronJob("phantom-id-ext-999")).toBe(false);
375
+ });
376
+
377
+ it("deleting twice returns false on second call", () => {
378
+ const id = uniqueId();
379
+ addCronJob(makeJob({ id }));
380
+ expect(deleteCronJob(id)).toBe(true);
381
+ expect(deleteCronJob(id)).toBe(false);
382
+ });
383
+
384
+ it("triggers a sync write", () => {
385
+ const id = uniqueId();
386
+ addCronJob(makeJob({ id }));
387
+ writeFileSyncMock.mockClear();
388
+ existsSyncMock.mockReturnValue(false);
389
+ deleteCronJob(id);
390
+ expect(writeFileSyncMock).toHaveBeenCalled();
391
+ });
392
+ });
393
+
394
+ // ── recordCronRun ──────────────────────────────────────────────────────────
395
+
396
+ describe("recordCronRun", () => {
397
+ it("increments runCount from 0 to 1 on first call", () => {
398
+ const id = uniqueId();
399
+ addCronJob(makeJob({ id, runCount: 0 }));
400
+ recordCronRun(id);
401
+ expect(getCronJob(id)!.runCount).toBe(1);
402
+ });
403
+
404
+ it("increments runCount on each successive call", () => {
405
+ const id = uniqueId();
406
+ addCronJob(makeJob({ id, runCount: 0 }));
407
+ recordCronRun(id);
408
+ recordCronRun(id);
409
+ recordCronRun(id);
410
+ expect(getCronJob(id)!.runCount).toBe(3);
411
+ });
412
+
413
+ it("sets lastRunAt to a timestamp close to now", () => {
414
+ const id = uniqueId();
415
+ addCronJob(makeJob({ id }));
416
+ const before = Date.now();
417
+ recordCronRun(id);
418
+ const after = Date.now();
419
+
420
+ const lastRun = getCronJob(id)!.lastRunAt!;
421
+ expect(lastRun).toBeGreaterThanOrEqual(before);
422
+ expect(lastRun).toBeLessThanOrEqual(after);
423
+ });
424
+
425
+ it("is a no-op (does not throw) for a non-existent ID", () => {
426
+ expect(() => recordCronRun("non-existent-run-id-ext")).not.toThrow();
427
+ });
428
+
429
+ it("does not reset runCount when called on a job with existing runCount", () => {
430
+ const id = uniqueId();
431
+ addCronJob(makeJob({ id, runCount: 10 }));
432
+ recordCronRun(id);
433
+ expect(getCronJob(id)!.runCount).toBe(11);
434
+ });
435
+ });
436
+
437
+ // ── getAllCronJobs ─────────────────────────────────────────────────────────
438
+
439
+ describe("getAllCronJobs", () => {
440
+ it("includes recently added jobs", () => {
441
+ const id1 = uniqueId();
442
+ const id2 = uniqueId();
443
+ addCronJob(makeJob({ id: id1 }));
444
+ addCronJob(makeJob({ id: id2 }));
445
+
446
+ const all = getAllCronJobs();
447
+ const ids = all.map((j) => j.id);
448
+ expect(ids).toContain(id1);
449
+ expect(ids).toContain(id2);
450
+ });
451
+
452
+ it("does not include deleted jobs", () => {
453
+ const id = uniqueId();
454
+ addCronJob(makeJob({ id }));
455
+ deleteCronJob(id);
456
+ const all = getAllCronJobs();
457
+ expect(all.map((j) => j.id)).not.toContain(id);
458
+ });
459
+
460
+ it("returns an array (not an object)", () => {
461
+ expect(Array.isArray(getAllCronJobs())).toBe(true);
462
+ });
463
+ });
464
+
465
+ // ── loadCronJobs — legacy array format ────────────────────────────────────
466
+
467
+ describe("loadCronJobs — array (legacy) format", () => {
468
+ it("converts array to object and makes jobs retrievable", () => {
469
+ const legacy = [
470
+ {
471
+ id: "legacy-ext-1",
472
+ chatId: "chat-legacy",
473
+ schedule: "0 8 * * *",
474
+ type: "message" as const,
475
+ content: "Good morning",
476
+ name: "Morning",
477
+ enabled: true,
478
+ createdAt: 1000,
479
+ runCount: 2,
480
+ },
481
+ {
482
+ id: "legacy-ext-2",
483
+ chatId: "chat-legacy",
484
+ schedule: "0 20 * * *",
485
+ type: "query" as const,
486
+ content: "Evening report",
487
+ name: "Evening",
488
+ enabled: false,
489
+ createdAt: 2000,
490
+ runCount: 0,
491
+ },
492
+ ];
493
+
494
+ existsSyncMock.mockReturnValueOnce(true);
495
+ readFileSyncMock.mockReturnValueOnce(JSON.stringify(legacy));
496
+
497
+ loadCronJobs();
498
+
499
+ expect(getCronJob("legacy-ext-1")).toBeDefined();
500
+ expect(getCronJob("legacy-ext-1")!.name).toBe("Morning");
501
+ expect(getCronJob("legacy-ext-2")).toBeDefined();
502
+ expect(getCronJob("legacy-ext-2")!.type).toBe("query");
503
+ });
504
+ });
505
+
506
+ // ── loadCronJobs — corrupt primary → backup fallback ──────────────────────
507
+
508
+ describe("loadCronJobs — corrupt primary tries backup", () => {
509
+ it("falls back to backup when primary JSON is corrupt", () => {
510
+ const backup = {
511
+ "bak-ext-job": {
512
+ id: "bak-ext-job",
513
+ chatId: "bak-chat",
514
+ schedule: "0 7 * * *",
515
+ type: "message" as const,
516
+ content: "From backup",
517
+ name: "Backup job",
518
+ enabled: true,
519
+ createdAt: 3000,
520
+ runCount: 0,
521
+ },
522
+ };
523
+
524
+ // primary exists but is corrupt; backup exists and is valid
525
+ existsSyncMock
526
+ .mockReturnValueOnce(true) // primary existsSync
527
+ .mockReturnValueOnce(true); // backup existsSync
528
+ readFileSyncMock
529
+ .mockReturnValueOnce("{{{ not json }}}") // primary read
530
+ .mockReturnValueOnce(JSON.stringify(backup)); // backup read
531
+
532
+ expect(() => loadCronJobs()).not.toThrow();
533
+ expect(getCronJob("bak-ext-job")).toBeDefined();
534
+ expect(getCronJob("bak-ext-job")!.name).toBe("Backup job");
535
+ });
536
+
537
+ it("does not throw when primary corrupt and backup does not exist (line 56 FALSE branch)", () => {
538
+ // primary exists but corrupt; backup file does not exist → existsSync(bakFile) = false
539
+ existsSyncMock
540
+ .mockReturnValueOnce(true) // primary existsSync
541
+ .mockReturnValueOnce(false); // backup existsSync → FALSE branch
542
+ readFileSyncMock.mockReturnValueOnce("{{{ not json }}}");
543
+
544
+ expect(() => loadCronJobs()).not.toThrow();
545
+ });
546
+
547
+ it("loads backup in array format when primary is corrupt (line 58 TRUE branch)", () => {
548
+ const legacyArray = [
549
+ {
550
+ id: "bak-arr-1",
551
+ chatId: "bak-chat",
552
+ schedule: "0 6 * * *",
553
+ type: "message" as const,
554
+ content: "From array backup",
555
+ name: "Array backup job",
556
+ enabled: true,
557
+ createdAt: 1000,
558
+ runCount: 0,
559
+ },
560
+ ];
561
+
562
+ // primary exists but corrupt; backup exists with array (legacy) format
563
+ existsSyncMock
564
+ .mockReturnValueOnce(true) // primary existsSync
565
+ .mockReturnValueOnce(true); // backup existsSync
566
+ readFileSyncMock
567
+ .mockReturnValueOnce("{{{ not json }}}") // primary read
568
+ .mockReturnValueOnce(JSON.stringify(legacyArray)); // backup read (array)
569
+
570
+ loadCronJobs();
571
+
572
+ expect(getCronJob("bak-arr-1")).toBeDefined();
573
+ expect(getCronJob("bak-arr-1")!.name).toBe("Array backup job");
574
+ });
575
+
576
+ it("does not throw when both primary and backup are corrupt", () => {
577
+ existsSyncMock
578
+ .mockReturnValueOnce(true) // primary exists
579
+ .mockReturnValueOnce(true); // backup exists
580
+ readFileSyncMock
581
+ .mockReturnValueOnce("BAD JSON PRIMARY")
582
+ .mockReturnValueOnce("BAD JSON BACKUP");
583
+
584
+ expect(() => loadCronJobs()).not.toThrow();
585
+ });
586
+
587
+ it("handles missing store file gracefully (does not throw)", () => {
588
+ existsSyncMock.mockReturnValue(false);
589
+ expect(() => loadCronJobs()).not.toThrow();
590
+ });
591
+ });
592
+
593
+ // ── loadCronJobs — timezone validation ────────────────────────────────────
594
+
595
+ describe("loadCronJobs — invalid timezone stripping", () => {
596
+ beforeEach(() => existsSyncMock.mockReset().mockReturnValue(false));
597
+
598
+ it("strips invalid timezone from loaded job", async () => {
599
+ const { isValidTimezone } = await import("../storage/cron-store.js");
600
+ const jobWithBadTz: Record<string, unknown> = {
601
+ "tz-bad-id": {
602
+ id: "tz-bad-id",
603
+ chatId: "99",
604
+ schedule: "0 * * * *",
605
+ type: "message",
606
+ content: "hi",
607
+ name: "TZ Test",
608
+ enabled: true,
609
+ createdAt: Date.now(),
610
+ runCount: 0,
611
+ timezone: "Not/A_Real_Zone",
612
+ },
613
+ };
614
+ existsSyncMock.mockReturnValueOnce(true).mockReturnValue(false);
615
+ readFileSyncMock.mockReturnValueOnce(JSON.stringify(jobWithBadTz));
616
+ loadCronJobs();
617
+ const job = getCronJob("tz-bad-id");
618
+ expect(job).toBeDefined();
619
+ expect(job!.timezone).toBeUndefined();
620
+ });
621
+
622
+ it("preserves valid timezone on load", async () => {
623
+ const jobWithGoodTz: Record<string, unknown> = {
624
+ "tz-good-id": {
625
+ id: "tz-good-id",
626
+ chatId: "99",
627
+ schedule: "0 * * * *",
628
+ type: "message",
629
+ content: "hi",
630
+ name: "TZ Good",
631
+ enabled: true,
632
+ createdAt: Date.now(),
633
+ runCount: 0,
634
+ timezone: "Europe/Warsaw",
635
+ },
636
+ };
637
+ existsSyncMock.mockReturnValueOnce(true).mockReturnValue(false);
638
+ readFileSyncMock.mockReturnValueOnce(JSON.stringify(jobWithGoodTz));
639
+ loadCronJobs();
640
+ const job = getCronJob("tz-good-id");
641
+ expect(job).toBeDefined();
642
+ expect(job!.timezone).toBe("Europe/Warsaw");
643
+ });
644
+ });
645
+
646
+ describe("isValidTimezone", () => {
647
+ it("returns true for valid IANA timezones", async () => {
648
+ const { isValidTimezone } = await import("../storage/cron-store.js");
649
+ expect(isValidTimezone("UTC")).toBe(true);
650
+ expect(isValidTimezone("America/New_York")).toBe(true);
651
+ expect(isValidTimezone("Europe/Warsaw")).toBe(true);
652
+ expect(isValidTimezone("Asia/Tokyo")).toBe(true);
653
+ });
654
+
655
+ it("returns false for invalid timezone strings", async () => {
656
+ const { isValidTimezone } = await import("../storage/cron-store.js");
657
+ expect(isValidTimezone("Not/Real")).toBe(false);
658
+ expect(isValidTimezone("")).toBe(false);
659
+ expect(isValidTimezone("BadString")).toBe(false);
660
+ });
661
+ });