import { describe, expect, it, vi } from "vitest"; import { createCronJob, listCronJobs, removeCronJob, removeCronJobsForAgent, removeCronJobsForAgentWithBackup, restoreCronJobs, runCronJobNow, type CronJobSummary, } from "@/lib/cron/types"; import type { GatewayClient } from "@/lib/gateway/GatewayClient"; const createListedJob = (params: { id: string; name: string; agentId?: string; updatedAtMs?: number; }): CronJobSummary => ({ id: params.id, name: params.name, agentId: params.agentId, enabled: true, updatedAtMs: params.updatedAtMs ?? 1_700_000_000_000, schedule: { kind: "every", everyMs: 60_000 }, sessionTarget: "isolated", wakeMode: "now", payload: { kind: "agentTurn", message: "Run checks." }, state: {}, }); describe("cron gateway client", () => { it("lists_jobs_via_cron_list_include_disabled_true", async () => { const client = { call: vi.fn(async () => ({ jobs: [] })), } as unknown as GatewayClient; await listCronJobs(client); expect(client.call).toHaveBeenCalledWith("cron.list", { includeDisabled: true }); }); it("runs_job_now_with_force_mode", async () => { const client = { call: vi.fn(async () => ({ ok: true, ran: true })), } as unknown as GatewayClient; await runCronJobNow(client, "job-1"); expect(client.call).toHaveBeenCalledWith("cron.run", { id: "job-1", mode: "force" }); }); it("removes_job_by_id", async () => { const client = { call: vi.fn(async () => ({ ok: true, removed: true })), } as unknown as GatewayClient; await removeCronJob(client, "job-1"); expect(client.call).toHaveBeenCalledWith("cron.remove", { id: "job-1" }); }); it("throws_when_job_id_missing_for_run_or_remove", async () => { const client = { call: vi.fn(async () => ({ ok: true })), } as unknown as GatewayClient; await expect(runCronJobNow(client, " ")).rejects.toThrow("Cron job id is required."); await expect(removeCronJob(client, "")).rejects.toThrow("Cron job id is required."); }); it("removes_all_jobs_for_agent", async () => { const client = { call: vi.fn(async (method: string, payload: { id?: string }) => { if (method === "cron.list") { return { jobs: [ createListedJob({ id: "job-1", name: "Job 1", agentId: "agent-1" }), createListedJob({ id: "job-2", name: "Job 2", agentId: "agent-2" }), createListedJob({ id: "job-3", name: "Job 3", agentId: "agent-1" }), ], }; } if (method === "cron.remove") { return { ok: true, removed: payload.id !== "job-3" }; } throw new Error(`Unexpected method: ${method}`); }), } as unknown as GatewayClient; await expect(removeCronJobsForAgent(client, "agent-1")).resolves.toBe(1); expect(client.call).toHaveBeenCalledWith("cron.list", { includeDisabled: true }); expect(client.call).toHaveBeenCalledWith("cron.remove", { id: "job-1" }); expect(client.call).toHaveBeenCalledWith("cron.remove", { id: "job-3" }); }); it("throws_when_agent_id_missing_for_bulk_remove", async () => { const client = { call: vi.fn(async () => ({ jobs: [] })), } as unknown as GatewayClient; await expect(removeCronJobsForAgent(client, " ")).rejects.toThrow("Agent id is required."); }); it("throws_when_any_bulk_remove_call_fails", async () => { const client = { call: vi.fn(async (method: string) => { if (method === "cron.list") { return { jobs: [createListedJob({ id: "job-1", name: "Job 1", agentId: "agent-1" })], }; } if (method === "cron.remove") { return { ok: false, removed: false }; } throw new Error(`Unexpected method: ${method}`); }), } as unknown as GatewayClient; await expect(removeCronJobsForAgent(client, "agent-1")).rejects.toThrow( 'Failed to delete cron job "Job 1" (job-1).' ); }); it("returns_restore_inputs_when_removing_jobs_with_backup", async () => { const client = { call: vi.fn(async (method: string, payload: { id?: string }) => { if (method === "cron.list") { return { jobs: [ createListedJob({ id: "job-1", name: "Job 1", agentId: "agent-1" }), createListedJob({ id: "job-2", name: "Job 2", agentId: "agent-2" }), createListedJob({ id: "job-3", name: "Job 3", agentId: "agent-1" }), ], }; } if (method === "cron.remove") { return { ok: true, removed: payload.id !== "job-3" }; } throw new Error(`Unexpected method: ${method}`); }), } as unknown as GatewayClient; await expect(removeCronJobsForAgentWithBackup(client, "agent-1")).resolves.toEqual([ { name: "Job 1", agentId: "agent-1", sessionKey: undefined, description: undefined, enabled: true, deleteAfterRun: undefined, schedule: { kind: "every", everyMs: 60_000 }, sessionTarget: "isolated", wakeMode: "now", payload: { kind: "agentTurn", message: "Run checks." }, delivery: undefined, }, ]); }); it("restores_removed_jobs_when_backup_remove_fails_midway", async () => { const client = { call: vi.fn(async (method: string, payload: { id?: string; name?: string }) => { if (method === "cron.list") { return { jobs: [ createListedJob({ id: "job-1", name: "Job 1", agentId: "agent-1" }), createListedJob({ id: "job-2", name: "Job 2", agentId: "agent-1" }), ], }; } if (method === "cron.remove") { if (payload.id === "job-1") return { ok: true, removed: true }; return { ok: false, removed: false }; } if (method === "cron.add") { return { id: "restored-job-1", name: payload.name }; } throw new Error(`Unexpected method: ${method}`); }), } as unknown as GatewayClient; await expect(removeCronJobsForAgentWithBackup(client, "agent-1")).rejects.toThrow( 'Failed to delete cron job "Job 2" (job-2).' ); expect(client.call).toHaveBeenCalledWith("cron.add", { name: "Job 1", agentId: "agent-1", sessionKey: undefined, description: undefined, enabled: true, deleteAfterRun: undefined, schedule: { kind: "every", everyMs: 60_000 }, sessionTarget: "isolated", wakeMode: "now", payload: { kind: "agentTurn", message: "Run checks." }, delivery: undefined, }); }); it("restores_removed_jobs_when_remove_call_throws_midway", async () => { const thrown = new Error("network interrupted"); const client = { call: vi.fn(async (method: string, payload: { id?: string; name?: string }) => { if (method === "cron.list") { return { jobs: [ createListedJob({ id: "job-1", name: "Job 1", agentId: "agent-1" }), createListedJob({ id: "job-2", name: "Job 2", agentId: "agent-1" }), ], }; } if (method === "cron.remove") { if (payload.id === "job-1") return { ok: true, removed: true }; throw thrown; } if (method === "cron.add") { return { id: "restored-job-1", name: payload.name }; } throw new Error(`Unexpected method: ${method}`); }), } as unknown as GatewayClient; await expect(removeCronJobsForAgentWithBackup(client, "agent-1")).rejects.toBe(thrown); expect(client.call).toHaveBeenCalledWith("cron.add", { name: "Job 1", agentId: "agent-1", sessionKey: undefined, description: undefined, enabled: true, deleteAfterRun: undefined, schedule: { kind: "every", everyMs: 60_000 }, sessionTarget: "isolated", wakeMode: "now", payload: { kind: "agentTurn", message: "Run checks." }, delivery: undefined, }); }); it("throws_actionable_error_when_restore_fails", async () => { const client = { call: vi.fn(async (_method: string, payload: { name?: string }) => { if (payload.name === "Job 2") { throw new Error("cron.add failed"); } return { id: "job-restored", name: payload.name }; }), } as unknown as GatewayClient; await expect( restoreCronJobs(client, [ { name: "Job 1", agentId: "agent-1", enabled: true, schedule: { kind: "every", everyMs: 60_000 }, sessionTarget: "isolated", wakeMode: "now", payload: { kind: "agentTurn", message: "Run checks." }, }, { name: "Job 2", agentId: "agent-1", enabled: true, schedule: { kind: "every", everyMs: 120_000 }, sessionTarget: "isolated", wakeMode: "now", payload: { kind: "agentTurn", message: "Run checks again." }, }, ]) ).rejects.toThrow('Failed to restore cron job "Job 2" (agent-1): cron.add failed'); }); it("creates_job_via_cron_add", async () => { const client = { call: vi.fn(async () => ({ id: "job-1", name: "Morning brief" })), } as unknown as GatewayClient; const input = { name: "Morning brief", agentId: "agent-1", enabled: true, schedule: { kind: "cron" as const, expr: "0 7 * * *", tz: "America/Chicago" }, sessionTarget: "isolated" as const, wakeMode: "now" as const, payload: { kind: "agentTurn" as const, message: "Summarize overnight updates." }, delivery: { mode: "announce" as const, channel: "last" }, }; await createCronJob(client, input); expect(client.call).toHaveBeenCalledWith("cron.add", input); }); it("throws_when_create_payload_missing_required_name", async () => { const client = { call: vi.fn(async () => ({ id: "job-1" })), } as unknown as GatewayClient; await expect( createCronJob(client, { name: " ", agentId: "agent-1", schedule: { kind: "every", everyMs: 60_000 }, sessionTarget: "isolated", wakeMode: "now", payload: { kind: "agentTurn", message: "Run checks." }, }) ).rejects.toThrow("Cron job name is required."); }); });