This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / web / src / lib / api / client.test.ts
7.8 kB 192 lines
1import { describe, expect, it, vi, type Mock } from "vitest"; 2import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; 3import { ClientResponseError, createBobbinClient, type BobbinContext } from "./client"; 4import { jsonGet, rawGet } from "./_request"; 5 6const jsonResponse = (body: unknown, init: ResponseInit = {}): Response => 7 new Response(JSON.stringify(body), { 8 status: 200, 9 headers: { "content-type": "application/json" }, 10 ...init 11 }); 12 13const makeCtx = ( 14 fetchMock: typeof globalThis.fetch, 15 serviceUrl = "https://bobbin.test" 16): BobbinContext => createBobbinClient({ serviceUrl, fetch: fetchMock }); 17 18const fetchedUrl = (mock: Mock<typeof globalThis.fetch>, n = 0): URL => 19 new URL(String(mock.mock.calls[n][0])); 20 21describe("createBobbinClient", () => { 22 it("normalizes trailing slashes off the service url", () => { 23 const ctx = makeCtx(vi.fn(), "https://bobbin.test///"); 24 expect(ctx.serviceUrl).toBe("https://bobbin.test"); 25 }); 26}); 27 28describe("jsonGet URL construction", () => { 29 it("appends an array param as repeated keys", async () => { 30 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(jsonResponse({ ok: 1 })); 31 await jsonGet(makeCtx(fetchMock), "sh.tangled.repo.getRepos", { repos: ["a", "b"] }); 32 const url = fetchedUrl(fetchMock); 33 expect(url.searchParams.getAll("repos")).toEqual(["a", "b"]); 34 expect(url.search).toBe("?repos=a&repos=b"); 35 }); 36 37 it("omits undefined params entirely", async () => { 38 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(jsonResponse({ ok: 1 })); 39 await jsonGet(makeCtx(fetchMock), "sh.tangled.x", { keep: "yes", drop: undefined }); 40 const url = fetchedUrl(fetchMock); 41 expect(url.searchParams.has("drop")).toBe(false); 42 expect(url.searchParams.get("keep")).toBe("yes"); 43 }); 44 45 it("resolves the query against the normalized origin", async () => { 46 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(jsonResponse({ ok: 1 })); 47 await jsonGet(makeCtx(fetchMock, "https://bobbin.test/"), "sh.tangled.x", { a: "1" }); 48 expect(fetchedUrl(fetchMock).href).toBe("https://bobbin.test/xrpc/sh.tangled.x?a=1"); 49 }); 50}); 51 52describe("jsonGet request headers & signal", () => { 53 it("sends accept: application/json and merges caller headers + signal", async () => { 54 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(jsonResponse({ ok: 1 })); 55 const controller = new AbortController(); 56 await jsonGet(makeCtx(fetchMock), "sh.tangled.x", undefined, { 57 headers: { "x-custom": "1" }, 58 signal: controller.signal 59 }); 60 const init = fetchMock.mock.calls[0][1] as RequestInit; 61 expect(init.headers).toMatchObject({ accept: "application/json", "x-custom": "1" }); 62 expect(init.signal).toBe(controller.signal); 63 }); 64}); 65 66describe("jsonGet responses", () => { 67 it("throws ClientResponseError carrying status + error/description for a JSON error body", async () => { 68 const fetchMock = vi 69 .fn<typeof globalThis.fetch>() 70 .mockResolvedValue( 71 jsonResponse({ error: "RecordNotFound", message: "no such repo" }, { status: 404 }) 72 ); 73 const err = await jsonGet(makeCtx(fetchMock), "sh.tangled.x").catch((e: unknown) => e); 74 expect(err).toBeInstanceOf(ClientResponseError); 75 const cre = err as ClientResponseError; 76 expect(cre.status).toBe(404); 77 expect(cre.error).toBe("RecordNotFound"); 78 expect(cre.description).toBe("no such repo"); 79 }); 80 81 it("falls back to the status line for a non-JSON error body (does not hang)", async () => { 82 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue( 83 new Response("<html>502 Bad Gateway</html>", { 84 status: 502, 85 statusText: "Bad Gateway" 86 }) 87 ); 88 const err = await jsonGet(makeCtx(fetchMock), "sh.tangled.x").catch((e: unknown) => e); 89 expect(err).toBeInstanceOf(ClientResponseError); 90 const cre = err as ClientResponseError; 91 expect(cre.status).toBe(502); 92 expect(cre.error).toBe("XRPCError"); 93 expect(cre.description).toBe("Bad Gateway"); 94 }); 95}); 96 97describe("createBobbinClient service auth", () => { 98 // mintServiceAuth goes through `new Client({ handler: agent })`, and 99 // OAuthUserAgent satisfies FetchHandlerObject — so `handle` is where the 100 // com.atproto.server.getServiceAuth call lands and where the minted claims 101 // can be read back off the query string. 102 const makeAgent = (token = "jwt-1") => { 103 const handle = vi 104 .fn<(pathname: string, init: RequestInit) => Promise<Response>>() 105 .mockImplementation(() => Promise.resolve(jsonResponse({ token }))); 106 return { agent: { handle, sub: "did:plc:tester" } as unknown as OAuthUserAgent, handle }; 107 }; 108 109 const mintedParams = (handle: Mock, n = 0): URLSearchParams => 110 new URL(String(handle.mock.calls[n][0]), "https://pds.test").searchParams; 111 112 const authHeader = (fetchMock: Mock<typeof globalThis.fetch>, n = 0): string | null => 113 new Headers((fetchMock.mock.calls[n][1] as RequestInit | undefined)?.headers).get( 114 "authorization" 115 ); 116 117 // a Response body reads once, so each call needs its own 118 const okFetch = () => 119 vi 120 .fn<typeof globalThis.fetch>() 121 .mockImplementation(() => Promise.resolve(jsonResponse({ ok: 1 }))); 122 123 it("sends no authorization header when no agent is given", async () => { 124 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(jsonResponse({ ok: 1 })); 125 await jsonGet(makeCtx(fetchMock), "sh.tangled.x"); 126 expect(authHeader(fetchMock)).toBeNull(); 127 }); 128 129 it("attaches a minted bearer token when an agent is given", async () => { 130 const fetchMock = okFetch(); 131 const { agent } = makeAgent("jwt-abc"); 132 const ctx = createBobbinClient({ serviceUrl: "https://appview.test", fetch: fetchMock, agent }); 133 134 await jsonGet(ctx, "org.tangled.temp.search.searchCode", { q: "main" }); 135 136 expect(authHeader(fetchMock)).toBe("Bearer jwt-abc"); 137 }); 138 139 it("scopes each token to the method being called", async () => { 140 const fetchMock = okFetch(); 141 const { agent, handle } = makeAgent(); 142 const ctx = createBobbinClient({ serviceUrl: "https://appview.test", fetch: fetchMock, agent }); 143 144 await jsonGet(ctx, "org.tangled.temp.search.searchCode", { q: "main" }); 145 await jsonGet(ctx, "org.tangled.temp.notification.getUnreadCount"); 146 147 expect(handle).toHaveBeenCalledTimes(2); 148 expect(mintedParams(handle, 0).get("lxm")).toBe("org.tangled.temp.search.searchCode"); 149 expect(mintedParams(handle, 1).get("lxm")).toBe("org.tangled.temp.notification.getUnreadCount"); 150 }); 151 152 it("audiences the token at did:web:<host>, percent-encoding the port", async () => { 153 const fetchMock = okFetch(); 154 const { agent, handle } = makeAgent(); 155 // the dev default from lib/server/config.ts 156 const ctx = createBobbinClient({ 157 serviceUrl: "http://127.0.0.1:8080", 158 fetch: fetchMock, 159 agent 160 }); 161 162 await jsonGet(ctx, "org.tangled.temp.search.searchCode", { q: "main" }); 163 164 expect(mintedParams(handle).get("aud")).toBe("did:web:127.0.0.1%3A8080"); 165 }); 166 167 it("mints nothing for a fetch that is not an xrpc call", async () => { 168 const fetchMock = okFetch(); 169 const { agent, handle } = makeAgent(); 170 const ctx = createBobbinClient({ serviceUrl: "https://appview.test", fetch: fetchMock, agent }); 171 172 await ctx.fetch("https://appview.test/healthz"); 173 174 expect(handle).not.toHaveBeenCalled(); 175 expect(authHeader(fetchMock)).toBeNull(); 176 }); 177}); 178 179describe("rawGet", () => { 180 it("throws ClientResponseError on a non-2xx status", async () => { 181 const fetchMock = vi 182 .fn<typeof globalThis.fetch>() 183 .mockResolvedValue( 184 jsonResponse({ error: "UpstreamFailed", message: "knot down" }, { status: 502 }) 185 ); 186 const err = await rawGet(makeCtx(fetchMock), "sh.tangled.repo.archive").catch( 187 (e: unknown) => e 188 ); 189 expect(err).toBeInstanceOf(ClientResponseError); 190 expect((err as ClientResponseError).status).toBe(502); 191 }); 192});