import { describe, expect, it, vi, type Mock } from "vitest"; import { ClientResponseError, createBobbinClient, type BobbinContext } from "$lib/api/client"; import { jsonGet, rawGet } from "$lib/api/_request"; const jsonResponse = (body: unknown, init: ResponseInit = {}): Response => new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" }, ...init }); const makeCtx = ( fetchMock: typeof globalThis.fetch, serviceUrl = "https://bobbin.test" ): BobbinContext => createBobbinClient({ serviceUrl, fetch: fetchMock }); const fetchedUrl = (mock: Mock, 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() .mockResolvedValue(jsonResponse({ ok: 1 })); await jsonGet(makeCtx(fetchMock), "sh.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() .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() .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() .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() .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().mockResolvedValue( new Response("502 Bad Gateway", { 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() .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", async (original) => ({ ...(await original()), mintServiceAuth: async (_agent: unknown, options: { aud: string; lxm: string }) => { calls.push(options); return "jwt"; } })); const { createBobbinClient: create } = await import("$lib/api/client"); const fetchMock = vi .fn() .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" } ]); }); });