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<Response>>()
.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<Fetch>()
.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<Fetch>().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<Fetch>()
.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<Fetch>().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<Fetch>().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<Fetch>().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<Fetch>().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");
});
});