// The three OpenGraph cards — a gallery, a profile, a story.
//
// They return a satori element tree rather than a rendered image, so what is
// worth asserting is what goes *into* the picture: the right title and handle,
// the right photos, the right fallback when the thing does not exist, and the
// meta a crawler reads. Nothing here rasterises anything.
//
// `defineOG` hands back `{ generate }` untouched, so these run against a real
// database. `fetchImage` and `blobUrl` are stubbed — the first fetches remote
// bytes, the second builds CDN URLs — and `lookup`/`getRecords` stand in for
// hatk's record loaders.
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { startTestServer } from "@hatk/hatk/test";
import galleryOg from "../server/og/gallery.ts";
import profileOg from "../server/og/profile.ts";
import storyOg from "../server/og/story.ts";
import { allFonts, syneBrandFont, fallbackFonts } from "../server/og/fonts.ts";
const ALICE = "did:plc:alice";
const BARE = "did:plc:bare"; // no profile record
let server: any;
/** Every string in the element tree, in document order. */
function textOf(node: any): string[] {
if (node == null) return [];
if (typeof node === "string") return [node];
if (Array.isArray(node)) return node.flatMap(textOf);
return textOf(node.props?.children);
}
/** Every `src` on an
in the tree. */
function imgSrcs(node: any): string[] {
if (node == null || typeof node !== "object") return [];
if (Array.isArray(node)) return node.flatMap(imgSrcs);
const here = node.type === "img" && node.props?.src ? [node.props.src as string] : [];
return [...here, ...imgSrcs(node.props?.children)];
}
/** Every
's style, in document order. */
function imgStyles(node: any): any[] {
if (node == null || typeof node !== "object") return [];
if (Array.isArray(node)) return node.flatMap(imgStyles);
const here = node.type === "img" ? [node.props?.style ?? {}] : [];
return [...here, ...imgStyles(node.props?.children)];
}
/** Builds the context an OG handler expects, and records what it asked for. */
function ctxFor(params: Record) {
const fetched: string[] = [];
return {
fetched,
ctx: {
db: server.db,
params,
// The real one downloads the bytes; a data URL derived from the input is
// enough to tell which image ended up where.
fetchImage: async (url: string) => {
fetched.push(url);
return `data:image/jpeg;base64,${Buffer.from(url).toString("base64url").slice(0, 24)}`;
},
blobUrl: (did: string, ref: any, size?: string) => {
const link = ref?.ref?.$link ?? ref?.$link;
return link ? `https://cdn.test/${did}/${link}${size ? `?s=${size}` : ""}` : null;
},
lookup: async (_collection: string, _field: string, dids: string[]) => {
const rows = (await server.db.query(
`SELECT did, cid, display_name, description, avatar FROM "social.grain.actor.profile"
WHERE did IN (${dids.map((_: any, i: number) => `$${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,
handle: `${r.did.split(":").pop()}.test`,
value: {
displayName: r.display_name ?? undefined,
description: r.description ?? undefined,
avatar: r.avatar ? JSON.parse(r.avatar) : undefined,
},
});
}
return map;
},
getRecords: async (_collection: string, uris: string[]) => {
const rows = (await server.db.query(
`SELECT uri, cid, did, photo, aspect_ratio FROM "social.grain.photo"
WHERE uri IN (${uris.map((_: any, i: number) => `$${i + 1}`).join(",")})`,
uris,
)) as any[];
const map = new Map();
for (const r of rows) {
map.set(r.uri, {
uri: r.uri,
cid: r.cid,
did: r.did,
value: {
photo: JSON.parse(r.photo),
aspectRatio: r.aspect_ratio ? JSON.parse(r.aspect_ratio) : undefined,
},
});
}
return map;
},
} as any,
};
}
const blob = (link: string) => ({
$type: "blob",
ref: { $link: link },
mimeType: "image/jpeg",
size: 1,
});
beforeAll(async () => {
server = await startTestServer();
const { db } = server;
for (const did of [ALICE, BARE]) {
await db.run(`INSERT INTO _repos (did, status, handle) VALUES ($1, 'active', $2)`, [
did,
`${did.split(":").pop()}.test`,
]);
}
await db.run(
`INSERT INTO "social.grain.actor.profile" (uri, cid, did, indexed_at, display_name, description, avatar, created_at)
VALUES ($1, 'cid-p', $2, 'i', 'Alice Anders', 'landscape photographer', $3, '2026-01-01')`,
[`at://${ALICE}/social.grain.actor.profile/self`, ALICE, JSON.stringify(blob("bafy-avatar"))],
);
// Eight photos in one gallery: the card takes at most six.
await db.run(
`INSERT INTO "social.grain.gallery" (uri, cid, did, indexed_at, title, description, created_at)
VALUES ($1, 'cid-g1', $2, 'i', 'Portland Sunsets', 'evenings on the river', '2026-03-01')`,
[`at://${ALICE}/social.grain.gallery/g1`, ALICE],
);
for (let i = 0; i < 8; i++) {
const photoUri = `at://${ALICE}/social.grain.photo/p${i}`;
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-03-01')`,
[photoUri, `cid-p${i}`, ALICE, JSON.stringify(blob(`bafy-p${i}`))],
);
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-03-01', $4, $5, $6)`,
[
`at://${ALICE}/social.grain.gallery.item/i${i}`,
`cid-i${i}`,
ALICE,
`at://${ALICE}/social.grain.gallery/g1`,
photoUri,
i,
],
);
}
// A one-photo gallery, portrait, for the single-photo card.
await db.run(
`INSERT INTO "social.grain.gallery" (uri, cid, did, indexed_at, title, created_at)
VALUES ($1, 'cid-g3', $2, 'i', 'One Tall Tree', '2026-03-03')`,
[`at://${ALICE}/social.grain.gallery/g3`, ALICE],
);
await db.run(
`INSERT INTO "social.grain.photo" (uri, cid, did, indexed_at, photo, aspect_ratio, created_at)
VALUES ($1, 'cid-solo', $2, 'i', $3, '{"width":2,"height":3}', '2026-03-03')`,
[`at://${ALICE}/social.grain.photo/solo`, ALICE, JSON.stringify(blob("bafy-solo"))],
);
await db.run(
`INSERT INTO "social.grain.gallery.item" (uri, cid, did, indexed_at, created_at, gallery, item, position)
VALUES ($1, 'cid-isolo', $2, 'i', '2026-03-03', $3, $4, 0)`,
[
`at://${ALICE}/social.grain.gallery.item/isolo`,
ALICE,
`at://${ALICE}/social.grain.gallery/g3`,
`at://${ALICE}/social.grain.photo/solo`,
],
);
// A gallery with no title text worth speaking of, for the description fallback.
await db.run(
`INSERT INTO "social.grain.gallery" (uri, cid, did, indexed_at, title, created_at)
VALUES ($1, 'cid-g2', $2, 'i', 'Untitled', '2026-03-02')`,
[`at://${ALICE}/social.grain.gallery/g2`, ALICE],
);
await db.run(
`INSERT INTO "social.grain.story" (uri, cid, did, indexed_at, media, aspect_ratio, created_at)
VALUES ($1, 'cid-s1', $2, 'i', $3, '{"width":9,"height":16}', '2026-04-01')`,
[`at://${ALICE}/social.grain.story/s1`, ALICE, JSON.stringify(blob("bafy-story"))],
);
});
afterAll(async () => await server?.close());
describe("gallery card", () => {
test("shows the gallery's title and its author's handle", async () => {
const { ctx } = ctxFor({ actor: ALICE, rkey: "g1" });
const { element } = await galleryOg.generate(ctx);
const text = textOf(element);
expect(text).toContain("Portland Sunsets");
expect(text).toContain("@alice.test");
expect(text).toContain("grain");
});
test("accepts a handle in place of a did", async () => {
const byHandle = await galleryOg.generate(ctxFor({ actor: "alice.test", rkey: "g1" }).ctx);
const byDid = await galleryOg.generate(ctxFor({ actor: ALICE, rkey: "g1" }).ctx);
expect(textOf(byHandle.element)).toEqual(textOf(byDid.element));
expect(byHandle.meta).toEqual(byDid.meta);
});
test("gives a crawler a title and description", async () => {
const { ctx } = ctxFor({ actor: ALICE, rkey: "g1" });
const { meta } = await galleryOg.generate(ctx);
expect(meta).toEqual({
title: "Portland Sunsets by @alice.test — Grain",
description: "evenings on the river",
});
});
test("falls back to a generic description when the gallery has none", async () => {
const { ctx } = ctxFor({ actor: ALICE, rkey: "g2" });
const { meta } = await galleryOg.generate(ctx);
expect(meta!.description).toBe("Photo gallery on Grain");
});
test("lays out at most six photos, however many the gallery holds", async () => {
const { ctx } = ctxFor({ actor: ALICE, rkey: "g1" });
const { element } = await galleryOg.generate(ctx);
const srcs = imgSrcs(element);
// One
per collage placement, plus the author's avatar.
expect(srcs.length).toBeLessThanOrEqual(7);
expect(srcs.length).toBeGreaterThan(1);
});
test("fetches the photo bytes and the avatar, not the CDN urls verbatim", async () => {
const { ctx, fetched } = ctxFor({ actor: ALICE, rkey: "g1" });
const { element } = await galleryOg.generate(ctx);
expect(fetched.some((u) => u.includes("bafy-avatar"))).toBe(true);
expect(fetched.filter((u) => u.includes("bafy-p")).length).toBe(6);
// Nothing in the rendered tree points at the CDN — satori gets data URLs.
expect(imgSrcs(element).every((s) => s.startsWith("data:"))).toBe(true);
});
test("draws a lone photo at its own shape rather than cropping it", async () => {
const { ctx, fetched } = ctxFor({ actor: ALICE, rkey: "g3" });
const { element } = await galleryOg.generate(ctx);
// The avatar is drawn after the collage, so the photo is the first
.
const [photo] = imgStyles(element);
const width = parseFloat(photo.width);
const height = parseFloat(photo.height);
expect(width / height).toBeCloseTo(2 / 3, 2);
// And it is asked for at full size, since it fills the frame.
expect(fetched.some((u) => u.includes("bafy-solo") && u.includes("feed_fullsize"))).toBe(true);
});
test("says so when the gallery does not exist", async () => {
const { ctx } = ctxFor({ actor: ALICE, rkey: "nope" });
const { element } = await galleryOg.generate(ctx);
expect(textOf(element)).toEqual(["Gallery not found"]);
});
test("ships the fonts it needs to draw the title", async () => {
const { ctx } = ctxFor({ actor: ALICE, rkey: "g1" });
const { options } = await galleryOg.generate(ctx);
expect(options!.fonts!.map((f: any) => f.name)).toEqual(allFonts().map((f) => f.name));
});
});
describe("profile card", () => {
test("shows the display name and handle", async () => {
const { ctx } = ctxFor({ actor: ALICE });
const { element } = await profileOg.generate(ctx);
// The description goes to the crawler in `meta` but is deliberately not
// drawn on the card — the card is name, handle and branding.
expect(textOf(element)).toEqual(["Alice Anders", "@alice.test", "grain"]);
});
test("accepts a handle in place of a did", async () => {
const byHandle = await profileOg.generate(ctxFor({ actor: "alice.test" }).ctx);
const byDid = await profileOg.generate(ctxFor({ actor: ALICE }).ctx);
expect(byHandle.meta).toEqual(byDid.meta);
});
test("gives a crawler a title and description", async () => {
const { ctx } = ctxFor({ actor: ALICE });
const { meta } = await profileOg.generate(ctx);
expect(meta).toEqual({
title: "Alice Anders (@alice.test) — Grain",
description: "landscape photographer",
});
});
test("falls back to the did when there is no profile record", async () => {
const { ctx } = ctxFor({ actor: BARE });
const { meta } = await profileOg.generate(ctx);
// No profile means no handle either — the truncated did stands in for both.
expect(meta!.title).toBe(`${BARE.slice(0, 24)} (@${BARE.slice(0, 24)}) — Grain`);
expect(meta!.description).toBe(`@${BARE.slice(0, 24)} on Grain`);
});
test("draws a cover photo from each recent gallery", async () => {
const { ctx, fetched } = ctxFor({ actor: ALICE });
await profileOg.generate(ctx);
// Only position 0 of g1 qualifies; g2 has no items.
expect(fetched.filter((u) => u.includes("bafy-p")).length).toBe(1);
expect(fetched.some((u) => u.includes("bafy-p0"))).toBe(true);
});
});
describe("story card", () => {
test("shows the author and draws the story image", async () => {
const { ctx, fetched } = ctxFor({ actor: ALICE, rkey: "s1" });
const { element } = await storyOg.generate(ctx);
// The card names the author; the handle appears only in the meta title.
expect(textOf(element)).toEqual(["Alice Anders", "grain"]);
expect(fetched.some((u) => u.includes("bafy-story"))).toBe(true);
expect(imgSrcs(element).every((s) => s.startsWith("data:"))).toBe(true);
});
test("accepts a handle in place of a did", async () => {
const byHandle = await storyOg.generate(ctxFor({ actor: "alice.test", rkey: "s1" }).ctx);
const byDid = await storyOg.generate(ctxFor({ actor: ALICE, rkey: "s1" }).ctx);
expect(textOf(byHandle.element)).toEqual(textOf(byDid.element));
expect(byHandle.meta).toEqual(byDid.meta);
});
test("gives a crawler a title", async () => {
const { ctx } = ctxFor({ actor: ALICE, rkey: "s1" });
const { meta } = await storyOg.generate(ctx);
expect(meta).toEqual({
title: "Story by @alice.test — Grain",
description: "Photo story on Grain",
});
});
test("says so when the story does not exist", async () => {
const { ctx } = ctxFor({ actor: ALICE, rkey: "nope" });
const { element } = await storyOg.generate(ctx);
expect(textOf(element)).toEqual(["Story not found"]);
});
});
describe("labeled and space galleries", () => {
// Its own account, so the collage counts in the profile tests above stay put.
const MIXED = "did:plc:mixed";
async function galleryWithPhoto(rkey: string, createdAt: string, space: string | null = null) {
const uri = `at://${MIXED}/social.grain.gallery/${rkey}`;
const photoUri = `at://${MIXED}/social.grain.photo/${rkey}`;
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}`, MIXED, space, `Title ${rkey}`, createdAt],
);
await server.db.run(
`INSERT INTO "social.grain.photo" (uri, cid, did, space, indexed_at, photo, aspect_ratio, created_at)
VALUES ($1, $2, $3, $4, 'i', $5, '{"width":3,"height":2}', $6)`,
[photoUri, `cid-p-${rkey}`, MIXED, space, JSON.stringify(blob(`bafy-${rkey}`)), createdAt],
);
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://${MIXED}/social.grain.gallery.item/${rkey}`,
`cid-i-${rkey}`,
MIXED,
space,
createdAt,
uri,
photoUri,
],
);
return uri;
}
beforeAll(async () => {
await server.db.run(
`INSERT INTO "social.grain.actor.profile" (uri, cid, did, indexed_at, display_name, created_at)
VALUES ($1, 'cid-pm', $2, 'i', 'Mixed', '2026-01-01')`,
[`at://${MIXED}/social.grain.actor.profile/self`, MIXED],
);
await galleryWithPhoto("clean", "2026-05-01");
const labeled = await galleryWithPhoto("labeled", "2026-05-02");
await server.db.run(
`INSERT INTO _labels (src, uri, val, neg, cts) VALUES ('did:plc:mod', $1, 'porn', 0, '2026-05-03')`,
[labeled],
);
const selfLabeled = await galleryWithPhoto("selflabeled", "2026-05-04");
await server.db.run(
`INSERT INTO "social.grain.gallery__labels_self_labels" (parent_uri, parent_did, val) VALUES ($1, $2, 'nudity')`,
[selfLabeled, MIXED],
);
await galleryWithPhoto(
"pooled",
"2026-05-05",
"at://did:plc:group/space/social.grain.group/pool",
);
});
test("a labeled gallery's card carries its title but none of its photos", async () => {
for (const rkey of ["labeled", "selflabeled"]) {
const { ctx, fetched } = ctxFor({ actor: MIXED, rkey });
const { element } = await galleryOg.generate(ctx);
expect(textOf(element)).toContain(`Title ${rkey}`);
expect(fetched.filter((u) => u.includes(`bafy-${rkey}`))).toEqual([]);
}
});
test("a gallery in a space has no public card", async () => {
const { ctx, fetched } = ctxFor({ actor: MIXED, rkey: "pooled" });
const { element } = await galleryOg.generate(ctx);
expect(textOf(element)).toEqual(["Gallery not found"]);
expect(fetched).toEqual([]);
});
test("the profile collage skips labeled and space galleries", async () => {
const { ctx, fetched } = ctxFor({ actor: MIXED });
await profileOg.generate(ctx);
const photos = fetched.filter((u) => u.includes("/bafy-") && !u.includes("avatar"));
expect(photos).toHaveLength(1);
expect(photos[0]).toContain("bafy-clean");
});
});
describe("fonts", () => {
test("loads the brand font and the fallbacks", () => {
expect(syneBrandFont()).toMatchObject({ name: "Syne", weight: 800, style: "normal" });
expect(syneBrandFont().data.byteLength).toBeGreaterThan(0);
expect(fallbackFonts().map((f) => f.name)).toEqual(["Noto Sans JP", "Noto Emoji"]);
expect(allFonts()).toHaveLength(3);
});
test("reads each font file once and caches it", () => {
// Every card asks for the whole set; re-reading three files per request
// would be a per-request disk hit for bytes that never change.
expect(syneBrandFont().data).toBe(syneBrandFont().data);
expect(fallbackFonts()[0].data).toBe(fallbackFonts()[0].data);
});
});