feat(web): api client and display formatters
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0a540208e1
commit
0bb871cd19
4 files changed
+604
No files matched your search
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { ApiError, request } from "../src/api.ts";
|
||||
|
||||
function fakeFetch(res: Response | (() => never)) {
|
||||
const calls: { url: string; init: RequestInit }[] = [];
|
||||
const fn = (async (url: string, init: RequestInit) => {
|
||||
calls.push({ url, init });
|
||||
if (typeof res === "function") return res();
|
||||
return res.clone();
|
||||
}) as unknown as typeof fetch;
|
||||
return { fn, calls };
|
||||
}
|
||||
|
||||
describe("request", () => {
|
||||
test("throws an ApiError carrying code on a non-2xx JSON error", async () => {
|
||||
const { fn } = fakeFetch(
|
||||
new Response(JSON.stringify({ error: { code: "validation", message: "title: required" } }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
const err = await request("POST", "entries/quick", { body: { title: "" }, fetchImpl: fn }).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(ApiError);
|
||||
expect(err.code).toBe("validation");
|
||||
expect(err.status).toBe(400);
|
||||
expect(err.message).toBe("title: required");
|
||||
});
|
||||
|
||||
test("non-GET requests send the CSRF header, JSON and same-origin cookies", async () => {
|
||||
const { fn, calls } = fakeFetch(new Response(JSON.stringify({ id: "e1" }), { status: 201 }));
|
||||
const out = await request<{ id: string }>("POST", "entries/quick", { body: { title: "Dune" }, fetchImpl: fn });
|
||||
expect(out).toEqual({ id: "e1" });
|
||||
const { url, init } = calls[0]!;
|
||||
expect(url).toBe("/api/v1/entries/quick");
|
||||
const h = new Headers(init.headers);
|
||||
expect(h.get("x-requested-with")).toBe("sttracker");
|
||||
expect(h.get("content-type")).toBe("application/json");
|
||||
expect(init.credentials).toBe("same-origin");
|
||||
expect(init.body).toBe(JSON.stringify({ title: "Dune" }));
|
||||
});
|
||||
|
||||
test("login and register also send the CSRF header and JSON content type", async () => {
|
||||
const { fn, calls } = fakeFetch(new Response(JSON.stringify({ id: "u1" })));
|
||||
await request("POST", "auth/login", { body: { username: "a", password: "b" }, fetchImpl: fn });
|
||||
await request("POST", "history/h1/restore", { fetchImpl: fn });
|
||||
for (const c of calls) {
|
||||
const h = new Headers(c.init.headers);
|
||||
expect(h.get("x-requested-with")).toBe("sttracker");
|
||||
expect(h.get("content-type")).toBe("application/json");
|
||||
}
|
||||
});
|
||||
|
||||
test("multipart uploads send the CSRF header but let fetch set the content type", async () => {
|
||||
const { fn, calls } = fakeFetch(new Response(JSON.stringify({ id: "a1" }), { status: 201 }));
|
||||
const form = new FormData();
|
||||
form.set("entity", "entry");
|
||||
await request("POST", "attachments", { form, fetchImpl: fn });
|
||||
const h = new Headers(calls[0]!.init.headers);
|
||||
expect(h.get("x-requested-with")).toBe("sttracker");
|
||||
expect(h.get("content-type")).toBeNull();
|
||||
expect(calls[0]!.init.body).toBe(form);
|
||||
});
|
||||
|
||||
test("GET builds the query string and skips empty values", async () => {
|
||||
const { fn, calls } = fakeFetch(new Response(JSON.stringify({ items: [] })));
|
||||
await request("GET", "entries", { query: { q: "dune", status: undefined, draft: true, limit: 50 }, fetchImpl: fn });
|
||||
expect(calls[0]!.url).toBe("/api/v1/entries?q=dune&draft=true&limit=50");
|
||||
});
|
||||
|
||||
test("204 resolves to undefined", async () => {
|
||||
const { fn } = fakeFetch(new Response(null, { status: 204 }));
|
||||
expect(await request("DELETE", "engagements/x", { fetchImpl: fn })).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a non-JSON error still becomes an ApiError", async () => {
|
||||
const { fn } = fakeFetch(new Response("<html>Bad gateway</html>", { status: 502 }));
|
||||
const err = await request("GET", "me", { fetchImpl: fn }).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(ApiError);
|
||||
expect(err.code).toBe("http_502");
|
||||
expect(err.status).toBe(502);
|
||||
});
|
||||
|
||||
test("a network failure becomes an ApiError with code network", async () => {
|
||||
const { fn } = fakeFetch(() => {
|
||||
throw new TypeError("fetch failed");
|
||||
});
|
||||
const err = await request("GET", "me", { fetchImpl: fn }).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(ApiError);
|
||||
expect(err.code).toBe("network");
|
||||
expect(err.status).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user