Files
kandev/apps/web/lib/api/client.test.ts
T

276 lines
8.5 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiError, fetchBlob, fetchJson, setOnUnauthorized } from "./client";
const reloadMocks = vi.hoisted(() => ({
signalBackendReloadRequired: vi.fn(),
}));
vi.mock("@/lib/platform/backend-reload-coordinator", () => ({
signalBackendReloadRequired: (...args: unknown[]) =>
reloadMocks.signalBackendReloadRequired(...args),
}));
const interlockToken = "replayable-per-boot-value";
const BACKEND_URL = "http://backend.test";
describe("fetchJson", () => {
afterEach(() => {
setOnUnauthorized(null);
vi.unstubAllGlobals();
delete (window as unknown as { __KANDEV_BOOT_PAYLOAD__?: unknown }).__KANDEV_BOOT_PAYLOAD__;
});
it("attaches the replayable interim settings interlock to mutations", async () => {
(window as unknown as { __KANDEV_BOOT_PAYLOAD__?: unknown }).__KANDEV_BOOT_PAYLOAD__ = {
interimSettingsInterlockToken: interlockToken,
};
const fetcher = vi.fn().mockResolvedValue(new Response("{}", { status: 200 }));
vi.stubGlobal("fetch", fetcher);
await fetchJson("/api/v1/agents", {
baseUrl: BACKEND_URL,
init: { method: "POST", body: "{}" },
});
expect(fetcher).toHaveBeenCalledWith(`${BACKEND_URL}/api/v1/agents`, expect.any(Object));
expect(
new Headers(fetcher.mock.calls[0][1]?.headers).get("X-Kandev-Interim-Settings-Interlock"),
).toBe(interlockToken);
});
it("replaces a lowercase content type without losing mutation headers", async () => {
(window as unknown as { __KANDEV_BOOT_PAYLOAD__?: unknown }).__KANDEV_BOOT_PAYLOAD__ = {
interimSettingsInterlockToken: interlockToken,
};
const fetcher = vi.fn().mockResolvedValue(new Response("{}", { status: 200 }));
vi.stubGlobal("fetch", fetcher);
await fetchJson("/api/v1/agents", {
baseUrl: BACKEND_URL,
init: {
method: "POST",
body: "{}",
headers: { "content-type": "text/plain", "X-Caller-Header": "preserved" },
},
});
const headers = fetcher.mock.calls[0][1]?.headers;
const normalizedHeaders = new Headers(headers);
expect({
isHeaders: headers instanceof Headers,
contentType: normalizedHeaders.get("content-type"),
contentTypeCount: [...normalizedHeaders.entries()].filter(
([name]) => name.toLowerCase() === "content-type",
).length,
callerHeader: normalizedHeaders.get("X-Caller-Header"),
interlock: normalizedHeaders.get("X-Kandev-Interim-Settings-Interlock"),
}).toEqual({
isHeaders: true,
contentType: "application/json",
contentTypeCount: 1,
callerHeader: "preserved",
interlock: interlockToken,
});
});
it("notifies the app for a Kandev session challenge", async () => {
const unauthorized = vi.fn();
setOnUnauthorized(unauthorized);
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: "authentication required" }), {
status: 401,
headers: {
"Content-Type": "application/json",
"WWW-Authenticate": "Bearer",
},
}),
),
);
await expect(
fetchJson("/api/v1/workspaces", { baseUrl: "http://kandev.test" }),
).rejects.toBeInstanceOf(ApiError);
expect(unauthorized).toHaveBeenCalledOnce();
});
it("keeps an unchallenged provider 401 in the calling integration", async () => {
const unauthorized = vi.fn();
setOnUnauthorized(unauthorized);
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: "GitHub credentials are invalid" }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
),
);
const request = fetchJson("/api/v1/github/user/prs", { baseUrl: "http://kandev.test" });
await expect(request).rejects.toMatchObject({
name: "ApiError",
status: 401,
message: "GitHub credentials are invalid",
});
expect(unauthorized).not.toHaveBeenCalled();
});
});
describe("fetchJson response parsing", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("preserves the HTTP status when a successful response is not JSON", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response("not-json", { status: 200, statusText: "OK" })),
);
await expect(fetchJson("/ready", { baseUrl: BACKEND_URL })).rejects.toMatchObject({
name: "ApiError",
status: 200,
});
});
});
describe("ApiError response classification", () => {
afterEach(() => {
vi.unstubAllGlobals();
reloadMocks.signalBackendReloadRequired.mockReset();
});
it("preserves machine-readable error codes from the backend", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValue(
new Response(
JSON.stringify({ error: "invalid branch policy", error_code: "branch_policy_stale" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
),
),
);
await expect(fetchJson("/api/v1/tasks", { baseUrl: BACKEND_URL })).rejects.toMatchObject({
name: "ApiError",
errorCode: "branch_policy_stale",
});
});
it("handles only the stale settings interlock response", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
error: "interim settings interlock required",
error_code: "interim_settings_interlock_required",
}),
{ status: 403, headers: { "Content-Type": "application/json" } },
),
),
);
await expect(
fetchJson("/api/v1/agent-profiles/p1", { baseUrl: BACKEND_URL }),
).rejects.toMatchObject({
name: "ApiError",
status: 403,
errorCode: "interim_settings_interlock_required",
handled: true,
});
expect(reloadMocks.signalBackendReloadRequired).toHaveBeenCalledWith(
"settings_interlock_rejected",
);
});
it("leaves other forbidden responses unhandled", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: "forbidden" }), {
status: 403,
headers: { "Content-Type": "application/json" },
}),
),
);
await expect(
fetchJson("/api/v1/agent-profiles/p1", { baseUrl: BACKEND_URL }),
).rejects.toMatchObject({
name: "ApiError",
status: 403,
handled: false,
});
expect(reloadMocks.signalBackendReloadRequired).not.toHaveBeenCalled();
});
});
describe("fetchBlob", () => {
afterEach(() => {
setOnUnauthorized(null);
vi.unstubAllGlobals();
});
it("returns the response body as a Blob on success", async () => {
const body = new Blob(["zip-bytes"], { type: "application/zip" });
const fetcher = vi.fn().mockResolvedValue(new Response(body, { status: 200 }));
vi.stubGlobal("fetch", fetcher);
const result = await fetchBlob("/api/v1/workspaces/ws-1/automations/export/zip", {
baseUrl: BACKEND_URL,
});
expect(fetcher).toHaveBeenCalledWith(
`${BACKEND_URL}/api/v1/workspaces/ws-1/automations/export/zip`,
expect.any(Object),
);
expect(result).toBeInstanceOf(Blob);
expect(await result.text()).toBe("zip-bytes");
});
it("throws ApiError and never returns a Blob on a non-2xx status", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: "workspace not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
}),
),
);
await expect(
fetchBlob("/api/v1/workspaces/ws-missing/automations/export/zip", {
baseUrl: BACKEND_URL,
}),
).rejects.toMatchObject({ name: "ApiError", status: 404, message: "workspace not found" });
});
it("notifies the app for a Kandev session challenge, like fetchJson", async () => {
const unauthorized = vi.fn();
setOnUnauthorized(unauthorized);
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: "authentication required" }), {
status: 401,
headers: { "Content-Type": "application/json", "WWW-Authenticate": "Bearer" },
}),
),
);
await expect(
fetchBlob("/api/v1/workspaces/ws-1/automations/export/zip", {
baseUrl: BACKEND_URL,
}),
).rejects.toBeInstanceOf(ApiError);
expect(unauthorized).toHaveBeenCalledOnce();
});
});