0
2
// The story surface: getStories (the last 24 hours), getStoryArchive (all of
// them, paged), getStoryAuthors (who has one right now), getStory (a single
// one by uri or handle), and the hydrator all but the last share.
//
// Stories expire after 24 hours, so the fixture is dated relative to now.
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"; // Alice blocked him
const ERIN = "did:plc:erin"; // no profile record, only a handle
const hoursAgo = (h: number) => new Date(Date.now() - h * 3600_000).toISOString();
const PDX = "8a28f002358ffff";
let server: any;
async function story(opts: {
id: string;
did: string;
ageHours: number;
location?: { name: string; value: string } | null;
address?: { locality?: string; region?: string; country?: string } | null;
badAspect?: boolean;
selfLabel?: string;
}) {
const { db } = server;
const at = hoursAgo(opts.ageHours);
const uri = `at://${opts.did}/social.grain.story/${opts.id}`;
await db.run(
`INSERT INTO "social.grain.story" (uri, cid, did, indexed_at, media, aspect_ratio, location, address, created_at)
VALUES ($1, $2, $3, 'i', $4, $5, $6, $7, $8)`,
[
uri,
`cid-${opts.id}`,
opts.did,
JSON.stringify({
$type: "blob",
ref: { $link: `bafy-${opts.id}` },
mimeType: "image/jpeg",
size: 1,
}),
opts.badAspect ? "{oops" : '{"width":9,"height":16}',
opts.location ? JSON.stringify(opts.location) : null,
opts.address ? JSON.stringify(opts.address) : null,
at,
],
);
if (opts.selfLabel) {
await db.run(
`INSERT INTO "social.grain.story__labels_self_labels" (parent_uri, parent_did, val)
VALUES ($1, $2, $3)`,
[uri, opts.did, opts.selfLabel],
);
}
return uri;
}
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();
}
/** For the cases where the interesting part is the status code. */
const status = async (path: string) => (await server.fetch(path)).status;
async function post(nsid: string, body: unknown, as?: string) {
const path = `/xrpc/${nsid}`;
const init = {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
};
return as ? server.fetchAs(as, path, init) : server.fetch(path, init);
}
const storyUri = (did: string, id: string) => `at://${did}/social.grain.story/${id}`;
const ids = (stories: any[]) => stories.map((s) => s.uri.split("/").pop());
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)
)`,
);
// Setup scripts do not run under the test server, so the private tables
// the story handlers read are created here, as _mutes is above.
await db.run(
`CREATE TABLE IF NOT EXISTS _story_views (
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"],
]) {
await db.run(`INSERT INTO _repos (did, status, handle) VALUES ($1, $2, $3)`, [
did,
status,
handle,
]);
}
// Everyone but Erin has a profile, so Erin exercises the handle fallback.
for (const [did, name] of [
[ALICE, "Alice A"],
[BOB, "Bob Ross"],
[DAVE, "Dave D"],
]) {
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: three live, one expired, one hidden by a self-label.
await story({ id: "a1", did: ALICE, ageHours: 1 });
await story({
id: "a2",
did: ALICE,
ageHours: 3,
location: { name: "Portland", value: PDX },
address: { locality: "Portland", region: "Oregon", country: "US" },
});
await story({ id: "a3", did: ALICE, ageHours: 5 });
await story({ id: "a-old", did: ALICE, ageHours: 30 });
await story({ id: "a-hidden", did: ALICE, ageHours: 2, selfLabel: "spam" });
// An aspect ratio that is not valid JSON — the hydrator falls back.
await story({ id: "a-bad", did: ALICE, ageHours: 4, badAspect: true });
await story({ id: "b1", did: BOB, ageHours: 2 });
await story({ id: "c1", did: CAROL, ageHours: 2 }); // taken down
await story({ id: "d1", did: DAVE, ageHours: 4 }); // Alice blocked him
await story({ id: "e1", did: ERIN, ageHours: 6 }); // no profile
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],
);
// Bob favorited and commented on Alice's a1.
const a1 = `at://${ALICE}/social.grain.story/a1`;
await db.run(
`INSERT INTO "social.grain.favorite" (uri, cid, did, indexed_at, created_at, subject)
VALUES ($1, 'cid-fav', $2, 'i', $3, $4)`,
[`at://${BOB}/social.grain.favorite/f1`, BOB, hoursAgo(1), a1],
);
for (const [rkey, who] of [
["cm1", BOB],
["cm2", ALICE],
]) {
await db.run(
`INSERT INTO "social.grain.comment" (uri, cid, did, indexed_at, created_at, subject, text)
VALUES ($1, $2, $3, 'i', $4, $5, 'nice')`,
[`at://${who}/social.grain.comment/${rkey}`, `cid-${rkey}`, who, hoursAgo(1), a1],
);
}
// Alice cross-posted a2 to Bluesky; the hydrator finds it by the link text.
await db.run(
`INSERT INTO "app.bsky.feed.post" (uri, cid, did, indexed_at, text, created_at)
VALUES ($1, 'cid-post', $2, 'i', $3, '2026-01-01')`,
[
`at://${ALICE}/app.bsky.feed.post/post1`,
ALICE,
`look at this https://grain.social/profile/${ALICE}/story/a2`,
],
);
// And a3 by handle. Both forms must match.
await db.run(
`INSERT INTO "app.bsky.feed.post" (uri, cid, did, indexed_at, text, created_at)
VALUES ($1, 'cid-post2', $2, 'i', $3, '2026-01-02')`,
[
`at://${ALICE}/app.bsky.feed.post/post2`,
ALICE,
`and this https://grain.social/profile/alice.test/story/a3`,
],
);
});
afterAll(async () => await server?.close());
describe("getStories", () => {
test("returns an actor's last 24 hours, oldest first", async () => {
// Oldest first is deliberate: a story ring plays forward in time.
const { stories } = await get(`/xrpc/social.grain.unspecced.getStories?actor=${ALICE}`);
expect(ids(stories)).toEqual(["a3", "a-bad", "a2", "a1"]);
});
test("drops a story older than 24 hours", async () => {
const { stories } = await get(`/xrpc/social.grain.unspecced.getStories?actor=${ALICE}`);
expect(ids(stories)).not.toContain("a-old");
});
test("drops a story its author labelled with a hide value", async () => {
const { stories } = await get(`/xrpc/social.grain.unspecced.getStories?actor=${ALICE}`);
expect(ids(stories)).not.toContain("a-hidden");
});
test("returns nothing for a taken-down author", async () => {
const { stories } = await get(`/xrpc/social.grain.unspecced.getStories?actor=${CAROL}`);
expect(stories).toEqual([]);
});
test("rejects a request with no actor", async () => {
// `actor` is required by the lexicon, so this is refused before the
// handler runs — its own `if (!actor)` guard is unreachable.
expect(await status("/xrpc/social.grain.unspecced.getStories")).toBe(400);
});
test("attaches the author's profile to every story", async () => {
const { stories } = await get(`/xrpc/social.grain.unspecced.getStories?actor=${ALICE}`);
for (const s of stories) {
expect(s.creator).toMatchObject({ did: ALICE, handle: "alice.test", displayName: "Alice A" });
}
});
test("falls back to the handle when the author has no profile record", async () => {
const { stories } = await get(`/xrpc/social.grain.unspecced.getStories?actor=${ERIN}`);
expect(stories[0].creator).toMatchObject({ did: ERIN, handle: "erin.test" });
expect(stories[0].creator.displayName).toBeUndefined();
});
test("counts comments and marks nothing expired inside the window", async () => {
const { stories } = await get(`/xrpc/social.grain.unspecced.getStories?actor=${ALICE}`);
const a1 = stories.find((s: any) => s.uri.endsWith("/a1"));
expect(a1.commentCount).toBe(2);
expect(a1.expired).toBe(false);
});
test("formats a stored location for display", async () => {
const { stories } = await get(`/xrpc/social.grain.unspecced.getStories?actor=${ALICE}`);
const a2 = stories.find((s: any) => s.uri.endsWith("/a2"));
expect(a2.location).toEqual({ name: "Portland", value: PDX });
expect(a2.address).toMatchObject({ locality: "Portland", region: "Oregon", country: "US" });
expect(a2.locationDisplay).toBe("Portland, Oregon, US");
});
test("links a story back to the Bluesky post that cross-posted it", async () => {
const { stories } = await get(`/xrpc/social.grain.unspecced.getStories?actor=${ALICE}`);
const a2 = stories.find((s: any) => s.uri.endsWith("/a2"));
expect(a2.crossPost).toEqual({
url: `https://bsky.app/profile/${ALICE}/post/post1`,
});
const a1 = stories.find((s: any) => s.uri.endsWith("/a1"));
expect(a1.crossPost).toBeUndefined();
});
test("finds a cross-post that links the story by handle", async () => {
const { stories } = await get(`/xrpc/social.grain.unspecced.getStories?actor=${ALICE}`);
const a3 = stories.find((s: any) => s.uri.endsWith("/a3"));
expect(a3.crossPost).toEqual({ url: `https://bsky.app/profile/${ALICE}/post/post2` });
});
test("accepts a handle in place of a did", async () => {
const { stories } = await get("/xrpc/social.grain.unspecced.getStories?actor=alice.test");
expect(ids(stories)).toEqual(["a3", "a-bad", "a2", "a1"]);
});
test("has nothing for a handle nobody holds", async () => {
const { stories } = await get("/xrpc/social.grain.unspecced.getStories?actor=nobody.test");
expect(stories).toEqual([]);
});
test("falls back to 4:3 when the stored aspect ratio is not valid JSON", async () => {
const { stories } = await get(`/xrpc/social.grain.unspecced.getStories?actor=${ALICE}`);
const bad = stories.find((s: any) => s.uri.endsWith("/a-bad"));
expect(bad).toBeDefined();
expect(bad.aspectRatio).toEqual({ width: 4, height: 3 });
});
test("marks the viewer's own favorite", async () => {
const anon = await get(`/xrpc/social.grain.unspecced.getStories?actor=${ALICE}`);
expect(anon.stories.find((s: any) => s.uri.endsWith("/a1")).viewer).toBeUndefined();
const seen = await get(`/xrpc/social.grain.unspecced.getStories?actor=${ALICE}`, BOB);
const a1 = seen.stories.find((s: any) => s.uri.endsWith("/a1"));
expect(a1.viewer.fav).toBe(`at://${BOB}/social.grain.favorite/f1`);
// Alice did not favorite it, so hers stays bare.
const mine = await get(`/xrpc/social.grain.unspecced.getStories?actor=${ALICE}`, ALICE);
expect(mine.stories.find((s: any) => s.uri.endsWith("/a1")).viewer).toBeUndefined();
});
});
describe("getStoryArchive", () => {
test("returns every story newest first, expiry included", async () => {
const { stories } = await get(`/xrpc/social.grain.unspecced.getStoryArchive?actor=${ALICE}`);
expect(ids(stories)).toEqual(["a1", "a2", "a-bad", "a3", "a-old"]);
});
test("accepts a handle in place of a did", async () => {
const { stories } = await get("/xrpc/social.grain.unspecced.getStoryArchive?actor=alice.test");
expect(ids(stories)).toEqual(["a1", "a2", "a-bad", "a3", "a-old"]);
});
test("marks an expired story expired", async () => {
const { stories } = await get(`/xrpc/social.grain.unspecced.getStoryArchive?actor=${ALICE}`);
const byId = Object.fromEntries(stories.map((s: any) => [s.uri.split("/").pop(), s]));
expect(byId["a-old"].expired).toBe(true);
expect(byId["a1"].expired).toBe(false);
});
test("pages with a created_at cursor", async () => {
// A page can come back shorter than the limit. The LIMIT is applied in
// SQL, but hidden stories are filtered afterwards in the hydrator, so a
// labelled story still consumes a slot on its page. a-hidden is the second
// newest, so the first page of two yields one story. A client has to page
// until the cursor is gone rather than until a page looks short.
const first = await get(`/xrpc/social.grain.unspecced.getStoryArchive?actor=${ALICE}&limit=2`);
expect(ids(first.stories)).toEqual(["a1"]);
expect(first.cursor).toBeTruthy();
const second = await get(
`/xrpc/social.grain.unspecced.getStoryArchive?actor=${ALICE}&limit=2&cursor=${encodeURIComponent(first.cursor)}`,
);
expect(ids(second.stories)).toEqual(["a2", "a-bad"]);
const third = await get(
`/xrpc/social.grain.unspecced.getStoryArchive?actor=${ALICE}&limit=2&cursor=${encodeURIComponent(second.cursor)}`,
);
expect(ids(third.stories)).toEqual(["a3", "a-old"]);
expect(third.cursor).toBeUndefined();
});
test("still hides a self-labelled story", async () => {
const { stories } = await get(`/xrpc/social.grain.unspecced.getStoryArchive?actor=${ALICE}`);
expect(ids(stories)).not.toContain("a-hidden");
});
test("returns nothing for a taken-down author, and rejects a missing actor", async () => {
expect(
(await get(`/xrpc/social.grain.unspecced.getStoryArchive?actor=${CAROL}`)).stories,
).toEqual([]);
expect(await status("/xrpc/social.grain.unspecced.getStoryArchive")).toBe(400);
});
});
describe("getStoryAuthors", () => {
test("lists who has a live story, most recent first", async () => {
const { authors } = await get("/xrpc/social.grain.unspecced.getStoryAuthors");
expect(authors.map((a: any) => a.profile.did)).toEqual([ALICE, BOB, DAVE, ERIN]);
});
test("counts each author's live stories, ignoring expired and hidden ones", async () => {
const { authors } = await get("/xrpc/social.grain.unspecced.getStoryAuthors");
const alice = authors.find((a: any) => a.profile.did === ALICE);
// a1, a2, a3 and a-bad: what getStories serves. a-old is outside the
// window and a-hidden is self-labelled spam, which getStories drops, so
// the count agrees with what the viewer will actually show.
expect(alice.storyCount).toBe(4);
});
test("omits taken-down authors", async () => {
const { authors } = await get("/xrpc/social.grain.unspecced.getStoryAuthors");
expect(authors.map((a: any) => a.profile.did)).not.toContain(CAROL);
});
test("omits authors the viewer blocked", async () => {
const { authors } = await get("/xrpc/social.grain.unspecced.getStoryAuthors", ALICE);
expect(authors.map((a: any) => a.profile.did)).not.toContain(DAVE);
});
test("falls back to the handle for an author with no profile", async () => {
const { authors } = await get("/xrpc/social.grain.unspecced.getStoryAuthors");
const erin = authors.find((a: any) => a.profile.did === ERIN);
expect(erin.profile).toMatchObject({ did: ERIN, handle: "erin.test", cid: "" });
});
});
describe("getStory", () => {
test("returns one story by its at:// uri", async () => {
const uri = `at://${ALICE}/social.grain.story/a2`;
const { story } = await get(
`/xrpc/social.grain.unspecced.getStory?story=${encodeURIComponent(uri)}`,
);
expect(story.uri).toBe(uri);
expect(story.creator).toMatchObject({ did: ALICE, displayName: "Alice A" });
expect(story.aspectRatio).toEqual({ width: 9, height: 16 });
expect(story.location).toEqual({ name: "Portland", value: PDX });
expect(story.crossPost).toEqual({ url: `https://bsky.app/profile/${ALICE}/post/post1` });
});
test("accepts a handle in the uri's authority position", async () => {
const { story } = await get(
`/xrpc/social.grain.unspecced.getStory?story=${encodeURIComponent("at://alice.test/social.grain.story/a2")}`,
);
expect(story.uri).toBe(`at://${ALICE}/social.grain.story/a2`);
});
test("returns a self-label on the story", async () => {
const uri = `at://${ALICE}/social.grain.story/a-hidden`;
const { story } = await get(
`/xrpc/social.grain.unspecced.getStory?story=${encodeURIComponent(uri)}`,
);
// getStory serves a labelled story and reports the label, leaving the
// decision to the client — unlike the list endpoints, which filter it out.
expect(story.labels.map((l: any) => l.val)).toEqual(["spam"]);
});
test("returns nothing for a taken-down author", async () => {
const uri = `at://${CAROL}/social.grain.story/c1`;
expect(
await get(`/xrpc/social.grain.unspecced.getStory?story=${encodeURIComponent(uri)}`),
).toEqual({});
});
test("returns nothing for an unknown story or an unresolvable handle", async () => {
const gone = `at://${ALICE}/social.grain.story/nope`;
expect(
await get(`/xrpc/social.grain.unspecced.getStory?story=${encodeURIComponent(gone)}`),
).toEqual({});
expect(
await get(
`/xrpc/social.grain.unspecced.getStory?story=${encodeURIComponent("at://nobody.test/social.grain.story/a1")}`,
),
).toEqual({});
});
test("rejects a request with no story uri", async () => {
expect(await status("/xrpc/social.grain.unspecced.getStory")).toBe(400);
});
});
// Viewed state is private to the viewer and lives in _story_views. Bob does
// the watching here; nothing he marks is visible to Alice, and nothing he
// marks changes what an anonymous request sees.
describe("markStoriesViewed", () => {
const MARK = "social.grain.unspecced.markStoriesViewed";
const authors = async (as?: string) =>
(await get("/xrpc/social.grain.unspecced.getStoryAuthors", as)).authors;
const alice = async (as?: string) =>
(await authors(as)).find((a: any) => a.profile.did === ALICE);
test("requires a session", async () => {
const res = await post(MARK, { stories: [storyUri(ALICE, "a1")] });
expect(res.status).toBeGreaterThanOrEqual(400);
});
test("rejects an empty list, a non-list, and a list that is too long", async () => {
expect((await post(MARK, { stories: [] }, BOB)).status).toBeGreaterThanOrEqual(400);
expect((await post(MARK, {}, BOB)).status).toBeGreaterThanOrEqual(400);
expect((await post(MARK, { stories: "at://x" }, BOB)).status).toBeGreaterThanOrEqual(400);
expect((await post(MARK, { stories: ["not-a-uri"] }, BOB)).status).toBeGreaterThanOrEqual(400);
const many = Array.from({ length: 101 }, (_, i) => storyUri(ALICE, `x${i}`));
expect((await post(MARK, { stories: many }, BOB)).status).toBeGreaterThanOrEqual(400);
});
test("before anything is watched, an authenticated viewer sees every story unwatched", async () => {
const a = await alice(BOB);
expect(a.unviewedCount).toBe(a.storyCount);
expect(a.lastViewedAt).toBeUndefined();
const { stories } = await get(`/xrpc/social.grain.unspecced.getStories?actor=${ALICE}`, BOB);
expect(stories.some((s: any) => s.viewer?.viewed)).toBe(false);
});
test("an anonymous request carries no viewed state at all", async () => {
const a = await alice();
expect(a.unviewedCount).toBeUndefined();
expect(a.lastViewedAt).toBeUndefined();
});
test("marking a story flags it on the story and moves the author's high-water mark", async () => {
// a3 is the oldest live story; a1 is the newest.
const res = await post(MARK, { stories: [storyUri(ALICE, "a3")] }, BOB);
expect(res.status).toBe(200);
const { stories } = await get(`/xrpc/social.grain.unspecced.getStories?actor=${ALICE}`, BOB);
const a3 = stories.find((s: any) => s.uri.endsWith("/a3"));
const a1 = stories.find((s: any) => s.uri.endsWith("/a1"));
expect(a3.viewer.viewed).toBe(true);
// Bob favorited a1 but has not watched it: the fav stays, viewed stays off.
expect(a1.viewer).toEqual({ fav: `at://${BOB}/social.grain.favorite/f1` });
const a = await alice(BOB);
expect(a.lastViewedAt).toBe(a3.createdAt);
expect(a.unviewedCount).toBe(a.storyCount - 1);
});
test("the single-story endpoint agrees", async () => {
const seen = await get(
`/xrpc/social.grain.unspecced.getStory?story=${storyUri(ALICE, "a3")}`,
BOB,
);
expect(seen.story.viewer).toEqual({ viewed: true });
const unseen = await get(
`/xrpc/social.grain.unspecced.getStory?story=${storyUri(ALICE, "a1")}`,
BOB,
);
expect(unseen.story.viewer).toBeUndefined();
});
test("watching everything catches the viewer up", async () => {
const { stories } = await get(`/xrpc/social.grain.unspecced.getStories?actor=${ALICE}`, BOB);
const res = await post(MARK, { stories: stories.map((s: any) => s.uri) }, BOB);
expect(res.status).toBe(200);
const a = await alice(BOB);
// Every story getStories served is watched, and a-hidden, which it never
// serves, is not counted either: the ring can actually reach grey.
expect(a.unviewedCount).toBe(0);
expect(a.lastViewedAt).toBe(a.latestAt);
});
test("marking twice is not an error and stores one row", async () => {
expect((await post(MARK, { stories: [storyUri(ALICE, "a3")] }, BOB)).status).toBe(200);
const rows = await server.db.query(
`SELECT * FROM _story_views WHERE did = $1 AND subject = $2`,
[BOB, storyUri(ALICE, "a3")],
);
expect(rows).toHaveLength(1);
});
test("a URI that is not a known story is ignored rather than stored", async () => {
expect((await post(MARK, { stories: [storyUri(ALICE, "nope")] }, BOB)).status).toBe(200);
const rows = await server.db.query(`SELECT * FROM _story_views WHERE subject = $1`, [
storyUri(ALICE, "nope"),
]);
expect(rows).toHaveLength(0);
});
test("one account's views are invisible to another", async () => {
const a = await alice(ERIN);
expect(a.lastViewedAt).toBeUndefined();
expect(a.unviewedCount).toBe(a.storyCount);
const { stories } = await get(`/xrpc/social.grain.unspecced.getStories?actor=${ALICE}`, ERIN);
expect(stories.some((s: any) => s.viewer?.viewed)).toBe(false);
});
});