Something went wrong. Try again.
grain.social is a photo sharing platform built on atproto. grain.social
appview atproto photography
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473// The three on-commit hooks that turn firehose records into push// notifications, and the four helpers they gate on: isRecent, isBlockedOrMuted,// shouldPush and getUnseenCount.//// `defineHook` returns the handler untouched, so these run against a real// database without a firehose or a server. Only `push` and `lookup` are// stubbed: `push` to capture what would have been delivered, `lookup` because// the hooks use it for one thing, the actor's display name.
import { afterAll, beforeAll, describe, expect, test } from "vitest";import { startTestServer } from "@hatk/hatk/test";import commentHook from "../server/hooks/on-commit-comment.ts";import favoriteHook from "../server/hooks/on-commit-favorite.ts";import followHook from "../server/hooks/on-commit-follow.ts";import { isBlockedOrMuted } from "../server/helpers/isModerated.ts";
const ALICE = "did:plc:alice";const BOB = "did:plc:bob";const FRED = "did:plc:fred"; // no profile recordconst CAROL = "did:plc:carol"; // taken downconst DAVE = "did:plc:dave"; // Alice blocked himconst ERIN = "did:plc:erin"; // Alice muted herconst HANK = "did:plc:hank"; // wrote the comment others reply toconst ZARA = "did:plc:zara"; // badge arithmeticconst IVY = "did:plc:ivy";const JUDY = "did:plc:judy";const MORT = "did:plc:mort"; // taken down, favorited Zara's galleryconst NELL = "did:plc:nell"; // Zara blocked her, favorited Zara's galleryconst PAT = "did:plc:pat"; // turned favorite pushes offconst QUINN = "did:plc:quinn"; // only wants pushes from accounts she followsconst OTTO = "did:plc:otto"; // blocked Alice, rather than the other way round
const G1 = `at://${ALICE}/social.grain.gallery/g1`;const S1 = `at://${ALICE}/social.grain.story/s1`;const CM1 = `at://${HANK}/social.grain.comment/cm1`;const GZ = `at://${ZARA}/social.grain.gallery/gz`;const GP = `at://${PAT}/social.grain.gallery/gp`;const GQ = `at://${QUINN}/social.grain.gallery/gq`;
const PROFILES: Record<string, string> = { [BOB]: "Bob Ross", [HANK]: "Hank Hill" };
const now = () => new Date().toISOString();const minutesAgo = (m: number) => new Date(Date.now() - m * 60_000).toISOString();
let server: any;
/** Stands in for the hydrate lookup, which the hooks use only for a name. */const lookup = (async (_collection: string, _field: string, dids: string[]) => { const map = new Map<string, { value: { displayName: string } }>(); for (const did of dids) { if (PROFILES[did]) map.set(did, { value: { displayName: PROFILES[did] } }); } return map;}) as any;
/** Run a hook against the real database and collect what it tried to push. */async function fire( hook: { handler: (ctx: any) => Promise<void> }, ctx: { repo: string; record: Record<string, unknown> | null; action?: "create" | "delete" },) { const sent: any[] = []; await hook.handler({ action: ctx.action ?? "create", collection: "test", record: ctx.record, repo: ctx.repo, uri: `at://${ctx.repo}/test/1`, db: server.db, lookup, push: { send: async (message: any) => void sent.push(message) }, }); return sent;}
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], );}
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) )`, ); await db.run( `CREATE TABLE IF NOT EXISTS _preferences ( did TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (did, key) )`, );
for (const [did, status] of [ [ALICE, "active"], [BOB, "active"], [FRED, "active"], [CAROL, "takendown"], [DAVE, "active"], [ERIN, "active"], [HANK, "active"], [ZARA, "active"], [IVY, "active"], [JUDY, "active"], [MORT, "takendown"], [NELL, "active"], [PAT, "active"], [QUINN, "active"], [OTTO, "active"], ]) { await db.run(`INSERT INTO _repos (did, status, handle) VALUES ($1, $2, $3)`, [ did, status, `${did.split(":").pop()}.test`, ]); }
for (const [uri, did] of [ [G1, ALICE], [GZ, ZARA], [GP, PAT], [GQ, QUINN], ]) { await db.run( `INSERT INTO "social.grain.gallery" (uri, cid, did, indexed_at, title, created_at) VALUES ($1, $2, $3, 'i', 'g', '2026-01-01')`, [uri, `cid-${uri}`, did], ); } await db.run( `INSERT INTO "social.grain.story" (uri, cid, did, indexed_at, media, aspect_ratio, created_at) VALUES ($1, 'cid-s1', $2, 'i', $3, '{"width":3,"height":2}', '2026-01-01')`, [ S1, ALICE, JSON.stringify({ $type: "blob", ref: { $link: "bafy-s1" }, mimeType: "image/jpeg", size: 1, }), ], ); // Hank's comment on Alice's gallery — the thing replies reply to. await db.run( `INSERT INTO "social.grain.comment" (uri, cid, did, indexed_at, created_at, subject, text) VALUES ($1, 'cid-cm1', $2, 'i', '2026-01-01', $3, 'nice one')`, [CM1, HANK, G1], );
// Alice blocked Dave and muted Erin. await db.run( `INSERT INTO "social.grain.graph.block" (uri, cid, did, indexed_at, subject, created_at) VALUES ($1, 'cid-bl', $2, 'i', $3, '2026-01-01')`, [`at://${ALICE}/social.grain.graph.block/dave`, ALICE, DAVE], ); await db.run(`INSERT INTO _mutes (did, subject, created_at) VALUES ($1, $2, 'i')`, [ALICE, ERIN]); // Otto blocked Alice, so the block runs the other way from Dave's. await db.run( `INSERT INTO "social.grain.graph.block" (uri, cid, did, indexed_at, subject, created_at) VALUES ($1, 'cid-bl-o', $2, 'i', $3, '2026-01-01')`, [`at://${OTTO}/social.grain.graph.block/alice`, OTTO, ALICE], );
// Zara's unseen pile: two that count, two that must not. await favorite(IVY, GZ, "2026-02-01", "z1"); await favorite(JUDY, GZ, "2026-02-02", "z2"); await favorite(MORT, GZ, "2026-02-03", "z3"); // taken down await favorite(NELL, GZ, "2026-02-04", "z4"); // Zara blocked her await db.run( `INSERT INTO "social.grain.graph.block" (uri, cid, did, indexed_at, subject, created_at) VALUES ($1, 'cid-bl-n', $2, 'i', $3, '2026-01-01')`, [`at://${ZARA}/social.grain.graph.block/nell`, ZARA, NELL], );
// Pat turned favorite pushes off; Quinn only wants them from accounts she follows. await db.run(`INSERT INTO _preferences (did, key, value) VALUES ($1, $2, $3)`, [ PAT, "notificationPrefs", JSON.stringify({ favorites: { push: false, inApp: true, from: "all" } }), ]); await db.run(`INSERT INTO _preferences (did, key, value) VALUES ($1, $2, $3)`, [ QUINN, "notificationPrefs", JSON.stringify({ favorites: { push: true, inApp: true, from: "follows" }, follows: { push: true, inApp: true, from: "follows" }, }), ]); await db.run( `INSERT INTO "social.grain.graph.follow" (uri, cid, did, indexed_at, subject, created_at) VALUES ($1, 'cid-fol', $2, 'i', $3, '2026-01-01')`, [`at://${QUINN}/social.grain.graph.follow/bob`, QUINN, BOB], );});
afterAll(async () => await server?.close());
// isBlockedOrMuted gates every push the three hooks send, so it is worth// testing on its own rather than only through them.describe("isBlockedOrMuted", () => { test("returns an answer instead of throwing", async () => { // Regression: the three existence checks each carried their own LIMIT 1 // before a UNION ALL, which SQLite rejects at parse time. Because // fireOnCommitHooks swallows a hook rejection into a log line, that threw // on every push path and silently turned off favorite, comment and follow // notifications entirely. A thrown error is a different failure from a // `true`, so assert the type, not just the value. await expect(isBlockedOrMuted(server.db, ALICE, BOB)).resolves.toBe(false); });
test("is true when the recipient muted the actor", async () => { expect(await isBlockedOrMuted(server.db, ALICE, ERIN)).toBe(true); });
test("is true when the recipient blocked the actor", async () => { expect(await isBlockedOrMuted(server.db, ALICE, DAVE)).toBe(true); });
test("is true when the actor blocked the recipient", async () => { // Otto blocked Alice; Alice should still not hear from him. expect(await isBlockedOrMuted(server.db, ALICE, OTTO)).toBe(true); });
test("is false between two unrelated accounts", async () => { expect(await isBlockedOrMuted(server.db, ALICE, HANK)).toBe(false); });});
describe("on-commit-favorite", () => { const fav = (subject: string, repo = BOB, createdAt = now()) => fire(favoriteHook, { repo, record: { subject, createdAt } });
test("tells a gallery owner who favorited it", async () => { const [sent, ...rest] = await fav(G1); expect(rest).toEqual([]); expect(sent).toMatchObject({ did: ALICE, title: "New favorite", body: "Bob Ross favorited your gallery", data: { type: "gallery-favorite", uri: G1 }, }); });
test("tells a story owner", async () => { const [sent] = await fav(S1); expect(sent).toMatchObject({ did: ALICE, body: "Bob Ross favorited your story", data: { type: "story-favorite", uri: S1 }, }); });
test("tells a comment author", async () => { const [sent] = await fav(CM1); expect(sent).toMatchObject({ did: HANK, body: "Bob Ross favorited your comment", data: { type: "comment-favorite", uri: CM1 }, }); });
test("falls back to 'Someone' when the actor has no profile", async () => { const [sent] = await fav(G1, FRED); expect(sent.body).toBe("Someone favorited your gallery"); });
test("says nothing about favoriting your own gallery", async () => { expect(await fav(G1, ALICE)).toEqual([]); });
test("ignores deletes", async () => { const sent = await fire(favoriteHook, { repo: BOB, record: { subject: G1, createdAt: now() }, action: "delete", }); expect(sent).toEqual([]); });
test("ignores a favorite with no subject", async () => { expect(await fire(favoriteHook, { repo: BOB, record: { createdAt: now() } })).toEqual([]); });
test("ignores a favorite of something that is not a gallery, story or comment", async () => { expect(await fav("at://did:plc:nobody/social.grain.gallery/gone")).toEqual([]); });
describe("backfill suppression", () => { // Backfill replays a repo's whole history as fresh `create` events, so the // record's own timestamp is the only thing separating it from live traffic. test("stays quiet for a record older than an hour", async () => { expect(await fav(G1, BOB, minutesAgo(90))).toEqual([]); });
test("still notifies for one just under the hour", async () => { expect(await fav(G1, BOB, minutesAgo(30))).toHaveLength(1); });
test("treats a future timestamp as clock skew, not as stale", async () => { expect(await fav(G1, BOB, new Date(Date.now() + 60_000).toISOString())).toHaveLength(1); });
test("fails closed on a record with no usable createdAt", async () => { expect(await fire(favoriteHook, { repo: BOB, record: { subject: G1 } })).toEqual([]); expect( await fire(favoriteHook, { repo: BOB, record: { subject: G1, createdAt: "not a date" } }), ).toEqual([]); }); });
describe("moderation", () => { test("a taken-down account cannot announce itself", async () => { expect(await fav(G1, CAROL)).toEqual([]); });
test("someone the recipient blocked cannot announce themselves", async () => { expect(await fav(G1, DAVE)).toEqual([]); });
test("someone the recipient muted cannot either", async () => { expect(await fav(G1, ERIN)).toEqual([]); });
test("nor can someone who blocked the recipient", async () => { expect(await fav(G1, OTTO)).toEqual([]); }); });
describe("notification preferences", () => { test("respects favorites being switched off", async () => { expect(await fav(GP)).toEqual([]); });
test("with from:follows, notifies only for accounts the recipient follows", async () => { expect(await fav(GQ, BOB)).toHaveLength(1); // Quinn follows Bob expect(await fav(GQ, FRED)).toEqual([]); // she does not follow Fred });
test("defaults to notifying when the recipient has no preferences at all", async () => { expect(await fav(G1)).toHaveLength(1); }); });
describe("badge count", () => { test("is the unseen count plus this notification", async () => { // Zara has four favorites on her gallery, but Mort is taken down and she // blocked Nell, so only two of them count. const [sent] = await fav(GZ); expect(sent.badge).toBe(3); });
test("counts only what arrived after the recipient last looked", async () => { await server.db.run(`INSERT INTO _preferences (did, key, value) VALUES ($1, $2, $3)`, [ ZARA, "lastSeenNotifications", JSON.stringify("2026-02-01T12:00:00Z"), ]); const [sent] = await fav(GZ); expect(sent.badge).toBe(2); // Ivy's 2026-02-01 favorite now predates the mark await server.db.run(`DELETE FROM _preferences WHERE did = $1 AND key = $2`, [ ZARA, "lastSeenNotifications", ]); }); });});
describe("on-commit-comment", () => { test("tells a gallery owner about a comment", async () => { const [sent, ...rest] = await fire(commentHook, { repo: BOB, record: { subject: G1, createdAt: now(), text: "hi" }, }); expect(rest).toEqual([]); expect(sent).toMatchObject({ did: ALICE, title: "New comment", body: "Bob Ross commented on your gallery", data: { type: "gallery-comment", uri: G1 }, }); });
test("tells a story owner about a comment", async () => { const [sent] = await fire(commentHook, { repo: BOB, record: { subject: S1, createdAt: now(), text: "hi" }, }); expect(sent).toMatchObject({ did: ALICE, body: "Bob Ross commented on your story", data: { type: "story-comment", uri: S1 }, }); });
test("a reply notifies both the parent author and the gallery owner", async () => { // Hank wrote the parent comment, Alice owns the gallery it sits on, so one // reply is news to two different people. const sent = await fire(commentHook, { repo: BOB, record: { subject: G1, replyTo: CM1, createdAt: now(), text: "hi" }, }); expect(sent.map((s) => [s.did, s.title])).toEqual([ [HANK, "New reply"], [ALICE, "New comment"], ]); });
test("says nothing about replying to yourself", async () => { const sent = await fire(commentHook, { repo: HANK, record: { subject: G1, replyTo: CM1, createdAt: now(), text: "hi" }, }); // Hank replying to Hank is not news, but Alice still owns the gallery. expect(sent.map((s) => s.did)).toEqual([ALICE]); });
test("a taken-down account cannot announce itself", async () => { expect( await fire(commentHook, { repo: CAROL, record: { subject: G1, createdAt: now() } }), ).toEqual([]); });
test("stays quiet for a backfilled comment", async () => { expect( await fire(commentHook, { repo: BOB, record: { subject: G1, createdAt: minutesAgo(90) } }), ).toEqual([]); });});
describe("on-commit-follow", () => { const follow = (subject: string, repo = BOB, createdAt = now()) => fire(followHook, { repo, record: { subject, createdAt } });
test("tells the followed account who followed them", async () => { const [sent] = await follow(ALICE); expect(sent).toMatchObject({ did: ALICE, title: "New follower", body: "Bob Ross followed you", data: { type: "follow", did: BOB }, }); });
test("ignores a self-follow", async () => { expect(await follow(BOB, BOB)).toEqual([]); });
test("a taken-down account cannot announce itself", async () => { expect(await follow(ALICE, CAROL)).toEqual([]); });
test("someone the recipient blocked or muted cannot either", async () => { expect(await follow(ALICE, DAVE)).toEqual([]); expect(await follow(ALICE, ERIN)).toEqual([]); });
test("stays quiet for a backfilled follow", async () => { expect(await follow(ALICE, BOB, minutesAgo(90))).toEqual([]); });
test("respects a from:follows preference", async () => { expect(await follow(QUINN, BOB)).toHaveLength(1); // Quinn follows Bob back expect(await follow(QUINN, FRED)).toEqual([]); });});