0
2
// The four search-backed endpoints: searchProfiles, searchGalleries,
// searchActorsTypeahead and mentionSearch's unscoped mode.
//
// These are the only tests in the suite that use `loadFixtures` rather than
// `db.run`, and they have to: a record is only searchable if it went in through
// `insertRecord`, which is what maintains the FTS index. A raw SQL insert
// leaves the row present but unfindable — the same as in production. The
// fixtures live in `test/fixtures/search/`.
//
// (Until @hatk/hatk alpha.82 none of this could be tested at all: the harness
// never built the FTS tables, so every query answered empty.)
import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest";
import { startTestServer } from "@hatk/hatk/test";
const ALICE = "did:plc:alice";
const BOB = "did:plc:bob";
const CAROL = "did:plc:carol"; // taken down
let server: any;
async function get(path: string) {
const res = await server.fetch(path);
expect(res.status).toBe(200);
return res.json();
}
beforeAll(async () => {
server = await startTestServer();
await server.loadFixtures("test/fixtures/search");
});
afterEach(() => vi.unstubAllGlobals());
afterAll(async () => await server?.close());
describe("searchProfiles", () => {
test("finds a profile by display name", async () => {
const { items } = await get("/xrpc/social.grain.unspecced.searchProfiles?q=Anders");
expect(items.map((i: any) => i.did)).toEqual([ALICE]);
expect(items[0]).toMatchObject({
handle: "alice.test",
displayName: "Alice Anders",
description: "landscape photographer in Portland",
});
});
test("finds a profile by a word in its description", async () => {
const { items } = await get("/xrpc/social.grain.unspecced.searchProfiles?q=trees");
expect(items.map((i: any) => i.did)).toEqual([BOB]);
});
test("discriminates rather than returning everyone", async () => {
const { items } = await get("/xrpc/social.grain.unspecced.searchProfiles?q=Anders");
expect(items.map((i: any) => i.did)).not.toContain(BOB);
});
test("leaves out taken-down accounts", async () => {
// Carol's description says "photographer", the same as Alice's.
const { items } = await get("/xrpc/social.grain.unspecced.searchProfiles?q=photographer");
expect(items.map((i: any) => i.did)).toContain(ALICE);
expect(items.map((i: any) => i.did)).not.toContain(CAROL);
});
test("finds nothing for a word nobody wrote", async () => {
const { items } = await get("/xrpc/social.grain.unspecced.searchProfiles?q=kayaking");
expect(items).toEqual([]);
});
});
describe("searchGalleries", () => {
test("finds a gallery by title", async () => {
const { items } = await get("/xrpc/social.grain.unspecced.searchGalleries?q=Sunsets");
expect(items.map((i: any) => i.uri)).toEqual([`at://${ALICE}/social.grain.gallery/g1`]);
});
test("finds a gallery by description", async () => {
const { items } = await get("/xrpc/social.grain.unspecced.searchGalleries?q=cascades");
expect(items.map((i: any) => i.uri)).toEqual([`at://${BOB}/social.grain.gallery/g2`]);
});
test("hydrates the gallery rather than returning the raw row", async () => {
const { items } = await get("/xrpc/social.grain.unspecced.searchGalleries?q=Sunsets");
expect(items[0]).toMatchObject({
title: "Portland Sunsets",
creator: { did: ALICE, handle: "alice.test" },
});
});
test("leaves out galleries from taken-down accounts", async () => {
const { items } = await get("/xrpc/social.grain.unspecced.searchGalleries?q=Harbour");
expect(items).toEqual([]);
});
test("finds nothing for a word nobody wrote", async () => {
const { items } = await get("/xrpc/social.grain.unspecced.searchGalleries?q=kayaking");
expect(items).toEqual([]);
});
test("leaves out spam-labelled galleries, and returns them once negated", async () => {
const uri = `at://${BOB}/social.grain.gallery/g2`;
await server.db.run(
`INSERT INTO _labels (src, uri, val, neg, cts) VALUES ('self', $1, 'spam', 0, '2026-10-21')`,
[uri],
);
try {
const hidden = await get("/xrpc/social.grain.unspecced.searchGalleries?q=cascades");
expect(hidden.items).toEqual([]);
await server.db.run(
`INSERT INTO _labels (src, uri, val, neg, cts) VALUES ('admin', $1, 'spam', 1, '2026-10-22')`,
[uri],
);
const shown = await get("/xrpc/social.grain.unspecced.searchGalleries?q=cascades");
expect(shown.items.map((i: any) => i.uri)).toEqual([uri]);
} finally {
await server.db.run(`DELETE FROM _labels WHERE uri = $1`, [uri]);
}
});
});
describe("searchActorsTypeahead", () => {
/**
* Answers the Bluesky typeahead call and passes everything else through —
* the harness drives `server.fetch` over real HTTP, so a stub that swallowed
* every request would break the request under test.
*/
function stubBsky(actors: unknown[] | null, ok = true) {
const real = globalThis.fetch;
vi.stubGlobal("fetch", async (input: any, init?: any) => {
const url = typeof input === "string" ? input : input?.url;
if (typeof url === "string" && url.includes("app.bsky.actor.searchActorsTypeahead")) {
if (!ok) return new Response("nope", { status: 500 });
return new Response(JSON.stringify({ actors }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
return real(input, init);
});
}
test("puts grain accounts ahead of Bluesky ones", async () => {
// The point of the endpoint: someone already on grain is the better
// suggestion, whatever Bluesky ranks first.
stubBsky([{ did: "did:plc:stranger", handle: "stranger.bsky", displayName: "A Stranger" }]);
const { actors } = await get("/xrpc/social.grain.unspecced.searchActorsTypeahead?q=Anders");
expect(actors.map((a: any) => a.did)).toEqual([ALICE, "did:plc:stranger"]);
expect(actors[0]).toMatchObject({ handle: "alice.test", displayName: "Alice Anders" });
});
test("lists an account once when both sides return it", async () => {
stubBsky([{ did: ALICE, handle: "alice.bsky", displayName: "Alice from Bluesky" }]);
const { actors } = await get("/xrpc/social.grain.unspecced.searchActorsTypeahead?q=Anders");
expect(actors.filter((a: any) => a.did === ALICE)).toHaveLength(1);
// The grain profile is the one kept.
expect(actors[0].handle).toBe("alice.test");
});
test("still answers from grain when Bluesky returns an error status", async () => {
stubBsky(null, false);
const { actors } = await get("/xrpc/social.grain.unspecced.searchActorsTypeahead?q=Anders");
expect(actors.map((a: any) => a.did)).toEqual([ALICE]);
});
test("still answers from grain when the call to Bluesky never lands", async () => {
// A refused connection or a DNS failure rejects rather than returning a
// response, which is a different branch from an error status.
const real = globalThis.fetch;
vi.stubGlobal("fetch", async (input: any, init?: any) => {
const url = typeof input === "string" ? input : input?.url;
if (typeof url === "string" && url.includes("app.bsky.actor.searchActorsTypeahead")) {
throw new TypeError("fetch failed");
}
return real(input, init);
});
const { actors } = await get("/xrpc/social.grain.unspecced.searchActorsTypeahead?q=Anders");
expect(actors.map((a: any) => a.did)).toEqual([ALICE]);
});
test("still answers from Bluesky when grain has no match", async () => {
stubBsky([{ did: "did:plc:stranger", handle: "stranger.bsky", displayName: "A Stranger" }]);
const { actors } = await get("/xrpc/social.grain.unspecced.searchActorsTypeahead?q=kayaking");
expect(actors.map((a: any) => a.did)).toEqual(["did:plc:stranger"]);
});
test("copes with Bluesky answering without an actors array", async () => {
stubBsky(null);
const { actors } = await get("/xrpc/social.grain.unspecced.searchActorsTypeahead?q=Anders");
expect(actors.map((a: any) => a.did)).toEqual([ALICE]);
});
test("honours the limit across both sources", async () => {
stubBsky([
{ did: "did:plc:s1", handle: "s1.bsky" },
{ did: "did:plc:s2", handle: "s2.bsky" },
]);
const { actors } = await get(
"/xrpc/social.grain.unspecced.searchActorsTypeahead?q=Anders&limit=2",
);
expect(actors).toHaveLength(2);
expect(actors[0].did).toBe(ALICE);
});
});
describe("mentionSearch, searching within one account's galleries", () => {
const path = (scope: string, search: string) =>
`/xrpc/parts.page.mention.search?service=${encodeURIComponent("at://did:plc:svc/parts.page.mention.service/self")}&scope=${encodeURIComponent(scope)}&search=${encodeURIComponent(search)}`;
test("finds an account's own gallery by a word in it", async () => {
const { results } = await get(path(ALICE, "Sunsets"));
expect(results.map((r: any) => r.name)).toEqual(["Portland Sunsets"]);
});
test("will not reach another account's galleries", async () => {
// Bob's "Mountain Trails" matches the term, but the scope is Alice.
const { results } = await get(path(ALICE, "Mountain"));
expect(results).toEqual([]);
// ...and it is genuinely findable when the scope is his.
expect((await get(path(BOB, "Mountain"))).results.map((r: any) => r.name)).toEqual([
"Mountain Trails",
]);
});
test("finds nothing in an account with no matching gallery", async () => {
expect((await get(path(ALICE, "kayaking"))).results).toEqual([]);
});
});
describe("mentionSearch, searching users", () => {
test("finds a grain user by name", async () => {
const { results } = await get(
`/xrpc/parts.page.mention.search?service=${encodeURIComponent("at://did:plc:svc/parts.page.mention.service/self")}&search=Anders`,
);
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
uri: `at://${ALICE}/social.grain.actor.profile/self`,
name: "Alice Anders",
description: "landscape photographer in Portland",
href: `http://127.0.0.1:3000/profile/alice.test`,
subscope: { scope: ALICE, label: "Galleries" },
});
});
test("finds nothing for a word nobody wrote", async () => {
const { results } = await get(
`/xrpc/parts.page.mention.search?service=${encodeURIComponent("at://did:plc:svc/parts.page.mention.service/self")}&search=kayaking`,
);
expect(results).toEqual([]);
});
});