import { describe, expect, it, vi, type Mock } from "vitest";
import { ClientResponseError, createBobbinClient, type BobbinContext } from "#lib/api/client.ts";
import { jsonGet, rawGet } from "#lib/api/_request.ts";
import type { Fetch } from "#lib/fetch.ts";
const jsonResponse = (body: unknown, init: ResponseInit = {}): Response =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
...init
});
const makeCtx = (fetchMock: Fetch, serviceUrl = "https://bobbin.test"): BobbinContext =>
createBobbinClient({ serviceUrl, fetch: fetchMock });
const fetchedUrl = (mock: Mock<Fetch>, n = 0): URL => new URL(String(mock.mock.calls[n][0]));
describe("createBobbinClient", () => {
it("normalizes trailing slashes off the service url", () => {
const ctx = makeCtx(vi.fn(), "https://bobbin.test///");
expect(ctx.serviceUrl).toBe("https://bobbin.test");
});
});
describe("jsonGet URL construction", () => {
it("appends an array param as repeated keys", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(jsonResponse({ ok: 1 }));
await jsonGet(makeCtx(fetchMock), "org.tangled.repo.getRepos", { repos: ["a", "b"] });
const url = fetchedUrl(fetchMock);
expect(url.searchParams.getAll("repos")).toEqual(["a", "b"]);
expect(url.search).toBe("?repos=a&repos=b");
});
it("omits undefined params entirely", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(jsonResponse({ ok: 1 }));
await jsonGet(makeCtx(fetchMock), "sh.tangled.x", { keep: "yes", drop: undefined });
const url = fetchedUrl(fetchMock);
expect(url.searchParams.has("drop")).toBe(false);
expect(url.searchParams.get("keep")).toBe("yes");
});
it("resolves the query against the normalized origin", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(jsonResponse({ ok: 1 }));
await jsonGet(makeCtx(fetchMock, "https://bobbin.test/"), "sh.tangled.x", { a: "1" });
expect(fetchedUrl(fetchMock).href).toBe("https://bobbin.test/xrpc/sh.tangled.x?a=1");
});
});
describe("jsonGet request headers & signal", () => {
it("sends accept: application/json and merges caller headers + signal", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(jsonResponse({ ok: 1 }));
const controller = new AbortController();
await jsonGet(makeCtx(fetchMock), "sh.tangled.x", undefined, {
headers: { "x-custom": "1" },
signal: controller.signal
});
const init = fetchMock.mock.calls[0][1] as RequestInit;
expect(init.headers).toMatchObject({ accept: "application/json", "x-custom": "1" });
expect(init.signal).toBe(controller.signal);
});
});
describe("jsonGet responses", () => {
it("throws ClientResponseError carrying status + error/description for a JSON error body", async () => {
const fetchMock = vi
.fn<Fetch>()
.mockResolvedValue(
jsonResponse({ error: "RecordNotFound", message: "no such repo" }, { status: 404 })
);
const err = await jsonGet(makeCtx(fetchMock), "sh.tangled.x").catch((e: unknown) => e);
expect(err).toBeInstanceOf(ClientResponseError);
const cre = err as ClientResponseError;
expect(cre.status).toBe(404);
expect(cre.error).toBe("RecordNotFound");
expect(cre.description).toBe("no such repo");
});
it("falls back to the status line for a non-JSON error body (does not hang)", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(
new Response("<html>502 Bad Gateway</html>", {
status: 502,
statusText: "Bad Gateway"
})
);
const err = await jsonGet(makeCtx(fetchMock), "sh.tangled.x").catch((e: unknown) => e);
expect(err).toBeInstanceOf(ClientResponseError);
const cre = err as ClientResponseError;
expect(cre.status).toBe(502);
expect(cre.error).toBe("XRPCError");
expect(cre.description).toBe("Bad Gateway");
});
});
describe("rawGet", () => {
it("throws ClientResponseError on a non-2xx status", async () => {
const fetchMock = vi
.fn<Fetch>()
.mockResolvedValue(
jsonResponse({ error: "UpstreamFailed", message: "knot down" }, { status: 502 })
);
const err = await rawGet(makeCtx(fetchMock), "sh.tangled.repo.archive").catch(
(e: unknown) => e
);
expect(err).toBeInstanceOf(ClientResponseError);
expect((err as ClientResponseError).status).toBe(502);
});
});
describe("service auth aud", () => {
const minted = async (nsid: string) => {
vi.resetModules();
const calls: { aud: string; lxm: string }[] = [];
vi.doMock("#lib/auth/agent.ts", async (original) => ({
...(await original<typeof import("#lib/auth/agent.ts")>()),
mintServiceAuth: async (_agent: unknown, options: { aud: string; lxm: string }) => {
calls.push(options);
return "jwt";
}
}));
const { createBobbinClient: create } = await import("#lib/api/client.ts");
const fetchMock = vi.fn<Fetch>().mockResolvedValue(jsonResponse({ ok: 1 }));
const ctx = create({
serviceUrl: "https://bobbin.test",
fetch: fetchMock,
agent: { sub: "did:plc:limpet" } as never
});
await ctx.fetch(`https://bobbin.test/xrpc/${nsid}`);
return { calls, headers: new Headers(fetchMock.mock.calls[0][1]?.headers) };
};
it("pocket preferences are minted for the app, not for whoever serves them", async () => {
const { calls, headers } = await minted("com.bad-example.pocket.getPreferences");
expect(calls).toEqual([
{ aud: "did:web:tangled.org", lxm: "com.bad-example.pocket.getPreferences" }
]);
expect(headers.get("authorization")).toBe("Bearer jwt");
});
it("putting them lands on the same aud", async () => {
const { calls } = await minted("com.bad-example.pocket.putPreferences");
expect(calls[0].aud).toBe("did:web:tangled.org");
});
it("every other method is still minted for the service it is sent to", async () => {
const { calls } = await minted("org.tangled.temp.search.searchCode");
expect(calls).toEqual([
{ aud: "did:web:bobbin.test", lxm: "org.tangled.temp.search.searchCode" }
]);
});
});