import { describe, expect, it, vi, type Mock } from "vitest"; import { ClientResponseError, createBobbinClient, type BobbinContext } from "./client"; import { jsonGet, rawGet } from "./_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); }); });