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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
133 lines · 4.9 KB Raw History
// The sitemap behind /sitemap.xml. What matters is what it leaves out: a search
// engine's index cannot blur or warn, so labeled galleries and labeled,
// taken-down or deactivated accounts stay out, as do galleries in a space and empty ones.

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

const ALICE = "did:plc:alice";
const BOB = "did:plc:bob"; // account-labeled
const CAROL = "did:plc:carol"; // taken down
const DAN = "did:plc:dan"; // deactivated
const NOHANDLE = "did:plc:nohandle";

let server: Awaited<ReturnType<typeof startTestServer>>;

async function account(did: string, handle: string | null, status = "active") {
  await server.db.run(`INSERT INTO _repos (did, status, handle) VALUES ($1, $2, $3)`, [
    did,
    status,
    handle,
  ]);
  await server.db.run(
    `INSERT INTO "social.grain.actor.profile" (uri, cid, did, indexed_at, created_at)
     VALUES ($1, 'cid', $2, 'i', '2026-01-01')`,
    [`at://${did}/social.grain.actor.profile/self`, did],
  );
}

async function gallery(
  did: string,
  rkey: string,
  opts: { createdAt?: string; space?: string; empty?: boolean } = {},
) {
  const uri = `at://${did}/social.grain.gallery/${rkey}`;
  const createdAt = opts.createdAt ?? "2026-03-01T00:00:00.000Z";
  await server.db.run(
    `INSERT INTO "social.grain.gallery" (uri, cid, did, space, indexed_at, title, created_at)
     VALUES ($1, $2, $3, $4, 'i', $5, $6)`,
    [uri, `cid-${rkey}`, did, opts.space ?? null, rkey, createdAt],
  );
  if (opts.empty) return uri;
  await server.db.run(
    `INSERT INTO "social.grain.gallery.item" (uri, cid, did, space, indexed_at, created_at, gallery, item, position)
     VALUES ($1, $2, $3, $4, 'i', $5, $6, $7, 0)`,
    [
      `at://${did}/social.grain.gallery.item/${rkey}`,
      `cid-i-${rkey}`,
      did,
      opts.space ?? null,
      createdAt,
      uri,
      `at://${did}/social.grain.photo/${rkey}`,
    ],
  );
  return uri;
}

async function label(uri: string, val: string, neg = 0, cts = "2026-03-02T00:00:00.000Z") {
  await server.db.run(
    `INSERT INTO _labels (src, uri, val, neg, cts) VALUES ('did:plc:mod', $1, $2, $3, $4)`,
    [uri, val, neg, cts],
  );
}

async function sitemap(kind: string, page = 0) {
  const res = await server.fetch(
    `/xrpc/social.grain.unspecced.getSitemap?kind=${kind}&page=${page}`,
  );
  expect(res.status).toBe(200);
  return (await res.json()) as { entries: { path: string; lastmod?: string }[]; pageCount: number };
}

beforeAll(async () => {
  server = await startTestServer();
  await account(ALICE, "alice.test");
  await account(BOB, "bob.test");
  await account(CAROL, "carol.test", "takendown");
  await account(DAN, "dan.test", "deactivated");
  await account(NOHANDLE, null);

  await gallery(ALICE, "public", { createdAt: "2026-03-05T00:00:00.000Z" });
  await gallery(ALICE, "older", { createdAt: "2026-03-01T00:00:00.000Z" });
  await gallery(ALICE, "empty", { empty: true });
  await gallery(ALICE, "inspace", { space: "at://did:plc:group/space/social.grain.group/pool" });
  await label(await gallery(ALICE, "porn"), "porn");
  const selfLabeled = await gallery(ALICE, "selfnsfw");
  await server.db.run(
    `INSERT INTO "social.grain.gallery__labels_self_labels" (parent_uri, parent_did, val) VALUES ($1, $2, 'nudity')`,
    [selfLabeled, ALICE],
  );
  // Labeled, then the label withdrawn: back in.
  const cleared = await gallery(ALICE, "cleared", { createdAt: "2026-03-03T00:00:00.000Z" });
  await label(cleared, "sexual", 0, "2026-03-02T00:00:00.000Z");
  await label(cleared, "sexual", 1, "2026-03-04T00:00:00.000Z");

  await gallery(BOB, "bobs");
  await label(BOB, "!hide");
  await gallery(CAROL, "carols");
  await gallery(DAN, "dans");
  await gallery(NOHANDLE, "anon");
});

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

describe("getSitemap", () => {
  test("galleries: public, non-empty, unlabeled, newest first", async () => {
    const { entries, pageCount } = await sitemap("galleries");
    expect(pageCount).toBe(1);
    expect(entries.map((e) => e.path)).toEqual([
      "/profile/alice.test/gallery/public",
      "/profile/alice.test/gallery/cleared",
      // Same timestamp as `anon`; the URI breaks the tie.
      "/profile/alice.test/gallery/older",
      "/profile/did:plc:nohandle/gallery/anon",
    ]);
    expect(entries[0].lastmod).toBe("2026-03-05T00:00:00.000Z");
  });

  test("profiles: accounts with an indexable gallery, by DID when there is no handle", async () => {
    const { entries } = await sitemap("profiles");
    expect(entries.map((e) => e.path)).toEqual([
      "/profile/alice.test",
      "/profile/did:plc:nohandle",
    ]);
    expect(entries[0].lastmod).toBe("2026-03-05T00:00:00.000Z");
  });

  test("a page past the end is empty", async () => {
    const { entries, pageCount } = await sitemap("galleries", 5);
    expect(entries).toEqual([]);
    expect(pageCount).toBe(1);
  });
});