feat(web): api client and display formatters

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 21:25:41 +01:00
1 parent 0a540208e1
commit 0bb871cd19
4 files changed
+604

No files matched your search

+92
View File
@@ -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);
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test";
import { formatDateRange, formatPartialDate, formatRating, parsePartialDate, rollupLabel } from "../src/format.ts";
describe("formatRating", () => {
test("5 stars with halves", () => {
expect(formatRating(90, "5half")).toBe("★★★★½");
expect(formatRating(100, "5half")).toBe("★★★★★");
expect(formatRating(80, "5half")).toBe("★★★★☆");
expect(formatRating(0, "5half")).toBe("☆☆☆☆☆");
});
test("null is empty on every scale", () => {
expect(formatRating(null, "5half")).toBe("");
expect(formatRating(null, "10")).toBe("");
expect(formatRating(undefined, "100")).toBe("");
});
test("whole stars, 10 and 100 point scales", () => {
expect(formatRating(90, "5")).toBe("★★★★★");
expect(formatRating(70, "5")).toBe("★★★★☆");
expect(formatRating(90, "10")).toBe("9/10");
expect(formatRating(85, "10")).toBe("8.5/10");
expect(formatRating(73, "100")).toBe("73/100");
});
});
describe("formatPartialDate", () => {
test("renders by precision", () => {
expect(formatPartialDate({ value: "2025-03", precision: "month" })).toBe("Mar 2025");
expect(formatPartialDate({ value: "2019", precision: "year" })).toBe("2019");
expect(formatPartialDate({ value: "2026-09-14", precision: "day" })).toBe("14 Sep 2026");
expect(formatPartialDate({ value: "2026-09-04T23:30:00-05:00", precision: "day" })).toBe("4 Sep 2026");
expect(formatPartialDate({ value: "", precision: "unknown" })).toBe("");
expect(formatPartialDate(null)).toBe("");
});
});
describe("formatDateRange", () => {
test("flags dates that are not exact", () => {
expect(formatDateRange(null, { value: "2025-03", precision: "month" })).toBe("Mar 2025 (no exact date)");
expect(formatDateRange({ value: "2026-09-02", precision: "day" }, { value: "2026-09-14", precision: "day" })).toBe(
"2 Sep – 14 Sep 2026",
);
expect(formatDateRange({ value: "2025-12-30", precision: "day" }, { value: "2026-01-02", precision: "day" })).toBe(
"30 Dec 2025 – 2 Jan 2026",
);
expect(formatDateRange({ value: "2026-09-02", precision: "day" }, null)).toBe("Started 2 Sep 2026");
expect(formatDateRange(null, null)).toBe("No dates");
});
});
describe("parsePartialDate", () => {
test("accepts year, month and day, rejects the rest", () => {
expect(parsePartialDate("2019")).toEqual({ value: "2019", precision: "year" });
expect(parsePartialDate(" 2025-03 ")).toEqual({ value: "2025-03", precision: "month" });
expect(parsePartialDate("2024-02-29")).toEqual({ value: "2024-02-29", precision: "day" });
expect(parsePartialDate("2023-02-29")).toBeNull();
expect(parsePartialDate("03/04/2019")).toBeNull();
expect(parsePartialDate("")).toBeNull();
});
});
describe("rollupLabel", () => {
test("children roll up as 'season 3 of 5'", () => {
expect(rollupLabel("show", { total: 5, completed: 3 })).toBe("season 3 of 5");
expect(rollupLabel("show", { total: 5, completed: 0 })).toBe("5 seasons");
expect(rollupLabel("podcast", { total: 200, completed: 200 })).toBe("all 200 episodes");
expect(rollupLabel("book", null)).toBeNull();
});
});