Files
kandev/apps/web/lib/api/domains/system-api.test.ts
T
José Almeida 041a19f060 feat: service-managed UI self-update (#1210)
* feat: add service-managed self-update

* chore: add temporary self-update test scripts

* chore: simplify temporary script markdown fences

* chore: run frontend in temporary self-update scripts

* fix: show self-update job progress after apply

* chore: add real npm self-update test script

* fix: isolate npm self-update prefix

* fix: detect npm service installs from bin shim

* chore: reset service before real npm update test

* chore: expand real npm status diagnostics

* fix: preserve service home during self-update

* fix(cli): tee self-update helper output to a log file

* fix(cli): avoid launchd bootstrap race when reloading the macOS service

* fix(backend): run macOS self-update helper once via transient launchd plist

* fix(web): show self-update progress and lock the apply button until the update lands

* fix(cli): reinstall via the kandev wrapper on Homebrew self-update

* chore: remove temp self-update test scripts

* fix(web): drop unused apply path from useUpdates hook

* fix(backend): snapshot install state once in apply preflight

* docs: align self-update docs with transient LaunchAgent flow

* chore(release): run cli bundle from apps workspace root

* fix(backend): guard against concurrent self-update applies

* fix(backend): check scheme and helper paths in self-update apply

* fix(backend): drop duplicate binary name from npx manual command

* fix(cli): allow --help for service self-update without --intent

* fix(cli): harden self-update dir perms and darwin uid resolution

* fix(web): set required POST fields last in applyUpdate

* docs: use JSON body notation for the apply endpoint

* fix(backend): expire self-update guard so a stuck helper cannot wedge apply

* refactor(cli): collapse redundant writeSync branch in self-update log
2026-06-01 17:49:15 +01:00

276 lines
8.6 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,
} 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<[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({
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.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");
});
});