import { describe, expect, it, vi, beforeEach } from "vitest"; import { removeCronJobsForAgentWithBackup, restoreCronJobs, type CronJobRestoreInput, } from "@/lib/cron/types"; import { deleteGatewayAgent } from "@/lib/gateway/agentConfig"; import { deleteAgentViaStudio } from "@/features/agents/operations/deleteAgentOperation"; vi.mock("@/lib/cron/types", async () => { const actual = await vi.importActual("@/lib/cron/types"); return { ...actual, removeCronJobsForAgentWithBackup: vi.fn(), restoreCronJobs: vi.fn(), }; }); vi.mock("@/lib/gateway/agentConfig", async () => { const actual = await vi.importActual( "@/lib/gateway/agentConfig" ); return { ...actual, deleteGatewayAgent: vi.fn() }; }); type FetchJson = (input: RequestInfo | URL, init?: RequestInit) => Promise; const createTrashResult = (overrides?: { trashDir?: string; moved?: Array<{ from: string; to: string }>; }) => ({ trashDir: "/tmp/trash", moved: [], ...(overrides ?? {}), }); const createCronRestoreInput = (name = "Job 1", agentId = "agent-1"): CronJobRestoreInput => ({ name, agentId, enabled: true, schedule: { kind: "every", everyMs: 60_000 }, sessionTarget: "isolated", wakeMode: "now", payload: { kind: "agentTurn", message: "Run checks." }, }); describe("delete agent via studio operation", () => { const mockedRemoveCronJobsForAgentWithBackup = vi.mocked(removeCronJobsForAgentWithBackup); const mockedRestoreCronJobs = vi.mocked(restoreCronJobs); const mockedDeleteGatewayAgent = vi.mocked(deleteGatewayAgent); beforeEach(() => { mockedRemoveCronJobsForAgentWithBackup.mockReset(); mockedRestoreCronJobs.mockReset(); mockedDeleteGatewayAgent.mockReset(); }); it("runs_steps_in_order_on_success", async () => { const calls: string[] = []; const fetchJson: FetchJson = vi.fn(async (_input, init) => { if (init?.method === "POST") { calls.push("trash"); return { result: createTrashResult() } as never; } throw new Error("Unexpected fetchJson call"); }); mockedRemoveCronJobsForAgentWithBackup.mockImplementation(async () => { calls.push("removeCron"); return []; }); mockedRestoreCronJobs.mockImplementation(async () => { calls.push("restoreCron"); }); mockedDeleteGatewayAgent.mockImplementation(async () => { calls.push("deleteGatewayAgent"); return { removed: true, removedBindings: 0 }; }); await expect( deleteAgentViaStudio({ client: {} as never, agentId: "agent-1", fetchJson }) ).resolves.toEqual({ trashed: createTrashResult(), restored: null, }); expect(calls).toEqual(["trash", "removeCron", "deleteGatewayAgent"]); }); it("attempts_restore_when_remove_cron_fails_and_trash_moved_paths", async () => { const calls: string[] = []; const originalErr = new Error("boom"); const trash = createTrashResult({ trashDir: "/tmp/trash-2", moved: [{ from: "/a", to: "/b" }], }); const fetchJson: FetchJson = vi.fn(async (_input, init) => { if (init?.method === "POST") { calls.push("trash"); return { result: trash } as never; } if (init?.method === "PUT") { calls.push("restore:agent-1:/tmp/trash-2"); return { result: { restored: [] } } as never; } throw new Error("Unexpected fetchJson call"); }); mockedRemoveCronJobsForAgentWithBackup.mockImplementation(async () => { calls.push("removeCron"); throw originalErr; }); mockedRestoreCronJobs.mockImplementation(async () => { calls.push("restoreCron"); }); mockedDeleteGatewayAgent.mockImplementation(async () => { calls.push("deleteGatewayAgent"); return { removed: true, removedBindings: 0 }; }); await expect( deleteAgentViaStudio({ client: {} as never, agentId: "agent-1", fetchJson }) ).rejects.toBe(originalErr); expect(calls).toEqual(["trash", "removeCron", "restore:agent-1:/tmp/trash-2"]); expect(mockedRestoreCronJobs).not.toHaveBeenCalled(); expect(mockedDeleteGatewayAgent).not.toHaveBeenCalled(); }); it("attempts_cron_restore_then_state_restore_when_gateway_delete_fails_and_trash_moved_paths", async () => { const calls: string[] = []; const originalErr = new Error("boom"); const backups = [createCronRestoreInput("Job X", "agent-1")]; const fetchJson: FetchJson = vi.fn(async (_input, init) => { if (init?.method === "POST") { calls.push("trash"); return { result: createTrashResult({ trashDir: "/tmp/trash-3", moved: [{ from: "/a", to: "/b" }], }), } as never; } if (init?.method === "PUT") { calls.push("restore:agent-1:/tmp/trash-3"); return { result: { restored: [] } } as never; } throw new Error("Unexpected fetchJson call"); }); mockedRemoveCronJobsForAgentWithBackup.mockImplementation(async () => { calls.push("removeCron"); return backups; }); mockedRestoreCronJobs.mockImplementation(async () => { calls.push("restoreCron"); }); mockedDeleteGatewayAgent.mockImplementation(async () => { calls.push("deleteGatewayAgent"); throw originalErr; }); await expect( deleteAgentViaStudio({ client: {} as never, agentId: "agent-1", fetchJson }) ).rejects.toBe(originalErr); expect(calls).toEqual([ "trash", "removeCron", "deleteGatewayAgent", "restoreCron", "restore:agent-1:/tmp/trash-3", ]); expect(mockedRestoreCronJobs).toHaveBeenCalledWith(expect.anything(), backups); }); it("does_not_restore_when_trash_moved_is_empty", async () => { const originalErr = new Error("boom"); const methods: string[] = []; const fetchJson: FetchJson = vi.fn(async (_input, init) => { const method = init?.method ?? "GET"; methods.push(method); if (method === "POST") { return { result: createTrashResult({ moved: [] }) } as never; } if (method === "PUT") { throw new Error("restore should not be called"); } throw new Error("Unexpected fetchJson call"); }); mockedRemoveCronJobsForAgentWithBackup.mockImplementation(async () => { throw originalErr; }); mockedRestoreCronJobs.mockResolvedValue(undefined); mockedDeleteGatewayAgent.mockImplementation(async () => { return { removed: true, removedBindings: 0 }; }); await expect( deleteAgentViaStudio({ client: {} as never, agentId: "agent-1", fetchJson }) ).rejects.toBe(originalErr); expect(methods).toEqual(["POST"]); expect(mockedDeleteGatewayAgent).not.toHaveBeenCalled(); expect(mockedRestoreCronJobs).not.toHaveBeenCalled(); }); it("logs_cron_and_state_restore_failures_and_still_throws_original_error", async () => { const originalErr = new Error("boom"); const cronRestoreErr = new Error("cron-restore-failed"); const restoreErr = new Error("restore-failed"); const logError = vi.fn(); const backups = [createCronRestoreInput("Job Z", "agent-1")]; const fetchJson: FetchJson = vi.fn(async (_input, init) => { if (init?.method === "POST") { return { result: createTrashResult({ trashDir: "/tmp/trash-4", moved: [{ from: "/a", to: "/b" }], }), } as never; } if (init?.method === "PUT") { throw restoreErr; } throw new Error("Unexpected fetchJson call"); }); mockedRemoveCronJobsForAgentWithBackup.mockImplementation(async () => { return backups; }); mockedRestoreCronJobs.mockImplementation(async () => { throw cronRestoreErr; }); mockedDeleteGatewayAgent.mockImplementation(async () => { throw originalErr; }); await expect( deleteAgentViaStudio({ client: {} as never, agentId: "agent-1", fetchJson, logError, }) ).rejects.toBe(originalErr); expect(logError).toHaveBeenCalledTimes(2); expect(logError).toHaveBeenNthCalledWith( 1, "Failed to restore removed cron jobs.", cronRestoreErr ); expect(logError).toHaveBeenNthCalledWith(2, "Failed to restore trashed agent state.", restoreErr); }); it("fails_fast_when_agent_id_is_missing", async () => { const fetchJson: FetchJson = vi.fn(async () => { throw new Error("fetch should not be called"); }); await expect( deleteAgentViaStudio({ client: {} as never, agentId: " ", fetchJson }) ).rejects.toThrow("Agent id is required."); expect(fetchJson).not.toHaveBeenCalled(); expect(mockedRemoveCronJobsForAgentWithBackup).not.toHaveBeenCalled(); expect(mockedRestoreCronJobs).not.toHaveBeenCalled(); expect(mockedDeleteGatewayAgent).not.toHaveBeenCalled(); }); });