// getGallery — the deep link to a single gallery — and the EXIF that the // shared gallery hydrator attaches to each photo. // // EXIF numbers are stored scaled by a million, because atproto lexicons have no // decimal type — the client multiplies on the way in (app/lib/utils/image-resize.ts) // and the hydrator divides on the way out. ISO is scaled too, despite being a // whole number already. What a reader wants is "1/250s" and "f/2.8", so the // formatting is where the meaning is. import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { startTestServer } from "@hatk/hatk/test"; const ALICE = "did:plc:alice"; const CAROL = "did:plc:carol"; // taken down const SCALE = 1_000_000; let server: any; async function get(path: string) { return server.fetch(path); } async function getOk(path: string) { const res = await get(path); expect(res.status).toBe(200); return res.json(); } const galleryPath = (uri: string) => `/xrpc/social.grain.unspecced.getGallery?gallery=${encodeURIComponent(uri)}`; beforeAll(async () => { server = await startTestServer(); const { db } = server; for (const [did, handle, status] of [ [ALICE, "alice.test", "active"], [CAROL, "carol.test", "takendown"], ]) { await db.run(`INSERT INTO _repos (did, status, handle) VALUES ($1, $2, $3)`, [ did, status, handle, ]); } const gallery = async (did: string, id: string) => { await db.run( `INSERT INTO "social.grain.gallery" (uri, cid, did, indexed_at, title, created_at) VALUES ($1, $2, $3, 'i', $4, '2026-03-01')`, [`at://${did}/social.grain.gallery/${id}`, `cid-${id}`, did, id], ); }; const photo = async (did: string, galleryId: string, id: string, exif?: Record) => { const photoUri = `at://${did}/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-03-01')`, [ photoUri, `cid-${id}`, did, 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-03-01', $4, $5, 0)`, [ `at://${did}/social.grain.gallery.item/${id}`, `cid-i-${id}`, did, `at://${did}/social.grain.gallery/${galleryId}`, photoUri, ], ); if (exif) { const cols = Object.keys(exif); await db.run( `INSERT INTO "social.grain.photo.exif" (uri, cid, did, indexed_at, photo, created_at${cols.map((c) => `, ${c}`).join("")}) VALUES ($1, $2, $3, 'i', $4, '2026-03-01'${cols.map((_, i) => `, $${i + 5}`).join("")})`, [ `at://${did}/social.grain.photo.exif/${id}`, `cid-e-${id}`, did, photoUri, ...cols.map((c) => exif[c]), ], ); } }; await gallery(ALICE, "g1"); await photo(ALICE, "g1", "p-full", { make: "RICOH IMAGING COMPANY, LTD.", model: "GR III", lens_make: "Ricoh", lens_model: "18.3mm f/2.8", // A fast shutter, a wide aperture and a 28mm-equivalent lens. exposure_time: Math.round(SCALE / 250), f_number: 2.8 * SCALE, focal_length_in35mm_format: 28 * SCALE, // ISO is scaled on the way in too, like the rest — see image-resize.ts. i_s_o: 400 * SCALE, flash: "No flash", date_time_original: "2026-02-14T10:00:00Z", }); await gallery(ALICE, "g2"); // A long exposure, where the shutter speed is a whole number of seconds. await photo(ALICE, "g2", "p-long", { make: "Sony", model: "ILCE-7M3", exposure_time: 4 * SCALE, f_number: 11 * SCALE, focal_length_in35mm_format: 35 * SCALE, }); await gallery(ALICE, "g3"); await photo(ALICE, "g3", "p-bare"); // no EXIF record at all await gallery(CAROL, "gc"); await photo(CAROL, "gc", "p-c"); }); afterAll(async () => await server?.close()); describe("getGallery", () => { test("returns a gallery by its at:// uri", async () => { const { gallery } = await getOk(galleryPath(`at://${ALICE}/social.grain.gallery/g1`)); expect(gallery.uri).toBe(`at://${ALICE}/social.grain.gallery/g1`); expect(gallery.creator.did).toBe(ALICE); expect(gallery.items).toHaveLength(1); }); test("accepts a handle in the uri's authority position", async () => { const { gallery } = await getOk(galleryPath("at://alice.test/social.grain.gallery/g1")); expect(gallery.uri).toBe(`at://${ALICE}/social.grain.gallery/g1`); }); test("refuses a gallery that does not exist", async () => { const res = await get(galleryPath(`at://${ALICE}/social.grain.gallery/nope`)); expect(res.status).toBe(400); }); test("refuses a gallery from a taken-down account", async () => { // A deep link must not bypass what every feed already hides. In practice // hatk's `getRecords` already excludes taken-down repos, so the row never // arrives and the "not found" above fires first — the handler's own // takedown check is unreachable today and removing it fails nothing. It is // defence in depth against `getRecords` changing, not the thing doing the // work here. const res = await get(galleryPath(`at://${CAROL}/social.grain.gallery/gc`)); expect(res.status).toBe(400); }); }); describe("EXIF hydration", () => { const exifOf = async (id: string) => (await getOk(galleryPath(`at://${ALICE}/social.grain.gallery/${id}`))).gallery.items[0].exif; test("renders a fast shutter as a fraction of a second", async () => { expect((await exifOf("g1")).exposureTime).toBe("1/250s"); }); test("renders a long exposure in whole seconds", async () => { expect((await exifOf("g2")).exposureTime).toBe("4s"); }); test("renders the aperture as an f-number to one decimal", async () => { expect((await exifOf("g1")).fNumber).toBe("f/2.8"); expect((await exifOf("g2")).fNumber).toBe("f/11.0"); }); test("renders the focal length in millimetres", async () => { expect((await exifOf("g1")).focalLengthIn35mmFormat).toBe("28mm"); expect((await exifOf("g2")).focalLengthIn35mmFormat).toBe("35mm"); }); test("passes through the fields that need no conversion", async () => { expect(await exifOf("g1")).toMatchObject({ make: "RICOH IMAGING COMPANY, LTD.", model: "GR III", lensMake: "Ricoh", lensModel: "18.3mm f/2.8", iSO: 400, flash: "No flash", }); }); test("leaves out the fields a photo did not record", async () => { const exif = await exifOf("g2"); expect(exif.iSO).toBeUndefined(); expect(exif.lensMake).toBeUndefined(); expect(exif.flash).toBeUndefined(); }); test("attaches no exif at all to a photo that has none", async () => { const { gallery } = await getOk(galleryPath(`at://${ALICE}/social.grain.gallery/g3`)); expect(gallery.items[0].exif).toBeUndefined(); }); });