~james/core

core/web/src/lib/api/notifications.test.ts -rw-r--r-- 4.4 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
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();
	});
});