import { describe, expect, it } from "vitest"; import { applyJobPatch, createJob } from "./service/jobs.js"; import type { CronServiceState } from "./service/state.js"; import { DEFAULT_TOP_OF_HOUR_STAGGER_MS } from "./stagger.js"; import type { CronJob, CronJobPatch } from "./types.js"; describe("applyJobPatch", () => { const createIsolatedAgentTurnJob = ( id: string, delivery: CronJob["delivery"], overrides?: Partial, ): CronJob => { const now = Date.now(); return { id, name: id, enabled: true, createdAtMs: now, updatedAtMs: now, schedule: { kind: "every", everyMs: 60_000 }, sessionTarget: "isolated", wakeMode: "now", payload: { kind: "agentTurn", message: "do it" }, delivery, state: {}, ...overrides, }; }; const switchToMainPatch = (): CronJobPatch => ({ sessionTarget: "main", payload: { kind: "systemEvent", text: "ping" }, }); const createMainSystemEventJob = (id: string, delivery: CronJob["delivery"]): CronJob => { return createIsolatedAgentTurnJob(id, delivery, { sessionTarget: "main", payload: { kind: "systemEvent", text: "ping" }, }); }; it("clears delivery when switching to main session", () => { const job = createIsolatedAgentTurnJob("job-1", { mode: "announce", channel: "telegram", to: "123", }); expect(() => applyJobPatch(job, switchToMainPatch())).not.toThrow(); expect(job.sessionTarget).toBe("main"); expect(job.payload.kind).toBe("systemEvent"); expect(job.delivery).toBeUndefined(); }); it("keeps webhook delivery when switching to main session", () => { const job = createIsolatedAgentTurnJob("job-webhook", { mode: "webhook", to: "https://example.invalid/cron", }); expect(() => applyJobPatch(job, switchToMainPatch())).not.toThrow(); expect(job.sessionTarget).toBe("main"); expect(job.delivery).toEqual({ mode: "webhook", to: "https://example.invalid/cron" }); }); it("maps legacy payload delivery updates onto delivery", () => { const job = createIsolatedAgentTurnJob("job-2", { mode: "announce", channel: "telegram", to: "123", }); const patch: CronJobPatch = { payload: { kind: "agentTurn", deliver: false, channel: "Signal", to: "555", bestEffortDeliver: true, }, }; expect(() => applyJobPatch(job, patch)).not.toThrow(); expect(job.payload.kind).toBe("agentTurn"); if (job.payload.kind === "agentTurn") { expect(job.payload.deliver).toBe(false); expect(job.payload.channel).toBe("Signal"); expect(job.payload.to).toBe("555"); expect(job.payload.bestEffortDeliver).toBe(true); } expect(job.delivery).toEqual({ mode: "none", channel: "signal", to: "555", bestEffort: true, }); }); it("treats legacy payload targets as announce requests", () => { const job = createIsolatedAgentTurnJob("job-3", { mode: "none", channel: "telegram", }); const patch: CronJobPatch = { payload: { kind: "agentTurn", to: " 999 " }, }; expect(() => applyJobPatch(job, patch)).not.toThrow(); expect(job.delivery).toEqual({ mode: "announce", channel: "telegram", to: "999", bestEffort: undefined, }); }); it("rejects webhook delivery without a valid http(s) target URL", () => { const expectedError = "cron webhook delivery requires delivery.to to be a valid http(s) URL"; const cases = [ { name: "no delivery update", patch: { enabled: true } satisfies CronJobPatch }, { name: "blank webhook target", patch: { delivery: { mode: "webhook", to: "" } } satisfies CronJobPatch, }, { name: "non-http protocol", patch: { delivery: { mode: "webhook", to: "ftp://example.invalid" }, } satisfies CronJobPatch, }, { name: "invalid URL", patch: { delivery: { mode: "webhook", to: "not-a-url" } } satisfies CronJobPatch, }, ] as const; for (const testCase of cases) { const job = createMainSystemEventJob("job-webhook-invalid", { mode: "webhook" }); expect(() => applyJobPatch(job, testCase.patch), testCase.name).toThrow(expectedError); } }); it("trims webhook delivery target URLs", () => { const job = createMainSystemEventJob("job-webhook-trim", { mode: "webhook", to: "https://example.invalid/original", }); expect(() => applyJobPatch(job, { delivery: { mode: "webhook", to: " https://example.invalid/trim " } }), ).not.toThrow(); expect(job.delivery).toEqual({ mode: "webhook", to: "https://example.invalid/trim" }); }); it("rejects Telegram delivery with invalid target (chatId/topicId format)", () => { const job = createIsolatedAgentTurnJob("job-telegram-invalid", { mode: "announce", channel: "telegram", to: "-10012345/6789", }); expect(() => applyJobPatch(job, { enabled: true })).toThrow( 'Invalid Telegram delivery target "-10012345/6789". Use colon (:) as delimiter for topics, not slash. Valid formats: -1001234567890, -1001234567890:123, -1001234567890:topic:123, @username, https://t.me/username', ); }); it("accepts Telegram delivery with t.me URL", () => { const job = createIsolatedAgentTurnJob("job-telegram-tme", { mode: "announce", channel: "telegram", to: "https://t.me/mychannel", }); expect(() => applyJobPatch(job, { enabled: true })).not.toThrow(); }); it("accepts Telegram delivery with t.me URL (no https)", () => { const job = createIsolatedAgentTurnJob("job-telegram-tme-no-https", { mode: "announce", channel: "telegram", to: "t.me/mychannel", }); expect(() => applyJobPatch(job, { enabled: true })).not.toThrow(); }); it("accepts Telegram delivery with valid target (plain chat id)", () => { const job = createIsolatedAgentTurnJob("job-telegram-valid", { mode: "announce", channel: "telegram", to: "-1001234567890", }); expect(() => applyJobPatch(job, { enabled: true })).not.toThrow(); }); it("accepts Telegram delivery with valid target (colon delimiter)", () => { const job = createIsolatedAgentTurnJob("job-telegram-valid-colon", { mode: "announce", channel: "telegram", to: "-1001234567890:123", }); expect(() => applyJobPatch(job, { enabled: true })).not.toThrow(); }); it("accepts Telegram delivery with valid target (topic marker)", () => { const job = createIsolatedAgentTurnJob("job-telegram-valid-topic", { mode: "announce", channel: "telegram", to: "-1001234567890:topic:456", }); expect(() => applyJobPatch(job, { enabled: true })).not.toThrow(); }); it("accepts Telegram delivery without target", () => { const job = createIsolatedAgentTurnJob("job-telegram-no-target", { mode: "announce", channel: "telegram", }); expect(() => applyJobPatch(job, { enabled: true })).not.toThrow(); }); it("accepts Telegram delivery with @username", () => { const job = createIsolatedAgentTurnJob("job-telegram-username", { mode: "announce", channel: "telegram", to: "@mybot", }); expect(() => applyJobPatch(job, { enabled: true })).not.toThrow(); }); }); function createMockState(now: number): CronServiceState { return { deps: { nowMs: () => now, }, } as unknown as CronServiceState; } describe("cron stagger defaults", () => { it("defaults top-of-hour cron jobs to 5m stagger", () => { const now = Date.parse("2026-02-08T10:00:00.000Z"); const state = createMockState(now); const job = createJob(state, { name: "hourly", enabled: true, schedule: { kind: "cron", expr: "0 * * * *", tz: "UTC" }, sessionTarget: "main", wakeMode: "now", payload: { kind: "systemEvent", text: "tick" }, }); expect(job.schedule.kind).toBe("cron"); if (job.schedule.kind === "cron") { expect(job.schedule.staggerMs).toBe(DEFAULT_TOP_OF_HOUR_STAGGER_MS); } }); it("keeps exact schedules when staggerMs is explicitly 0", () => { const now = Date.parse("2026-02-08T10:00:00.000Z"); const state = createMockState(now); const job = createJob(state, { name: "exact-hourly", enabled: true, schedule: { kind: "cron", expr: "0 * * * *", tz: "UTC", staggerMs: 0 }, sessionTarget: "main", wakeMode: "now", payload: { kind: "systemEvent", text: "tick" }, }); expect(job.schedule.kind).toBe("cron"); if (job.schedule.kind === "cron") { expect(job.schedule.staggerMs).toBe(0); } }); it("preserves existing stagger when editing cron expression without stagger", () => { const now = Date.now(); const job: CronJob = { id: "job-keep-stagger", name: "job-keep-stagger", enabled: true, createdAtMs: now, updatedAtMs: now, schedule: { kind: "cron", expr: "0 * * * *", tz: "UTC", staggerMs: 120_000 }, sessionTarget: "main", wakeMode: "now", payload: { kind: "systemEvent", text: "tick" }, state: {}, }; applyJobPatch(job, { schedule: { kind: "cron", expr: "0 */2 * * *", tz: "UTC" }, }); expect(job.schedule.kind).toBe("cron"); if (job.schedule.kind === "cron") { expect(job.schedule.expr).toBe("0 */2 * * *"); expect(job.schedule.staggerMs).toBe(120_000); } }); it("applies default stagger when switching from every to top-of-hour cron", () => { const now = Date.now(); const job: CronJob = { id: "job-switch-cron", name: "job-switch-cron", enabled: true, createdAtMs: now, updatedAtMs: now, schedule: { kind: "every", everyMs: 60_000 }, sessionTarget: "main", wakeMode: "now", payload: { kind: "systemEvent", text: "tick" }, state: {}, }; applyJobPatch(job, { schedule: { kind: "cron", expr: "0 * * * *", tz: "UTC" }, }); expect(job.schedule.kind).toBe("cron"); if (job.schedule.kind === "cron") { expect(job.schedule.staggerMs).toBe(DEFAULT_TOP_OF_HOUR_STAGGER_MS); } }); });