import { describe, expect, it, vi, type Mock } from "vitest"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import * as v from "@atcute/lexicons/validations"; import { ClientResponseError, createBobbinClient } from "#lib/api/client.ts"; import { fileResultSchema } from "#lib/api/lexicons/types/org/tangled/temp/search/searchCode.ts"; import { searchCode } from "#lib/api/search.ts"; import type { Fetch } from "#lib/fetch.ts"; const NSID = "org.tangled.temp.search.searchCode"; const BOBBIN_URL = "http://127.0.0.1:8090"; const jsonResponse = (body: unknown, init: ResponseInit = {}): Response => new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" }, ...init }); // the agent is only ever asked for a service-auth token, so a stub that answers // getServiceAuth is enough, and its call args are where the minted claims are. const makeAgent = (token = "jwt-1") => { const handle = vi .fn<(pathname: string, init: RequestInit) => Promise>() .mockImplementation(() => Promise.resolve(jsonResponse({ token }))); return { agent: { handle, sub: "did:plc:tester" } as unknown as OAuthUserAgent, handle }; }; const mintedParams = (handle: Mock): URLSearchParams => new URL(String(handle.mock.calls[0][0]), "https://pds.test").searchParams; const makeCtx = (fetchMock: Fetch, token = "jwt-1") => { const { agent, handle } = makeAgent(token); return { ctx: createBobbinClient({ serviceUrl: BOBBIN_URL, agent, fetch: fetchMock }), handle }; }; const WIRE_RESULTS = [ { repo: { did: "did:plc:wshs7t2adsemcrrd4snkeqli", slug: "knotserver", owner: { did: "did:plc:wshs7t2adsemcrrd4snkeqli", handle: "alice.tangled.sh" }, createdAt: "2025-06-01T10:30:00Z", starCount: 42 }, branches: ["main"], commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", path: "cmd/knot/main.go", language: "Go", chunks: [ { content: "func main() {\n\trun()\n}", lineStart: 24, highlights: [{ start: 5, end: 9 }] } ] }, { repo: { did: "did:plc:5bqvvbduvkc4d5g2vy4hqz3m", slug: "web", owner: { did: "did:plc:hd2rzezxhjqjvcpgnbdtqvvt", handle: "bob.tangled.sh" }, createdAt: "2025-07-14T08:00:00Z" }, commit: "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c", path: "src/lib/api/main.ts", // a filename match: no chunks, the ranges cover "main" in the path instead ranges: [{ start: 12, end: 16 }] } ]; describe("searchCode", () => { // a failing call has to reject rather than resolve with an empty page: the tab // tells the two apart to decide between the error box and "no results found". it("rejects when bobbin fails", async () => { const fetchMock = vi .fn() .mockResolvedValue( jsonResponse( { error: "MethodNotImplemented", message: "code search is not configured" }, { status: 501 } ) ); const { ctx } = makeCtx(fetchMock); await expect(searchCode(ctx, { q: "main" })).rejects.toThrow(ClientResponseError); }); it("calls the configured bobbin at the searchCode nsid", async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ results: [] })); const { ctx } = makeCtx(fetchMock); await searchCode(ctx, { q: "main", limit: 50 }); const url = new URL(String(fetchMock.mock.calls[0][0])); expect(url.origin).toBe(BOBBIN_URL); expect(url.pathname).toBe(`/xrpc/${NSID}`); expect(url.searchParams.get("q")).toBe("main"); expect(url.searchParams.get("limit")).toBe("50"); }); // the fixture has to satisfy the lexicon or it isn't testing the real wire shape. // this is what catches it drifting the next time searchCode.json changes it("uses a fixture that validates against the fileResult schema", () => { for (const result of WIRE_RESULTS) { expect(() => v.parse(fileResultSchema, result)).not.toThrow(); } }); it("hands back bobbin's results and cursor untouched", async () => { const fetchMock = vi .fn() .mockResolvedValue(jsonResponse({ results: WIRE_RESULTS, cursor: "50" })); const { ctx } = makeCtx(fetchMock); const out = await searchCode(ctx, { q: "main", limit: 50 }); expect(out.results).toEqual(WIRE_RESULTS); expect(out.cursor).toBe("50"); // the filename-only match survives with no chunks rather than being dropped expect(out.results[1].chunks).toBeUndefined(); }); // the last page omits the cursor, which is what ends the Load more button it("reports no cursor when bobbin omits one", async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ results: WIRE_RESULTS })); const { ctx } = makeCtx(fetchMock); expect((await searchCode(ctx, { q: "main" })).cursor).toBeUndefined(); }); // the repo scope is the only thing keeping a repo-scoped search off other repos' files it("scopes the search to a repo did when given one", async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ results: [] })); const { ctx } = makeCtx(fetchMock); await searchCode(ctx, { q: "main", repo: "did:plc:wshs7t2adsemcrrd4snkeqli" }); expect(new URL(String(fetchMock.mock.calls[0][0])).searchParams.get("repo")).toBe( "did:plc:wshs7t2adsemcrrd4snkeqli" ); }); it("forwards the cursor when asking for the next page", async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ results: [] })); const { ctx } = makeCtx(fetchMock); await searchCode(ctx, { q: "main", limit: 50, cursor: "50" }); expect(new URL(String(fetchMock.mock.calls[0][0])).searchParams.get("cursor")).toBe("50"); }); it("mints a token audienced at the bobbin host, scoped to the method", async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ results: [] })); const { ctx, handle } = makeCtx(fetchMock, "jwt-abc"); await searchCode(ctx, { q: "main" }); const minted = mintedParams(handle); expect(minted.get("aud")).toBe("did:web:127.0.0.1%3A8090"); expect(minted.get("lxm")).toBe(NSID); const headers = new Headers( (fetchMock.mock.calls[0][1] as RequestInit | undefined)?.headers ); expect(headers.get("authorization")).toBe("Bearer jwt-abc"); }); });