This repository has no description
1import { describe, expect, it, vi, type Mock } from "vitest";
2import { ClientResponseError, createBobbinClient, type BobbinContext } from "./client";
3import { jsonGet, rawGet } from "./_request";
4
5const jsonResponse = (body: unknown, init: ResponseInit = {}): Response =>
6 new Response(JSON.stringify(body), {
7 status: 200,
8 headers: { "content-type": "application/json" },
9 ...init
10 });
11
12const makeCtx = (
13 fetchMock: typeof globalThis.fetch,
14 serviceUrl = "https://bobbin.test"
15): BobbinContext => createBobbinClient({ serviceUrl, fetch: fetchMock });
16
17const fetchedUrl = (mock: Mock<typeof globalThis.fetch>, n = 0): URL =>
18 new URL(String(mock.mock.calls[n][0]));
19
20describe("createBobbinClient", () => {
21 it("normalizes trailing slashes off the service url", () => {
22 const ctx = makeCtx(vi.fn(), "https://bobbin.test///");
23 expect(ctx.serviceUrl).toBe("https://bobbin.test");
24 });
25});
26
27describe("jsonGet URL construction", () => {
28 it("appends an array param as repeated keys", async () => {
29 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(jsonResponse({ ok: 1 }));
30 await jsonGet(makeCtx(fetchMock), "sh.tangled.repo.getRepos", { repos: ["a", "b"] });
31 const url = fetchedUrl(fetchMock);
32 expect(url.searchParams.getAll("repos")).toEqual(["a", "b"]);
33 expect(url.search).toBe("?repos=a&repos=b");
34 });
35
36 it("omits undefined params entirely", async () => {
37 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(jsonResponse({ ok: 1 }));
38 await jsonGet(makeCtx(fetchMock), "sh.tangled.x", { keep: "yes", drop: undefined });
39 const url = fetchedUrl(fetchMock);
40 expect(url.searchParams.has("drop")).toBe(false);
41 expect(url.searchParams.get("keep")).toBe("yes");
42 });
43
44 it("resolves the query against the normalized origin", async () => {
45 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(jsonResponse({ ok: 1 }));
46 await jsonGet(makeCtx(fetchMock, "https://bobbin.test/"), "sh.tangled.x", { a: "1" });
47 expect(fetchedUrl(fetchMock).href).toBe("https://bobbin.test/xrpc/sh.tangled.x?a=1");
48 });
49});
50
51describe("jsonGet request headers & signal", () => {
52 it("sends accept: application/json and merges caller headers + signal", async () => {
53 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(jsonResponse({ ok: 1 }));
54 const controller = new AbortController();
55 await jsonGet(makeCtx(fetchMock), "sh.tangled.x", undefined, {
56 headers: { "x-custom": "1" },
57 signal: controller.signal
58 });
59 const init = fetchMock.mock.calls[0][1] as RequestInit;
60 expect(init.headers).toMatchObject({ accept: "application/json", "x-custom": "1" });
61 expect(init.signal).toBe(controller.signal);
62 });
63});
64
65describe("jsonGet responses", () => {
66 it("throws ClientResponseError carrying status + error/description for a JSON error body", async () => {
67 const fetchMock = vi
68 .fn<typeof globalThis.fetch>()
69 .mockResolvedValue(
70 jsonResponse({ error: "RecordNotFound", message: "no such repo" }, { status: 404 })
71 );
72 const err = await jsonGet(makeCtx(fetchMock), "sh.tangled.x").catch((e: unknown) => e);
73 expect(err).toBeInstanceOf(ClientResponseError);
74 const cre = err as ClientResponseError;
75 expect(cre.status).toBe(404);
76 expect(cre.error).toBe("RecordNotFound");
77 expect(cre.description).toBe("no such repo");
78 });
79
80 it("falls back to the status line for a non-JSON error body (does not hang)", async () => {
81 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
82 new Response("<html>502 Bad Gateway</html>", {
83 status: 502,
84 statusText: "Bad Gateway"
85 })
86 );
87 const err = await jsonGet(makeCtx(fetchMock), "sh.tangled.x").catch((e: unknown) => e);
88 expect(err).toBeInstanceOf(ClientResponseError);
89 const cre = err as ClientResponseError;
90 expect(cre.status).toBe(502);
91 expect(cre.error).toBe("XRPCError");
92 expect(cre.description).toBe("Bad Gateway");
93 });
94});
95
96describe("rawGet", () => {
97 it("throws ClientResponseError on a non-2xx status", async () => {
98 const fetchMock = vi
99 .fn<typeof globalThis.fetch>()
100 .mockResolvedValue(
101 jsonResponse({ error: "UpstreamFailed", message: "knot down" }, { status: 502 })
102 );
103 const err = await rawGet(makeCtx(fetchMock), "sh.tangled.repo.archive").catch(
104 (e: unknown) => e
105 );
106 expect(err).toBeInstanceOf(ClientResponseError);
107 expect((err as ClientResponseError).status).toBe(502);
108 });
109});