0
2
// The actor-facing read endpoints: the viewer's own block and mute lists,
// their favorites, and who to follow next. All four hydrate a list of DIDs into
// profiles the same way, so the handle fallback is checked once per endpoint
// rather than once per field.
import { afterAll, beforeAll, describe, expect, test } 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
const DAVE = "did:plc:dave";
const ERIN = "did:plc:erin"; // no profile record, handle only
const FRANK = "did:plc:frank";
const GINA = "did:plc:gina";
const HOLLY = "did:plc:holly"; // popular on grain, and unrelated to Alice
let server: any;
async function gallery(id: string, did: string, createdAt: string, opts: { empty?: boolean } = {}) {
const { db } = server;
const uri = `at://${did}/social.grain.gallery/${id}`;
await db.run(
`INSERT INTO "social.grain.gallery" (uri, cid, did, indexed_at, title, created_at)
VALUES ($1, $2, $3, 'i', $4, $5)`,
[uri, `cid-${id}`, did, id, createdAt],
);
if (!opts.empty) {
const photoUri = `at://${did}/social.grain.photo/${id}-p`;
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-p-${id}`,
did,
JSON.stringify({
$type: "blob",
ref: { $link: `bafy-${id}` },
mimeType: "image/jpeg",
size: 1,
}),
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, 0)`,
[`at://${did}/social.grain.gallery.item/${id}`, `cid-i-${id}`, did, createdAt, uri, photoUri],
);
}
return uri;
}
/** `who` favorited `subject` at `createdAt`; `rkey` makes repeat records distinct. */
async function favorite(who: string, subject: string, createdAt: string, rkey: string) {
await server.db.run(
`INSERT INTO "social.grain.favorite" (uri, cid, did, indexed_at, created_at, subject)
VALUES ($1, $2, $3, 'i', $4, $5)`,
[`at://${who}/social.grain.favorite/${rkey}`, `cid-${rkey}`, who, createdAt, subject],
);
}
async function get(path: string, as?: string) {
const res = as ? await server.fetchAs(as, path) : await server.fetch(path);
expect(res.status).toBe(200);
return res.json();
}
beforeAll(async () => {
server = await startTestServer();
const { db } = server;
await db.run(
`CREATE TABLE IF NOT EXISTS _mutes (
did TEXT NOT NULL, subject TEXT NOT NULL, created_at TEXT NOT NULL,
PRIMARY KEY (did, subject)
)`,
);
for (const [did, handle, status] of [
[ALICE, "alice.test", "active"],
[BOB, "bob.test", "active"],
[CAROL, "carol.test", "takendown"],
[DAVE, "dave.test", "active"],
[ERIN, "erin.test", "active"],
[FRANK, "frank.test", "active"],
[GINA, "gina.test", "active"],
[HOLLY, "holly.test", "active"],
]) {
await db.run(`INSERT INTO _repos (did, status, handle) VALUES ($1, $2, $3)`, [
did,
status,
handle,
]);
}
// Erin deliberately has no profile record — she exercises the handle fallback.
for (const [did, name] of [
[ALICE, "Alice A"],
[BOB, "Bob Ross"],
[CAROL, "Carol C"],
[DAVE, "Dave D"],
[FRANK, "Frank F"],
[GINA, "Gina G"],
[HOLLY, "Holly H"],
]) {
await db.run(
`INSERT INTO "social.grain.actor.profile" (uri, cid, did, indexed_at, display_name, created_at)
VALUES ($1, $2, $3, 'i', $4, '2026-01-01')`,
[`at://${did}/social.grain.actor.profile/self`, `cid-p-${did}`, did, name],
);
}
// Alice blocked three people, most recently Erin.
for (const [subject, at] of [
[BOB, "2026-01-01"],
[DAVE, "2026-01-02"],
[ERIN, "2026-01-03"],
]) {
await db.run(
`INSERT INTO "social.grain.graph.block" (uri, cid, did, indexed_at, subject, created_at)
VALUES ($1, $2, $3, 'i', $4, $5)`,
[
`at://${ALICE}/social.grain.graph.block/${subject}`,
`cid-bl-${subject}`,
ALICE,
subject,
at,
],
);
}
// ...and muted two.
await db.run(`INSERT INTO _mutes (did, subject, created_at) VALUES ($1, $2, $3)`, [
ALICE,
FRANK,
"2026-02-01",
]);
await db.run(`INSERT INTO _mutes (did, subject, created_at) VALUES ($1, $2, $3)`, [
ALICE,
ERIN,
"2026-02-02",
]);
// Galleries Alice can favorite.
await gallery("g1", BOB, "2026-03-01");
await gallery("g2", BOB, "2026-03-02");
await gallery("g3", DAVE, "2026-03-03");
await gallery("g-down", CAROL, "2026-03-04"); // taken-down author
await gallery("g-empty", BOB, "2026-03-05", { empty: true });
await gallery("g-hidden", BOB, "2026-03-06");
await db.run(
`INSERT INTO _labels (src, uri, val, neg, cts) VALUES ('did:plc:mod', $1, 'spam', 0, '2026-03-07')`,
[`at://${BOB}/social.grain.gallery/g-hidden`],
);
// Alice's favorites. g1 is favorited twice — an account really can hold two
// favorite records for one gallery, and the later one is what orders it.
await favorite(ALICE, `at://${BOB}/social.grain.gallery/g2`, "2026-04-01", "f-g2");
await favorite(ALICE, `at://${DAVE}/social.grain.gallery/g3`, "2026-04-02", "f-g3");
await favorite(ALICE, `at://${BOB}/social.grain.gallery/g1`, "2026-04-03", "f-g1a");
await favorite(ALICE, `at://${BOB}/social.grain.gallery/g1`, "2026-04-04", "f-g1b");
for (const [id, owner] of [
["g-down", CAROL],
["g-empty", BOB],
["g-hidden", BOB],
]) {
await favorite(ALICE, `at://${owner}/social.grain.gallery/${id}`, "2026-04-05", `f-${id}`);
}
await favorite(BOB, `at://${BOB}/social.grain.gallery/g1`, "2026-04-06", "f-bob");
// Suggested follows: Alice follows Gina, Frank and Carol on Bluesky. Gina has
// a grain profile and Alice does not follow her on grain, so she is the
// suggestion. Frank is muted, Carol is taken down.
for (const subject of [GINA, FRANK, CAROL, DAVE]) {
await db.run(
`INSERT INTO "app.bsky.graph.follow" (uri, cid, did, indexed_at, subject, created_at)
VALUES ($1, $2, $3, 'i', $4, '2026-01-01')`,
[`at://${ALICE}/app.bsky.graph.follow/${subject}`, `cid-bf-${subject}`, ALICE, subject],
);
}
// Alice already follows Dave on grain, so he is not a suggestion.
await db.run(
`INSERT INTO "social.grain.graph.follow" (uri, cid, did, indexed_at, subject, created_at)
VALUES ($1, 'cid-gf', $2, 'i', $3, '2026-01-01')`,
[`at://${ALICE}/social.grain.graph.follow/dave`, ALICE, DAVE],
);
// Holly is popular on grain and unconnected to Alice, so she is what the
// backfill reaches for. Bob could not be — Alice blocked him.
for (const follower of [DAVE, ERIN, FRANK, GINA]) {
await db.run(
`INSERT INTO "social.grain.graph.follow" (uri, cid, did, indexed_at, subject, created_at)
VALUES ($1, $2, $3, 'i', $4, '2026-01-01')`,
[`at://${follower}/social.grain.graph.follow/holly`, `cid-gfh-${follower}`, follower, HOLLY],
);
await db.run(
`INSERT INTO "social.grain.graph.follow" (uri, cid, did, indexed_at, subject, created_at)
VALUES ($1, $2, $3, 'i', $4, '2026-01-01')`,
[`at://${follower}/social.grain.graph.follow/bob`, `cid-gfb-${follower}`, follower, BOB],
);
}
});
afterAll(async () => await server?.close());
describe("getBlocks", () => {
test("lists who the viewer blocked, most recent first", async () => {
const { items } = await get("/xrpc/social.grain.unspecced.getBlocks", ALICE);
expect(items.map((i: any) => i.did)).toEqual([ERIN, DAVE, BOB]);
});
test("carries the block record's uri so it can be undone", async () => {
const { items } = await get("/xrpc/social.grain.unspecced.getBlocks", ALICE);
const bob = items.find((i: any) => i.did === BOB);
expect(bob.blockUri).toBe(`at://${ALICE}/social.grain.graph.block/${BOB}`);
});
test("hydrates each blocked account's profile", async () => {
const { items } = await get("/xrpc/social.grain.unspecced.getBlocks", ALICE);
expect(items.find((i: any) => i.did === BOB)).toMatchObject({
handle: "bob.test",
displayName: "Bob Ross",
});
});
test("falls back to the handle for an account with no profile", async () => {
const { items } = await get("/xrpc/social.grain.unspecced.getBlocks", ALICE);
const erin = items.find((i: any) => i.did === ERIN);
expect(erin.handle).toBe("erin.test");
expect(erin.displayName).toBeUndefined();
});
test("pages, and stops offering a cursor at the end", async () => {
const first = await get("/xrpc/social.grain.unspecced.getBlocks?limit=2", ALICE);
expect(first.items.map((i: any) => i.did)).toEqual([ERIN, DAVE]);
expect(first.cursor).toBeTruthy();
const second = await get(
`/xrpc/social.grain.unspecced.getBlocks?limit=2&cursor=${encodeURIComponent(first.cursor)}`,
ALICE,
);
expect(second.items.map((i: any) => i.did)).toEqual([BOB]);
expect(second.cursor).toBeUndefined();
});
test("is empty for a viewer who blocked nobody", async () => {
const { items } = await get("/xrpc/social.grain.unspecced.getBlocks", BOB);
expect(items).toEqual([]);
});
test("requires a session", async () => {
const res = await server.fetch("/xrpc/social.grain.unspecced.getBlocks");
expect(res.status).toBe(400);
});
});
describe("getMutes", () => {
test("lists who the viewer muted, most recent first", async () => {
const { items } = await get("/xrpc/social.grain.unspecced.getMutes", ALICE);
expect(items.map((i: any) => i.did)).toEqual([ERIN, FRANK]);
});
test("hydrates profiles and falls back to the handle", async () => {
const { items } = await get("/xrpc/social.grain.unspecced.getMutes", ALICE);
expect(items.find((i: any) => i.did === FRANK)).toMatchObject({
handle: "frank.test",
displayName: "Frank F",
});
expect(items.find((i: any) => i.did === ERIN).displayName).toBeUndefined();
});
test("carries no block uri, since a mute is not a record", async () => {
const { items } = await get("/xrpc/social.grain.unspecced.getMutes", ALICE);
expect(items[0].blockUri).toBeUndefined();
});
test("pages", async () => {
const first = await get("/xrpc/social.grain.unspecced.getMutes?limit=1", ALICE);
expect(first.items.map((i: any) => i.did)).toEqual([ERIN]);
expect(first.cursor).toBeTruthy();
const second = await get(
`/xrpc/social.grain.unspecced.getMutes?limit=1&cursor=${encodeURIComponent(first.cursor)}`,
ALICE,
);
expect(second.items.map((i: any) => i.did)).toEqual([FRANK]);
expect(second.cursor).toBeUndefined();
});
test("requires a session", async () => {
expect((await server.fetch("/xrpc/social.grain.unspecced.getMutes")).status).toBe(400);
});
});
describe("getActorFavorites", () => {
const path = (extra = "") =>
`/xrpc/social.grain.unspecced.getActorFavorites?actor=${ALICE}${extra}`;
const ids = (items: any[]) => items.map((i) => i.uri.split("/").pop());
test("lists the actor's favorites, most recently favorited first", async () => {
const { items } = await get(path(), ALICE);
expect(ids(items)).toEqual(["g1", "g3", "g2"]);
});
test("accepts the actor's handle, and still only shows the actor", async () => {
// The owner check compares DIDs, so the handle has to resolve first.
const byHandle = "/xrpc/social.grain.unspecced.getActorFavorites?actor=alice.test";
expect(ids((await get(byHandle, ALICE)).items)).toEqual(["g1", "g3", "g2"]);
expect((await get(byHandle, BOB)).items).toEqual([]);
});
test("lists a gallery once even when favorited twice", async () => {
// Duplicate favorite records for one gallery are real and present in prod.
const { items } = await get(path(), ALICE);
expect(ids(items).filter((i) => i === "g1")).toHaveLength(1);
});
test("orders a re-favorited gallery by the later record", async () => {
// g1's first favorite predates g2's and g3's; its second postdates both,
// and that is the one that decides where it sits.
const { items } = await get(path(), ALICE);
expect(ids(items)[0]).toBe("g1");
});
test("drops favorites of taken-down, empty and hidden galleries", async () => {
const { items } = await get(path(), ALICE);
expect(ids(items)).not.toContain("g-down");
expect(ids(items)).not.toContain("g-empty");
expect(ids(items)).not.toContain("g-hidden");
});
test("shows nothing to anyone but the actor", async () => {
// Favorites are private to the account that made them.
expect((await get(path(), BOB)).items).toEqual([]);
expect((await get(path())).items).toEqual([]);
});
test("pages with a created_at cursor", async () => {
const first = await get(path("&limit=2"), ALICE);
expect(ids(first.items)).toEqual(["g1", "g3"]);
expect(first.cursor).toBeTruthy();
const second = await get(path(`&limit=2&cursor=${encodeURIComponent(first.cursor)}`), ALICE);
expect(ids(second.items)).toEqual(["g2"]);
expect(second.cursor).toBeUndefined();
});
});
describe("getSuggestedFollows", () => {
const path = (extra = "") =>
`/xrpc/social.grain.unspecced.getSuggestedFollows?actor=${ALICE}${extra}`;
test("suggests Bluesky follows who are on grain but not followed here", async () => {
const { items } = await get(path("&limit=1"), ALICE);
expect(items.map((i: any) => i.did)).toEqual([GINA]);
});
test("skips accounts already followed on grain, taken down, or muted", async () => {
const { items } = await get(path("&limit=10"), ALICE);
const dids = items.map((i: any) => i.did);
expect(dids).not.toContain(DAVE); // already followed on grain
expect(dids).not.toContain(CAROL); // taken down
expect(dids).not.toContain(FRANK); // muted
expect(dids).not.toContain(ALICE); // never suggest yourself
});
test("backfills with popular grain profiles when the graph runs out", async () => {
// Only Gina comes from the Bluesky graph. The rest of the page is filled
// by follower count, and the Bluesky suggestion still leads.
const { items } = await get(path("&limit=3"), ALICE);
expect(items.map((i: any) => i.did)).toEqual([GINA, HOLLY]);
});
test("will not backfill someone the viewer blocked", async () => {
// Bob has as many followers as Holly, but Alice blocked him.
const { items } = await get(path("&limit=10"), ALICE);
expect(items.map((i: any) => i.did)).not.toContain(BOB);
});
test("reports each suggestion's follower count", async () => {
const { items } = await get(path("&limit=10"), ALICE);
expect(items.find((i: any) => i.did === HOLLY).followersCount).toBe(4);
expect(items.find((i: any) => i.did === GINA).followersCount).toBe(0);
});
test("cannot suggest an account with no grain profile", async () => {
const { items } = await get(path("&limit=10"), ALICE);
expect(items.map((i: any) => i.did)).not.toContain(ERIN);
});
});