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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
452 lines · 17.1 KB Raw History
// getNotifications is a UNION ALL over nine sources, each mapped to a reason
// and hydrated differently. `test/notifications.test.ts` covers the moderation
// filtering with a deliberately small fixture; this file covers the sources
// themselves, the preference filters and the paging, and needs a much larger
// one — hence the separate file rather than a second fixture in that one.

import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { startTestServer } from "@hatk/hatk/test";

const ALICE = "did:plc:alice"; // the viewer
const BOB = "did:plc:bob";
const CAROL = "did:plc:carol";
const ERIN = "did:plc:erin"; // no profile record

const GA = `at://${ALICE}/social.grain.gallery/ga`; // Alice's gallery
const SA = `at://${ALICE}/social.grain.story/sa`; // Alice's story
const GB = `at://${BOB}/social.grain.gallery/gb`; // Bob's gallery
const GM = `at://${BOB}/social.grain.gallery/gm`; // Bob's gallery, mentioning Alice
const CA = `at://${ALICE}/social.grain.comment/ca`; // Alice's comment, on Bob's gallery
const CC = `at://${CAROL}/social.grain.comment/cc`; // Carol's comment, on Bob's gallery

/** A facets blob mentioning `did`, in the shape getReason looks for. */
const mentions = (did: string) =>
  JSON.stringify([
    {
      index: { byteStart: 0, byteEnd: 6 },
      features: [{ $type: "app.bsky.richtext.facet#mention", did }],
    },
  ]);

let server: any;

async function get(query = "", as: string = ALICE) {
  const res = await server.fetchAs(as, `/xrpc/social.grain.unspecced.getNotifications${query}`);
  expect(res.status).toBe(200);
  return res.json();
}

const reasons = (notifications: any[]) => notifications.map((n) => n.reason);
const byReason = (notifications: any[], reason: string) =>
  notifications.find((n) => n.reason === reason);

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, handle] of [
    [ALICE, "alice.test"],
    [BOB, "bob.test"],
    [CAROL, "carol.test"],
    [ERIN, "erin.test"],
  ]) {
    await db.run(`INSERT INTO _repos (did, status, handle) VALUES ($1, 'active', $2)`, [
      did,
      handle,
    ]);
  }
  // Erin has no profile record, so she exercises the handle fallback.
  for (const [did, name] of [
    [ALICE, "Alice A"],
    [BOB, "Bob Ross"],
    [CAROL, "Carol C"],
  ]) {
    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],
    );
  }

  const gallery = async (uri: string, did: string, title: string, extra = {}) => {
    const e = extra as { description?: string; facets?: string };
    await db.run(
      `INSERT INTO "social.grain.gallery" (uri, cid, did, indexed_at, title, description, facets, created_at)
       VALUES ($1, $2, $3, 'i', $4, $5, $6, '2026-05-01')`,
      [uri, `cid-${uri}`, did, title, e.description ?? null, e.facets ?? null],
    );
  };
  await gallery(GA, ALICE, "Alice's gallery");
  await gallery(GB, BOB, "Bob's gallery");
  // A gallery whose description mentions Alice.
  await gallery(GM, BOB, "Bob's other gallery", {
    description: "@alice look at this",
    facets: mentions(ALICE),
  });

  // Two photos on Alice's gallery, inserted out of order so the thumbnail has
  // to come from position 0 rather than from insert order.
  for (const [id, position] of [
    ["p-second", 1],
    ["p-first", 0],
  ] as [string, number][]) {
    const photoUri = `at://${ALICE}/social.grain.photo/${id}`;
    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}', '2026-05-01')`,
      [
        photoUri,
        `cid-${id}`,
        ALICE,
        JSON.stringify({
          $type: "blob",
          ref: { $link: `bafy-${id}` },
          mimeType: "image/jpeg",
          size: 1,
        }),
      ],
    );
    await db.run(
      `INSERT INTO "social.grain.gallery.item" (uri, cid, did, indexed_at, created_at, gallery, item, position)
       VALUES ($1, $2, $3, 'i', '2026-05-01', $4, $5, $6)`,
      [
        `at://${ALICE}/social.grain.gallery.item/${id}`,
        `cid-i-${id}`,
        ALICE,
        GA,
        photoUri,
        position,
      ],
    );
  }

  await db.run(
    `INSERT INTO "social.grain.story" (uri, cid, did, indexed_at, media, aspect_ratio, created_at)
     VALUES ($1, 'cid-sa', $2, 'i', $3, '{"width":9,"height":16}', '2026-05-01')`,
    [
      SA,
      ALICE,
      JSON.stringify({
        $type: "blob",
        ref: { $link: "bafy-sa" },
        mimeType: "image/jpeg",
        size: 1,
      }),
    ],
  );

  const comment = async (
    uri: string,
    did: string,
    subject: string,
    text: string,
    createdAt: string,
    extra: { replyTo?: string; facets?: string } = {},
  ) => {
    await db.run(
      `INSERT INTO "social.grain.comment" (uri, cid, did, indexed_at, created_at, subject, text, reply_to, facets)
       VALUES ($1, $2, $3, 'i', $4, $5, $6, $7, $8)`,
      [
        uri,
        `cid-${uri}`,
        did,
        createdAt,
        subject,
        text,
        extra.replyTo ?? null,
        extra.facets ?? null,
      ],
    );
  };
  const favorite = async (rkey: string, did: string, subject: string, createdAt: string) => {
    await db.run(
      `INSERT INTO "social.grain.favorite" (uri, cid, did, indexed_at, created_at, subject)
       VALUES ($1, $2, $3, 'i', $4, $5)`,
      [`at://${did}/social.grain.favorite/${rkey}`, `cid-${rkey}`, did, createdAt, subject],
    );
  };

  // Alice's own comment on Bob's gallery, and Carol's alongside it.
  await comment(CA, ALICE, GB, "alice's comment", "2026-05-02");
  await comment(CC, CAROL, GB, "carol's comment", "2026-05-03");

  // One event per source, oldest first.
  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-06-01')`,
    [`at://${BOB}/social.grain.graph.follow/alice`, BOB, ALICE],
  );
  await favorite("f-ga", BOB, GA, "2026-06-02");
  await comment(`at://${BOB}/social.grain.comment/c1`, BOB, GA, "nice gallery", "2026-06-03");
  await comment(`at://${BOB}/social.grain.comment/c2`, BOB, GB, "replying to you", "2026-06-04", {
    replyTo: CA,
  });
  await favorite("f-sa", BOB, SA, "2026-06-05");
  await comment(`at://${BOB}/social.grain.comment/c4`, BOB, SA, "nice story", "2026-06-06");
  await favorite("f-ca", BOB, CA, "2026-06-07");
  // A mention inside a reply to someone else's comment.
  await comment(`at://${BOB}/social.grain.comment/c3`, BOB, GB, "@alice see this", "2026-06-08", {
    replyTo: CC,
    facets: mentions(ALICE),
  });
  // A top-level mention on Bob's gallery — no reply_to at all.
  await comment(`at://${BOB}/social.grain.comment/c6`, BOB, GB, "@alice top level", "2026-05-05", {
    facets: mentions(ALICE),
  });
  // A plain comment on Alice's own gallery that also mentions her — the source
  // is "comment", and only the facets promote it to a mention.
  await comment(`at://${BOB}/social.grain.comment/c5`, BOB, GA, "@alice yours", "2026-06-10", {
    facets: mentions(ALICE),
  });
  // Erin follows Alice last, and has no profile.
  await db.run(
    `INSERT INTO "social.grain.graph.follow" (uri, cid, did, indexed_at, subject, created_at)
     VALUES ($1, 'cid-fol-e', $2, 'i', $3, '2026-06-11')`,
    [`at://${ERIN}/social.grain.graph.follow/alice`, ERIN, ALICE],
  );
});

afterAll(async () => await server?.close());

describe("notification sources", () => {
  test("reports every kind of activity on the viewer's content", async () => {
    const { notifications } = await get("?limit=50");
    expect(new Set(reasons(notifications))).toEqual(
      new Set([
        "follow",
        "gallery-favorite",
        "gallery-comment",
        "reply",
        "story-favorite",
        "story-comment",
        "comment-favorite",
        "gallery-comment-mention",
        "gallery-mention",
      ]),
    );
  });

  test("orders them newest first", async () => {
    const { notifications } = await get("?limit=50");
    const times = notifications.map((n: any) => n.createdAt);
    expect([...times].sort().reverse()).toEqual(times);
  });

  test("a favorite on a gallery carries the gallery's title and cover", async () => {
    const n = byReason((await get("?limit=50")).notifications, "gallery-favorite");
    expect(n.galleryUri).toBe(GA);
    expect(n.galleryTitle).toBe("Alice's gallery");
    // The cover is the photo at position 0, which was inserted second.
    expect(n.galleryThumb).toContain("bafy-p-first");
  });

  test("a comment carries its text", async () => {
    const n = byReason((await get("?limit=50")).notifications, "gallery-comment");
    expect(n.commentText).toBe("nice gallery");
    expect(n.galleryUri).toBe(GA);
  });

  test("a reply carries both its own text and the comment it answers", async () => {
    const n = byReason((await get("?limit=50")).notifications, "reply");
    expect(n.commentText).toBe("replying to you");
    expect(n.replyToText).toBe("alice's comment");
  });

  test("a story favorite and comment carry the story and its thumbnail", async () => {
    const { notifications } = await get("?limit=50");
    for (const reason of ["story-favorite", "story-comment"]) {
      const n = byReason(notifications, reason);
      expect(n.storyUri).toBe(SA);
      expect(n.storyThumb).toContain("bafy-sa");
      expect(n.galleryUri).toBeUndefined();
    }
  });

  test("a favorite on a comment resolves through to the gallery it sits on", async () => {
    // The favorited record is Alice's comment; what she needs to see is which
    // gallery that conversation is on, and what she said.
    const n = byReason((await get("?limit=50")).notifications, "comment-favorite");
    expect(n.commentText).toBe("alice's comment");
    expect(n.galleryUri).toBe(GB);
    expect(n.galleryTitle).toBe("Bob's gallery");
  });

  test("a gallery whose description mentions the viewer", async () => {
    const n = byReason((await get("?limit=50")).notifications, "gallery-mention");
    expect(n.galleryUri).toBe(GM);
    expect(n.commentText).toBe("@alice look at this");
  });

  test("a top-level comment mentioning the viewer notifies them", async () => {
    // Regression: the mention source excluded replies to the viewer's own
    // comments with `reply_to NOT IN (...)`, which is NULL — and therefore
    // false — for a top-level comment. It only worked while the viewer had
    // never commented and the subquery was empty, so mentions went silent for
    // everyone the moment they posted their first comment. Alice has a comment
    // in this fixture, which is what makes this test meaningful.
    const { notifications } = await get("?limit=50");
    const top = notifications.find((n: any) => n.commentText === "@alice top level");
    expect(top).toBeDefined();
    expect(top.reason).toBe("gallery-comment-mention");
  });

  test("a comment on the viewer's own gallery is promoted to a mention by its facets", async () => {
    // Source is "comment", not "comment-mention" — the gallery is Alice's, so
    // the mention source excludes it and only getReason's facet check catches it.
    const { notifications } = await get("?limit=50");
    const mention = notifications.find(
      (n: any) => n.reason === "gallery-comment-mention" && n.commentText === "@alice yours",
    );
    expect(mention).toBeDefined();
    expect(mention.galleryUri).toBe(GA);
  });

  test("hydrates the author, falling back to the handle without a profile", async () => {
    const { notifications } = await get("?limit=50");
    const fromBob = notifications.find((n: any) => n.author.did === BOB);
    expect(fromBob.author).toMatchObject({ handle: "bob.test", displayName: "Bob Ross" });

    const fromErin = notifications.find((n: any) => n.author.did === ERIN);
    expect(fromErin.author.handle).toBe("erin.test");
    expect(fromErin.author.displayName).toBeUndefined();
  });

  test("never reports the viewer's own activity", async () => {
    const { notifications } = await get("?limit=50");
    expect(notifications.map((n: any) => n.author.did)).not.toContain(ALICE);
  });
});

describe("unseen count", () => {
  test("counts everything when the viewer has never looked", async () => {
    const { notifications, unseenCount } = await get("?limit=50");
    expect(unseenCount).toBe(notifications.length);
  });

  test("counts only what arrived after the last look", async () => {
    await server.db.run(`INSERT INTO _preferences (did, key, value) VALUES ($1, $2, $3)`, [
      ALICE,
      "lastSeenNotifications",
      JSON.stringify("2026-06-06T00:00:00Z"),
    ]);
    try {
      const { unseenCount } = await get("?limit=50");
      // f-ca, c3, c5 and Erin's follow postdate the mark; the gallery mention
      // is dated with its gallery, in May.
      expect(unseenCount).toBe(4);
    } finally {
      await server.db.run(`DELETE FROM _preferences WHERE did = $1 AND key = $2`, [
        ALICE,
        "lastSeenNotifications",
      ]);
    }
  });

  test("countOnly returns the number without the list", async () => {
    const { notifications, unseenCount } = await get("?countOnly=true&limit=50");
    expect(notifications).toEqual([]);
    expect(unseenCount).toBeGreaterThan(0);
  });
});

describe("notification preferences", () => {
  async function withPrefs(prefs: unknown, fn: () => Promise<void>) {
    await server.db.run(
      `INSERT INTO _preferences (did, key, value) VALUES ($1, 'notificationPrefs', $2)`,
      [ALICE, JSON.stringify(prefs)],
    );
    try {
      await fn();
    } finally {
      await server.db.run(`DELETE FROM _preferences WHERE did = $1 AND key = $2`, [
        ALICE,
        "notificationPrefs",
      ]);
    }
  }

  test("drops a category the viewer turned off in-app", async () => {
    await withPrefs({ favorites: { push: true, inApp: false, from: "all" } }, async () => {
      const { notifications } = await get("?limit=50");
      expect(reasons(notifications)).not.toContain("gallery-favorite");
      expect(reasons(notifications)).not.toContain("story-favorite");
      expect(reasons(notifications)).not.toContain("comment-favorite");
      // Other categories are untouched.
      expect(reasons(notifications)).toContain("follow");
    });
  });

  test("leaves a category alone when only push is off", async () => {
    await withPrefs({ favorites: { push: false, inApp: true, from: "all" } }, async () => {
      expect(reasons((await get("?limit=50")).notifications)).toContain("gallery-favorite");
    });
  });

  test("with from:follows, keeps only activity from accounts the viewer follows", async () => {
    // Alice follows nobody, so a follows-only category empties out.
    await withPrefs({ follows: { push: true, inApp: true, from: "follows" } }, async () => {
      expect(reasons((await get("?limit=50")).notifications)).not.toContain("follow");
    });

    await server.db.run(
      `INSERT INTO "social.grain.graph.follow" (uri, cid, did, indexed_at, subject, created_at)
       VALUES ($1, 'cid-af', $2, 'i', $3, '2026-05-01')`,
      [`at://${ALICE}/social.grain.graph.follow/bob`, ALICE, BOB],
    );
    try {
      await withPrefs({ follows: { push: true, inApp: true, from: "follows" } }, async () => {
        const { notifications } = await get("?limit=50");
        const follows = notifications.filter((n: any) => n.reason === "follow");
        // Bob's follow survives, Erin's does not.
        expect(follows.map((n: any) => n.author.did)).toEqual([BOB]);
      });
    } finally {
      await server.db.run(`DELETE FROM "social.grain.graph.follow" WHERE did = $1`, [ALICE]);
    }
  });

  test("keeps everything when a category has no preference set", async () => {
    await withPrefs({ comments: { push: true, inApp: false, from: "all" } }, async () => {
      expect(reasons((await get("?limit=50")).notifications)).toContain("follow");
    });
  });
});

describe("paging", () => {
  test("pages newest first with a created_at cursor", async () => {
    const first = await get("?limit=3");
    expect(first.notifications).toHaveLength(3);
    expect(first.cursor).toBeTruthy();

    const second = await get(`?limit=3&cursor=${encodeURIComponent(first.cursor)}`);
    const firstTimes = first.notifications.map((n: any) => n.createdAt);
    const secondTimes = second.notifications.map((n: any) => n.createdAt);
    expect(Math.max(...secondTimes.map(Date.parse))).toBeLessThan(
      Math.min(...firstTimes.map(Date.parse)),
    );
  });

  test("stops offering a cursor on the last page", async () => {
    const { notifications, cursor } = await get("?limit=50");
    expect(notifications.length).toBeGreaterThan(0);
    expect(cursor).toBeUndefined();
  });

  test("requires a session", async () => {
    const res = await server.fetch("/xrpc/social.grain.unspecced.getNotifications");
    expect(res.status).toBe(400);
  });
});