~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
546 lines · 22.3 KB Raw History
// 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);
  });
});