fabro/apps/fabro-web/app/lib/api-client.test.ts
2026-07-31 13:23:35 -04:00

164 lines
4.3 KiB
TypeScript

import { afterEach, describe, expect, mock, test } from "bun:test";
import {
ApiError,
apiData,
extractRequestId,
fetchAllPages,
generatedAxios,
runArtifactsDownloadUrl,
stageArtifactDownloadUrl,
} from "./api-client";
afterEach(() => {
mock.restore();
delete (globalThis as { window?: unknown }).window;
});
function axiosFailure({
status,
statusText,
data,
headers = {},
}: {
status: number;
statusText?: string;
data?: unknown;
headers?: Record<string, string>;
}) {
return {
isAxiosError: true,
message: statusText ?? `HTTP ${status}`,
response: {
status,
statusText: statusText ?? "",
data,
headers,
},
};
}
describe("generated Axios adapter", () => {
test("uses same-origin requests with browser credentials", () => {
expect(generatedAxios.defaults.baseURL).toBe("");
expect(generatedAxios.defaults.withCredentials).toBe(true);
});
test("normalizes generated client failures into ApiError", async () => {
const body = {
errors: [{
status: "500",
title: "Internal",
detail: "Database is unavailable.",
request_id: "body-req",
}],
};
try {
await apiData(() =>
Promise.reject(
axiosFailure({
status: 500,
statusText: "Internal Server Error",
data: body,
headers: { "x-request-id": "header-req" },
}),
),
);
throw new Error("expected apiData to reject");
} catch (error) {
expect(error).toBeInstanceOf(ApiError);
expect(error).toMatchObject({
status: 500,
message: "Database is unavailable.",
requestId: "header-req",
body,
});
}
});
test("redirects normal authenticated 401 responses to login", async () => {
const location = { href: "" };
(globalThis as unknown as { window: { location: typeof location } }).window = {
location,
};
await expect(() =>
apiData(() => Promise.reject(axiosFailure({ status: 401 }))),
).toThrow(ApiError);
expect(location.href).toBe("/login");
});
test("can suppress login redirects for login and install calls", async () => {
const location = { href: "" };
(globalThis as unknown as { window: { location: typeof location } }).window = {
location,
};
await expect(() =>
apiData(
() => Promise.reject(axiosFailure({ status: 401 })),
{ redirectOnUnauthorized: false },
),
).toThrow(ApiError);
expect(location.href).toBe("");
});
});
describe("fetchAllPages", () => {
test("preserves first-page extras and stops at the page cap", async () => {
const warnMock = mock(() => {});
const originalWarn = console.warn;
console.warn = warnMock;
let calls = 0;
try {
const result = await fetchAllPages<{ id: string }, { columns: { id: string; name: string }[] }>(
"board runs",
async () => {
calls += 1;
return {
columns: [{ id: "running", name: "Running" }],
data: [{ id: `run-${calls}` }],
meta: { has_more: true },
};
},
);
expect(result.columns).toEqual([{ id: "running", name: "Running" }]);
expect(result.data).toHaveLength(50);
expect(result.meta.has_more).toBe(true);
expect(warnMock).toHaveBeenCalledTimes(1);
} finally {
console.warn = originalWarn;
}
});
});
describe("stageArtifactDownloadUrl", () => {
test("builds the escaped download href", () => {
expect(
stageArtifactDownloadUrl("run 1", "stage@1", "logs/output.txt", 2),
).toBe(
"/api/v1/runs/run%201/stages/stage%401/artifacts/download?filename=logs%2Foutput.txt&retry=2",
);
});
});
describe("runArtifactsDownloadUrl", () => {
test("builds the escaped ZIP download href", () => {
expect(runArtifactsDownloadUrl("run 1")).toBe(
"/api/v1/runs/run%201/artifacts/download",
);
});
});
describe("extractRequestId", () => {
test("supports top-level, error-level, and detail-embedded request ids", () => {
expect(extractRequestId({ request_id: "top" })).toBe("top");
expect(extractRequestId({ errors: [{ request_id: "nested" }] })).toBe("nested");
expect(extractRequestId({ errors: [{ detail: "Request ID: req-detail" }] })).toBe("req-detail");
});
});