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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
191 lines · 6.7 KB Raw History
// The on-login hook, which seeds a grain profile from the user's Bluesky one
// the first time they sign in.
//
// Like the on-commit hooks, `defineHook` hands back the handler untouched, so
// this runs against a real database. `fetch` is stubbed because the hook reads
// the Bluesky profile straight off the user's PDS, and the two record writers
// are stubbed to record what would have been written to it.

import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest";
import { startTestServer } from "@hatk/hatk/test";
import onLogin from "../server/hooks/on-login.ts";

const NEW = "did:plc:newcomer"; // no grain profile at all
const BARE = "did:plc:bare"; // a grain profile with no display name
const DONE = "did:plc:done"; // a grain profile already filled in
const NOSESSION = "did:plc:nosession"; // no oauth session row

const PDS = "https://pds.example";

let server: any;

/** Runs the hook and reports what it tried to do. */
async function fire(did: string) {
  const created: any[] = [];
  const put: any[] = [];
  const ensured: string[] = [];
  await onLogin.handler({
    did,
    db: server.db,
    lookup: (async (_collection: string, _field: string, dids: string[]) => {
      const rows = (await server.db.query(
        `SELECT did, cid, display_name, created_at FROM "social.grain.actor.profile" WHERE did IN (${dids
          .map((_, i) => `$${i + 1}`)
          .join(",")})`,
        dids,
      )) as any[];
      const map = new Map();
      for (const r of rows) {
        map.set(r.did, {
          did: r.did,
          cid: r.cid,
          value: { displayName: r.display_name ?? undefined, createdAt: r.created_at },
        });
      }
      return map;
    }) as any,
    ensureRepo: async (d: string) => void ensured.push(d),
    createRecord: async (collection: string, record: any, opts?: any) => {
      created.push({ collection, record, opts });
      return {};
    },
    putRecord: async (collection: string, rkey: string, record: any) => {
      put.push({ collection, rkey, record });
      return {};
    },
    deleteRecord: async () => {},
  } as any);
  return { created, put, ensured };
}

const BSKY_PROFILE = {
  displayName: "Alice from Bluesky",
  description: "photographer",
  avatar: { $type: "blob", ref: { $link: "bafy-avatar" }, mimeType: "image/jpeg", size: 1 },
};

/** Answers the PDS getRecord call; everything else 404s. */
function stubPds(value: unknown | null, ok = true) {
  vi.stubGlobal("fetch", async (url: string) => {
    if (typeof url === "string" && url.includes("com.atproto.repo.getRecord")) {
      if (!ok) return new Response("nope", { status: 500 });
      return new Response(JSON.stringify({ value }), {
        status: 200,
        headers: { "content-type": "application/json" },
      });
    }
    return new Response("{}", { status: 404 });
  });
}

beforeAll(async () => {
  server = await startTestServer();
  const { db } = server;

  for (const did of [NEW, BARE, DONE, NOSESSION]) {
    await db.run(`INSERT INTO _repos (did, status, handle) VALUES ($1, 'active', $2)`, [
      did,
      `${did.split(":").pop()}.test`,
    ]);
  }

  // Everyone but NOSESSION has signed in, so we know where their PDS is.
  for (const did of [NEW, BARE, DONE]) {
    await db.run(
      `INSERT INTO _oauth_sessions (did, pds_endpoint, access_token, dpop_jkt)
       VALUES ($1, $2, 'tok', 'jkt')`,
      [did, PDS],
    );
  }

  // BARE has a profile record but never set a display name; DONE has one.
  await db.run(
    `INSERT INTO "social.grain.actor.profile" (uri, cid, did, indexed_at, created_at)
     VALUES ($1, 'cid-bare', $2, 'i', '2020-01-01')`,
    [`at://${BARE}/social.grain.actor.profile/self`, BARE],
  );
  await db.run(
    `INSERT INTO "social.grain.actor.profile" (uri, cid, did, indexed_at, display_name, created_at)
     VALUES ($1, 'cid-done', $2, 'i', 'Already Set', '2020-01-01')`,
    [`at://${DONE}/social.grain.actor.profile/self`, DONE],
  );
});

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

describe("on-login", () => {
  test("kicks off a backfill for whoever just signed in", async () => {
    stubPds(BSKY_PROFILE);
    const { ensured } = await fire(NEW);
    expect(ensured).toEqual([NEW]);
  });

  test("creates a grain profile from the Bluesky one on a first sign-in", async () => {
    stubPds(BSKY_PROFILE);
    const { created, put } = await fire(NEW);
    expect(put).toEqual([]);
    expect(created).toHaveLength(1);
    expect(created[0].collection).toBe("social.grain.actor.profile");
    expect(created[0].opts).toEqual({ rkey: "self" });
    expect(created[0].record).toMatchObject({
      displayName: "Alice from Bluesky",
      description: "photographer",
      avatar: BSKY_PROFILE.avatar,
    });
    expect(created[0].record.createdAt).toBeTruthy();
  });

  test("updates an existing but empty profile rather than creating a second", async () => {
    stubPds(BSKY_PROFILE);
    const { created, put } = await fire(BARE);
    expect(created).toEqual([]);
    expect(put).toHaveLength(1);
    expect(put[0]).toMatchObject({ collection: "social.grain.actor.profile", rkey: "self" });
    expect(put[0].record.displayName).toBe("Alice from Bluesky");
  });

  test("keeps the original createdAt when filling in an existing profile", async () => {
    // The account's own history, not the moment we noticed it was blank.
    stubPds(BSKY_PROFILE);
    const { put } = await fire(BARE);
    expect(put[0].record.createdAt).toBe("2020-01-01");
  });

  test("leaves a profile that already has a display name alone", async () => {
    stubPds(BSKY_PROFILE);
    const { created, put } = await fire(DONE);
    expect(created).toEqual([]);
    expect(put).toEqual([]);
  });

  test("writes only the fields Bluesky actually had", async () => {
    stubPds({ displayName: "Just a name" });
    const { created } = await fire(NEW);
    expect(created[0].record.displayName).toBe("Just a name");
    expect(created[0].record).not.toHaveProperty("description");
    expect(created[0].record).not.toHaveProperty("avatar");
  });

  test("writes nothing when there is no session to find the PDS with", async () => {
    stubPds(BSKY_PROFILE);
    const { created, put, ensured } = await fire(NOSESSION);
    expect(ensured).toEqual([NOSESSION]); // the backfill still runs
    expect(created).toEqual([]);
    expect(put).toEqual([]);
  });

  test("writes nothing when the PDS refuses the request", async () => {
    stubPds(BSKY_PROFILE, false);
    const { created, put } = await fire(NEW);
    expect(created).toEqual([]);
    expect(put).toEqual([]);
  });

  test("writes nothing when the PDS has no Bluesky profile to copy", async () => {
    stubPds(null);
    const { created, put } = await fire(NEW);
    expect(created).toEqual([]);
    expect(put).toEqual([]);
  });
});