import { describe, expect, it, vi } from "vitest";
import { createBobbinClient } from "#lib/api/client.ts";
import { hydrateNotifications, type NotificationItem } from "#lib/api/notifications.ts";
import type { Fetch } from "#lib/fetch.ts";
const REPO = "did:plc:repo";
const ticket = (rkey: string) => `at://${REPO}/org.tangled.track.ticket/${rkey}`;
const row = (over: Partial<NotificationItem>): NotificationItem => ({
uri: "at://did:plc:actor/org.tangled.temp.notification/1",
type: "issue_created",
category: "work",
actorDid: "did:plc:actor",
read: false,
createdAt: "2026-01-01T00:00:00.000Z",
...over
});
const json = (body: unknown) =>
new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } });
const HANDLES: Record<string, string> = {
"did:plc:actor": "alice.test",
"did:plc:owner": "bob.test"
};
const setup = ({ ticketsStatus = 200 } = {}) => {
const fetch = vi.fn<Fetch>(async (input) => {
const url = new URL(String(input));
if (url.pathname.endsWith("blue.microcosm.identity.resolveMiniDoc")) {
const did = url.searchParams.get("identifier") ?? "";
return json({ did, handle: HANDLES[did] });
}
if (url.pathname.endsWith("org.tangled.repo.getReposByRepoDids"))
return json({
items: [
{
uri: `at://${REPO}/org.tangled.repo.manifest/self`,
value: { declaration: { owner: "did:plc:owner", name: "core" } }
}
]
});
if (!url.pathname.endsWith("org.tangled.track.getTickets"))
return new Response("{}", { status: 404 });
if (ticketsStatus !== 200) return new Response("{}", { status: ticketsStatus });
return json({
items: url.searchParams.getAll("uris").map((uri) => ({
uri,
value: { title: { text: `title of ${uri.split("/").pop()}` } }
}))
});
});
return { fetch, ctx: createBobbinClient({ serviceUrl: "https://bobbin.test", fetch }) };
};
const ticketCalls = (fetch: ReturnType<typeof setup>["fetch"]) =>
fetch.mock.calls
.map(([input]) => new URL(String(input)))
.filter((url) => url.pathname.endsWith("org.tangled.track.getTickets"));
describe("hydrateNotifications titles", () => {
it("reads issue and pull titles from one getTickets call", async () => {
const { ctx, fetch } = setup();
const out = await hydrateNotifications(ctx, [
row({ issueAt: ticket("3001") }),
row({ type: "pull_created", pullAt: ticket("3002") }),
row({ issueAt: ticket("3001") })
]);
expect(out.map((n) => n.subject?.title)).toEqual([
"title of 3001",
"title of 3002",
"title of 3001"
]);
expect(out.map((n) => n.subject?.kind)).toEqual(["issue", "pull", "issue"]);
const calls = ticketCalls(fetch);
expect(calls).toHaveLength(1);
expect(calls[0].searchParams.getAll("uris")).toEqual([ticket("3001"), ticket("3002")]);
});
it("keeps legacy uris out of the batch so they can't fail it", async () => {
const { ctx, fetch } = setup();
const legacy = `at://${REPO}/sh.tangled.repo.issue/3kabc`;
const out = await hydrateNotifications(ctx, [
row({ issueAt: legacy }),
row({ issueAt: ticket("3001") })
]);
expect(ticketCalls(fetch)[0].searchParams.getAll("uris")).toEqual([ticket("3001")]);
expect(out[0].subject).toMatchObject({ kind: "issue", uri: legacy, number: "3kabc" });
expect(out[0].subject?.title).toBeUndefined();
expect(out[1].subject?.title).toBe("title of 3001");
});
it("makes no getTickets call when every uri is legacy", async () => {
const { ctx, fetch } = setup();
await hydrateNotifications(ctx, [
row({ pullAt: `at://${REPO}/sh.tangled.repo.pull/3kabc` })
]);
expect(ticketCalls(fetch)).toHaveLength(0);
});
it("chunks the lookup at fifty uris", async () => {
const { ctx, fetch } = setup();
const uris = Array.from({ length: 55 }, (_, index) => ticket(`r${index}`));
const out = await hydrateNotifications(
ctx,
uris.map((issueAt) => row({ issueAt }))
);
expect(ticketCalls(fetch).map((url) => url.searchParams.getAll("uris").length)).toEqual([
50, 5
]);
expect(out.every((n) => n.subject?.title)).toBe(true);
});
it("keeps hydrating actors and repos when only getTickets fails", async () => {
const { ctx } = setup({ ticketsStatus: 500 });
const out = await hydrateNotifications(ctx, [
row({ repoDid: REPO, issueAt: ticket("3001") })
]);
expect(out[0].actor.handle).toBe("alice.test");
expect(out[0].repo).toEqual({ ownerHandle: "bob.test", name: "core" });
expect(out[0].subject).toMatchObject({ kind: "issue", number: "3001" });
expect(out[0].subject?.title).toBeUndefined();
});
});