Files
kandev/apps/web/lib/api/domains/system-api.test.ts
T
2026-07-30 10:44:51 +01:00

419 lines
14 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Pin the backend config so URL assertions don't depend on the environment.
vi.mock("@/lib/config", () => ({
getBackendConfig: () => ({ apiBaseUrl: "http://api.test" }),
}));
import {
fetchSystemInfo,
fetchDiskUsage,
refreshDiskUsage,
fetchDatabaseStats,
vacuumDatabase,
optimizeDatabase,
resetDatabase,
fetchBackups,
createBackup,
restoreBackup,
deleteBackup,
buildBackupDownloadUrl,
fetchLogFiles,
fetchLogTail,
buildLogDownloadUrl,
fetchUpdates,
checkUpdates,
applyUpdate,
fetchSystemJob,
fetchRestartCapability,
requestRestart,
adoptStorageGoCache,
analyzeStorage,
deleteStorageQuarantine,
fetchStorageOverview,
fetchStorageQuarantine,
fetchStorageRuns,
purgeStorageQuarantine,
restoreStorageQuarantine,
runStorageMaintenance,
saveStorageSettings,
} from "./system-api";
const BASE = "http://api.test/api/v1/system";
type FetchInput = Parameters<typeof fetch>[0];
type FetchInit = Parameters<typeof fetch>[1];
const fetchSpy = vi.fn<(...args: [FetchInput, FetchInit?]) => Promise<Response>>();
beforeEach(() => {
fetchSpy.mockReset();
vi.stubGlobal("fetch", fetchSpy);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function jsonResponse(body: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
function lastCall(): { url: string; init: FetchInit | undefined } {
const call = fetchSpy.mock.calls.at(-1);
if (!call) throw new Error("expected fetch to have been called");
return { url: String(call[0]), init: call[1] };
}
function method(): string {
return (lastCall().init?.method ?? "GET").toUpperCase();
}
describe("fetchSystemInfo", () => {
it("GETs /info and returns the parsed body", async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse({
version: "1.2.3",
commit: "abc",
build_time: "2026-01-01T00:00:00Z",
go_version: "go1.24",
os: "darwin",
arch: "arm64",
}),
);
const info = await fetchSystemInfo();
expect(lastCall().url).toBe(`${BASE}/info`);
expect(method()).toBe("GET");
expect(info.version).toBe("1.2.3");
});
});
describe("fetchDiskUsage", () => {
it("GETs /disk-usage", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ data: null, computing: true }));
const res = await fetchDiskUsage();
expect(lastCall().url).toBe(`${BASE}/disk-usage`);
expect(method()).toBe("GET");
expect(res.computing).toBe(true);
});
});
describe("refreshDiskUsage", () => {
it("POSTs /disk-usage/refresh and returns the job id", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ job_id: "j-1" }));
const res = await refreshDiskUsage();
expect(lastCall().url).toBe(`${BASE}/disk-usage/refresh`);
expect(method()).toBe("POST");
expect(res.job_id).toBe("j-1");
});
});
describe("fetchDatabaseStats", () => {
it("GETs /database", async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse({
driver: "sqlite",
path: "/data/kandev.db",
size_bytes: 1,
wal_size_bytes: 0,
schema_version: "1",
last_backup_at: "",
}),
);
const stats = await fetchDatabaseStats();
expect(lastCall().url).toBe(`${BASE}/database`);
expect(method()).toBe("GET");
expect(stats.driver).toBe("sqlite");
expect(stats.path).toBe("/data/kandev.db");
});
});
describe("vacuumDatabase / optimizeDatabase", () => {
it("vacuum POSTs /database/vacuum", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ job_id: "v-1" }));
await vacuumDatabase();
expect(lastCall().url).toBe(`${BASE}/database/vacuum`);
expect(method()).toBe("POST");
});
it("optimize POSTs /database/optimize", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ job_id: "o-1" }));
await optimizeDatabase();
expect(lastCall().url).toBe(`${BASE}/database/optimize`);
expect(method()).toBe("POST");
});
});
describe("resetDatabase", () => {
it("POSTs /database/reset with the confirm payload", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ job_id: "r-1" }));
await resetDatabase("RESET");
const { url, init } = lastCall();
expect(url).toBe(`${BASE}/database/reset`);
expect((init?.method ?? "").toUpperCase()).toBe("POST");
expect(init?.body).toBe(JSON.stringify({ confirm: "RESET" }));
});
});
describe("fetchBackups / createBackup / restoreBackup / deleteBackup", () => {
it("fetchBackups GETs /backups", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse([]));
const items = await fetchBackups();
expect(lastCall().url).toBe(`${BASE}/backups`);
expect(method()).toBe("GET");
expect(items).toEqual([]);
});
it("createBackup POSTs /backups", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ job_id: "b-1" }));
await createBackup();
expect(lastCall().url).toBe(`${BASE}/backups`);
expect(method()).toBe("POST");
});
it("restoreBackup POSTs /backups/:name/restore with body and url-encodes the name", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ job_id: "rs-1" }));
await restoreBackup("manual 1.db", "RESTORE");
const { url, init } = lastCall();
expect(url).toBe(`${BASE}/backups/manual%201.db/restore`);
expect((init?.method ?? "").toUpperCase()).toBe("POST");
expect(init?.body).toBe(JSON.stringify({ confirm: "RESTORE" }));
});
it("deleteBackup DELETEs /backups/:name", async () => {
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 204 }));
await deleteBackup("manual-1.db");
const { url, init } = lastCall();
expect(url).toBe(`${BASE}/backups/manual-1.db`);
expect((init?.method ?? "").toUpperCase()).toBe("DELETE");
});
it("buildBackupDownloadUrl returns the absolute download URL", () => {
expect(buildBackupDownloadUrl("manual 1.db")).toBe(`${BASE}/backups/manual%201.db/download`);
});
});
describe("logs", () => {
it("fetchLogFiles GETs /logs", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse([]));
await fetchLogFiles();
expect(lastCall().url).toBe(`${BASE}/logs`);
expect(method()).toBe("GET");
});
it("fetchLogTail GETs /logs/tail with default n=1000 and no-store cache", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ lines: ["a", "b"] }));
const res = await fetchLogTail();
const { url, init } = lastCall();
expect(url).toBe(`${BASE}/logs/tail?n=1000`);
expect((init?.method ?? "GET").toUpperCase()).toBe("GET");
expect(init?.cache).toBe("no-store");
expect(res.lines).toEqual(["a", "b"]);
});
it("fetchLogTail uses the explicit n parameter when provided", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ lines: [] }));
await fetchLogTail(250);
expect(lastCall().url).toBe(`${BASE}/logs/tail?n=250`);
});
it("buildLogDownloadUrl returns the absolute download URL", () => {
expect(buildLogDownloadUrl("kandev.log.1")).toBe(`${BASE}/logs/kandev.log.1/download`);
});
});
describe("fetchSystemJob", () => {
it("GETs /jobs/:id and returns the job payload", async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse({
id: "job-abc",
kind: "vacuum",
state: "succeeded",
message: "done",
started_at: "2026-05-18T00:00:00Z",
}),
);
const job = await fetchSystemJob("job-abc");
expect(lastCall().url).toBe(`${BASE}/jobs/job-abc`);
expect(method()).toBe("GET");
expect(job.id).toBe("job-abc");
expect(job.state).toBe("succeeded");
});
});
describe("updates", () => {
it("fetchUpdates GETs /updates", async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse({
current: "1.0.0",
latest: "1.0.1",
latest_url: "https://gh/r",
latest_checked_at: "2026-05-18T00:00:00Z",
update_available: true,
}),
);
const res = await fetchUpdates();
expect(lastCall().url).toBe(`${BASE}/updates`);
expect(method()).toBe("GET");
expect(res.update_available).toBe(true);
});
it("checkUpdates POSTs /updates/check", async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse({
current: "1.0.0",
latest: "1.0.0",
latest_url: "",
latest_checked_at: "2026-05-18T00:00:00Z",
update_available: false,
}),
);
await checkUpdates();
expect(lastCall().url).toBe(`${BASE}/updates/check`);
expect(method()).toBe("POST");
});
it("applyUpdate POSTs /updates/apply with confirmation", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ job_id: "self-update-1" }));
const res = await applyUpdate("UPDATE");
const { url, init } = lastCall();
expect(url).toBe(`${BASE}/updates/apply`);
expect((init?.method ?? "").toUpperCase()).toBe("POST");
expect(init?.body).toBe(JSON.stringify({ confirm: "UPDATE" }));
expect(res.job_id).toBe("self-update-1");
});
});
describe("restart", () => {
it("fetchRestartCapability GETs /restart-capability", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ supported: false, mode: "manual" }));
const res = await fetchRestartCapability();
expect(lastCall().url).toBe(`${BASE}/restart-capability`);
expect(method()).toBe("GET");
expect(res.supported).toBe(false);
});
it("fetchRestartCapability always bypasses cache", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ supported: true, mode: "supervisor" }));
await fetchRestartCapability({ cache: "force-cache" });
expect(lastCall().init?.cache).toBe("no-store");
});
it("requestRestart POSTs /restart", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ accepted: true, message: "Restarting" }));
const res = await requestRestart({ init: { method: "GET" } });
expect(lastCall().url).toBe(`${BASE}/restart`);
expect(method()).toBe("POST");
expect(res.accepted).toBe(true);
});
});
describe("storage maintenance", () => {
const settings = {
enabled: false,
check_interval_hours: 24,
idle_for_minutes: 10,
orphan_grace_hours: 168,
quarantine_retention_hours: 168,
workspaces: { enabled: true },
kandev_containers: { enabled: true },
go_cache: { enabled: false, max_bytes: 16106127360, adopted_path: "" },
docker: {
dedicated_daemon_acknowledged: false,
build_cache_enabled: false,
build_cache_keep_bytes: 10737418240,
build_cache_unused_hours: 168,
unused_images_enabled: false,
unused_images_hours: 168,
},
};
it("loads overview and list resources without caching", async () => {
fetchSpy
.mockResolvedValueOnce(
jsonResponse({
settings,
summary: {},
capabilities: {},
analyzed_at: "2026-07-23T12:00:00Z",
last_run: null,
}),
)
.mockResolvedValueOnce(jsonResponse({ runs: [{ id: "run-1" }] }))
.mockResolvedValueOnce(jsonResponse({ entries: [{ id: "entry-1" }] }));
await fetchStorageOverview();
expect(lastCall().url).toBe(`${BASE}/storage`);
expect(lastCall().init?.cache).toBe("no-store");
expect((await fetchStorageRuns())[0]?.id).toBe("run-1");
expect(lastCall().url).toBe(`${BASE}/storage/runs?limit=20`);
expect((await fetchStorageQuarantine())[0]?.id).toBe("entry-1");
});
it("saves dedicated Docker acknowledgement with its confirmation", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ settings }));
await saveStorageSettings(settings, "DEDICATED");
expect(lastCall().url).toBe(`${BASE}/storage/settings`);
expect(method()).toBe("PATCH");
expect(JSON.parse(String(lastCall().init?.body))).toEqual({
settings,
confirmations: { dedicated_docker: "DEDICATED" },
});
});
it("uses fixed confirmations for Go-cache adoption and permanent deletion", async () => {
fetchSpy
.mockResolvedValueOnce(jsonResponse({ settings, capabilities: {} }))
.mockResolvedValueOnce(jsonResponse({ job_id: "delete-job" }));
await adoptStorageGoCache("/root/.cache/go-build");
expect(JSON.parse(String(lastCall().init?.body))).toEqual({
path: "/root/.cache/go-build",
confirm: "ADOPT",
});
const response = await deleteStorageQuarantine("entry/1");
expect(lastCall().url).toBe(`${BASE}/storage/quarantine/entry%2F1`);
expect(method()).toBe("DELETE");
expect(JSON.parse(String(lastCall().init?.body))).toEqual({ confirm: "DELETE" });
expect(response.job_id).toBe("delete-job");
});
it("uses typed confirmations for bulk quarantine purge", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ job_id: "eligible-job" }));
await purgeStorageQuarantine("eligible");
expect(lastCall().url).toBe(`${BASE}/storage/quarantine`);
expect(method()).toBe("DELETE");
expect(JSON.parse(String(lastCall().init?.body))).toEqual({
scope: "eligible",
confirm: "DELETE ELIGIBLE",
});
fetchSpy.mockResolvedValueOnce(jsonResponse({ job_id: "all-job" }));
await purgeStorageQuarantine("all");
expect(JSON.parse(String(lastCall().init?.body))).toEqual({
scope: "all",
confirm: "DELETE ALL NOW",
});
});
it("starts analysis, selected cleanup, and restore operations", async () => {
fetchSpy
.mockResolvedValueOnce(jsonResponse({ job_id: "analysis" }))
.mockResolvedValueOnce(jsonResponse({ job_id: "cleanup" }));
expect((await analyzeStorage()).job_id).toBe("analysis");
expect((await runStorageMaintenance(["workspaces"])).job_id).toBe("cleanup");
expect(JSON.parse(String(lastCall().init?.body))).toEqual({ resources: ["workspaces"] });
fetchSpy.mockResolvedValueOnce(jsonResponse({ job_id: "forced-cleanup" }));
expect((await runStorageMaintenance(["workspaces"], true)).job_id).toBe("forced-cleanup");
expect(JSON.parse(String(lastCall().init?.body))).toEqual({
resources: ["workspaces"],
force: true,
});
fetchSpy.mockResolvedValueOnce(jsonResponse({ entry: { id: "restored" } }));
expect((await restoreStorageQuarantine("entry-1")).id).toBe("restored");
});
});