4fa4f13558
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
315 lines
10 KiB
TypeScript
315 lines
10 KiB
TypeScript
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.");
|
|
});
|
|
});
|