Files
claw3d/tests/unit/deleteAgentOperation.test.ts
Luke The Dev 4fa4f13558 First Release of Claw3D (#11)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
2026-03-19 23:14:04 -05:00

278 lines
8.8 KiB
TypeScript

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<typeof import("@/lib/cron/types")>("@/lib/cron/types");
return {
...actual,
removeCronJobsForAgentWithBackup: vi.fn(),
restoreCronJobs: vi.fn(),
};
});
vi.mock("@/lib/gateway/agentConfig", async () => {
const actual = await vi.importActual<typeof import("@/lib/gateway/agentConfig")>(
"@/lib/gateway/agentConfig"
);
return { ...actual, deleteGatewayAgent: vi.fn() };
});
type FetchJson = <T>(input: RequestInfo | URL, init?: RequestInit) => Promise<T>;
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();
});
});