core/workbench-ui/src/api/client.test.ts

104 lines
3.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch, fetchProposalDetail, fetchProposals, WorkbenchApiError } from "./client";
import { proposalDetail, proposalSummaries } from "./__fixtures__/proposals";
describe("apiFetch", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("parses ok:true envelope and returns data", async () => {
const mockData = { backend: "numpy", git_revision: "abc123" };
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({ ok: true, generated_at: "2026-01-01T00:00:00Z", data: mockData }),
}),
);
const result = await apiFetch("/runtime/status");
expect(result).toEqual(mockData);
});
it("throws WorkbenchApiError on ok:false envelope", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({
ok: false,
generated_at: "2026-01-01T00:00:00Z",
error: { code: "runtime_unavailable", message: "Server is offline" },
}),
}),
);
await expect(apiFetch("/runtime/status")).rejects.toThrow(WorkbenchApiError);
await expect(apiFetch("/runtime/status")).rejects.toMatchObject({
code: "runtime_unavailable",
message: "Server is offline",
});
});
it("AbortController fires on timeout (never-resolving fetch)", async () => {
vi.useFakeTimers();
const abortMock = vi.fn();
const originalAbortController = global.AbortController;
// Replace AbortController to track abort calls
const mockController = {
signal: { aborted: false } as AbortSignal,
abort: abortMock,
};
vi.stubGlobal("AbortController", vi.fn(() => mockController));
// fetch never resolves
vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {})));
const fetchPromise = apiFetch("/runtime/status");
// Advance past the 5s timeout
await vi.advanceTimersByTimeAsync(6000);
expect(abortMock).toHaveBeenCalled();
// Clean up
global.AbortController = originalAbortController;
vi.useRealTimers();
// fetchPromise will never resolve but that's expected
fetchPromise.catch(() => {});
});
});
describe("proposal fetchers", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("unwraps the /proposals items envelope and applies a state filter locally", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({
ok: true,
generated_at: "now",
data: { items: proposalSummaries },
}),
}),
);
await expect(fetchProposals("accepted")).resolves.toEqual([proposalSummaries[1]]);
});
it("fetches proposal detail without mutating the proposal log", async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({ ok: true, generated_at: "now", data: proposalDetail }),
});
vi.stubGlobal("fetch", fetchMock);
await expect(fetchProposalDetail("proposal/detail id")).resolves.toEqual(proposalDetail);
expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:8765/proposals/proposal%2Fdetail%20id");
const init = fetchMock.mock.calls[0][1] as RequestInit;
expect(init.method).toBeUndefined();
});
});