0
2
// parts.page.mention.search — the picker that parts.page embeds use to mention
// a grain user or link one of their galleries. One endpoint, two modes: with a
// `scope` it searches that account's galleries, without one it searches users.
//
// This file covers the browse mode — a scope with no query — which is pure SQL
// over rows inserted with `db.run`. The search-backed modes need records in the
// FTS index, which only `insertRecord` maintains, so they live in
// `test/search.test.ts` with its YAML fixtures.
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { startTestServer } from "@hatk/hatk/test";
const ALICE = "did:plc:alice";
const CAROL = "did:plc:carol"; // taken down
const SERVICE = "at://did:plc:service/parts.page.mention.service/self";
let server: any;
const search = (query: string) =>
`/xrpc/parts.page.mention.search?service=${encodeURIComponent(SERVICE)}&${query}`;
async function get(query: string) {
const res = await server.fetch(search(query));
expect(res.status).toBe(200);
return (await res.json()).results;
}
async function gallery(opts: {
id: string;
did: string;
title: string;
description?: string | null;
createdAt: string;
photos?: string[];
hidden?: boolean;
}) {
const { db } = server;
const uri = `at://${opts.did}/social.grain.gallery/${opts.id}`;
await db.run(
`INSERT INTO "social.grain.gallery" (uri, cid, did, indexed_at, title, description, created_at)
VALUES ($1, $2, $3, 'i', $4, $5, $6)`,
[uri, `cid-${opts.id}`, opts.did, opts.title, opts.description ?? null, opts.createdAt],
);
// Photos are listed with their position, and inserted in the reverse of it,
// so the icon has to come from position 0 rather than from insert order.
const photos = opts.photos ?? [`${opts.id}-p`];
for (let i = photos.length - 1; i >= 0; i--) {
const photoUri = `at://${opts.did}/social.grain.photo/${photos[i]}`;
await db.run(
`INSERT INTO "social.grain.photo" (uri, cid, did, indexed_at, photo, aspect_ratio, created_at)
VALUES ($1, $2, $3, 'i', $4, '{"width":3,"height":2}', $5)`,
[
photoUri,
`cid-${photos[i]}`,
opts.did,
JSON.stringify({
$type: "blob",
ref: { $link: `bafy-${photos[i]}` },
mimeType: "image/jpeg",
size: 1,
}),
opts.createdAt,
],
);
await db.run(
`INSERT INTO "social.grain.gallery.item" (uri, cid, did, indexed_at, created_at, gallery, item, position)
VALUES ($1, $2, $3, 'i', $4, $5, $6, $7)`,
[
`at://${opts.did}/social.grain.gallery.item/${photos[i]}`,
`cid-i-${photos[i]}`,
opts.did,
opts.createdAt,
uri,
photoUri,
i,
],
);
}
if (opts.hidden) {
await db.run(
`INSERT INTO _labels (src, uri, val, neg, cts) VALUES ('did:plc:mod', $1, 'spam', 0, '2026-01-01')`,
[uri],
);
}
return uri;
}
beforeAll(async () => {
server = await startTestServer();
const { db } = server;
for (const [did, handle, status] of [
[ALICE, "alice.test", "active"],
[CAROL, "carol.test", "takendown"],
]) {
await db.run(`INSERT INTO _repos (did, status, handle) VALUES ($1, $2, $3)`, [
did,
status,
handle,
]);
}
await gallery({
id: "g1",
did: ALICE,
title: "Sunset",
description: "orange",
createdAt: "2026-03-01",
photos: ["g1-cover", "g1-second"],
});
await gallery({ id: "g2", did: ALICE, title: "Harbour", createdAt: "2026-03-02" });
await gallery({
id: "g-empty",
did: ALICE,
title: "Nothing",
createdAt: "2026-03-03",
photos: [],
});
await gallery({
id: "g-hidden",
did: ALICE,
title: "Hidden",
createdAt: "2026-03-04",
hidden: true,
});
await gallery({ id: "gc", did: CAROL, title: "Taken down", createdAt: "2026-03-05" });
});
afterAll(async () => await server?.close());
describe("browsing an account's galleries", () => {
test("lists them newest first with a link and an embed for each", async () => {
const results = await get(`scope=${encodeURIComponent(ALICE)}&search=`);
expect(results.map((r: any) => r.name)).toEqual(["Harbour", "Sunset"]);
const sunset = results.find((r: any) => r.name === "Sunset");
expect(sunset).toMatchObject({
uri: `at://${ALICE}/social.grain.gallery/g1`,
description: "orange",
href: `http://127.0.0.1:3000/profile/alice.test/gallery/g1`,
embed: {
src: `http://127.0.0.1:3000/embed/gallery/${ALICE}/g1`,
aspectRatio: { width: 16, height: 9 },
},
});
});
test("takes the icon from the gallery's first photo by position", async () => {
const results = await get(`scope=${encodeURIComponent(ALICE)}&search=`);
const sunset = results.find((r: any) => r.name === "Sunset");
expect(sunset.icon).toContain("bafy-g1-cover");
});
test("leaves out the description when there is none", async () => {
const results = await get(`scope=${encodeURIComponent(ALICE)}&search=`);
const harbour = results.find((r: any) => r.name === "Harbour");
expect(harbour).not.toHaveProperty("description");
});
test("skips empty and hidden galleries", async () => {
const names = (await get(`scope=${encodeURIComponent(ALICE)}&search=`)).map((r: any) => r.name);
expect(names).not.toContain("Nothing");
expect(names).not.toContain("Hidden");
});
test("skips a taken-down account entirely", async () => {
expect(await get(`scope=${encodeURIComponent(CAROL)}&search=`)).toEqual([]);
});
test("is empty for an account with nothing to show", async () => {
expect(await get("scope=did%3Aplc%3Anobody&search=")).toEqual([]);
});
test("honours the limit", async () => {
const results = await get(`scope=${encodeURIComponent(ALICE)}&search=&limit=1`);
expect(results).toHaveLength(1);
expect(results[0].name).toBe("Harbour"); // the newest
});
});
describe("searching users", () => {
test("short-circuits a blank query without touching the index", async () => {
expect(await get("search=")).toEqual([]);
expect(await get("search=%20%20")).toEqual([]);
});
});