The Appview for the kipclip.com atproto bookmarking service
1/**
2 * Tests for /api/initial-data endpoint.
3 * Verifies that bookmarks include tags, pagination works correctly,
4 * and all data is returned for each page.
5 *
6 * First-page requests trigger background PDS migrations (fire-and-forget),
7 * so those tests disable resource/op sanitizers.
8 */
9
10import "./test-setup.ts";
11
12import { assertEquals, assertExists } from "@std/assert";
13import { app } from "../main.ts";
14import { initOAuth } from "../lib/oauth-config.ts";
15import { setTestSessionProvider } from "../lib/session.ts";
16import type { SessionResult } from "../lib/session.ts";
17
18initOAuth(new Request("https://kipclip.com"));
19const handler = app.handler();
20
21const TEST_DID = "did:plc:test123";
22const BOOKMARK_COLLECTION = "community.lexicon.bookmarks.bookmark";
23const ANNOTATION_COLLECTION = "com.kipclip.annotation";
24const TAG_COLLECTION = "com.kipclip.tag";
25const PREFERENCES_COLLECTION = "com.kipclip.preferences";
26
27function createSession(
28 onRequest: (method: string, url: string, options?: any) => Response,
29): SessionResult {
30 return {
31 session: {
32 did: TEST_DID,
33 pdsUrl: "https://test.pds.example",
34 handle: "test.handle",
35 makeRequest: (
36 method: string,
37 url: string,
38 options?: any,
39 ): Promise<Response> => {
40 return Promise.resolve(onRequest(method, url, options));
41 },
42 } as any,
43 setCookieHeader: "sid=mock; Path=/; HttpOnly",
44 };
45}
46
47function bookmarkRecord(
48 rkey: string,
49 tags: string[],
50 createdAt = "2026-01-01T00:00:00.000Z",
51) {
52 return {
53 uri: `at://${TEST_DID}/${BOOKMARK_COLLECTION}/${rkey}`,
54 cid: `cid-${rkey}`,
55 value: {
56 subject: `https://example.com/${rkey}`,
57 createdAt,
58 tags,
59 },
60 };
61}
62
63function annotationRecord(rkey: string) {
64 return {
65 uri: `at://${TEST_DID}/${ANNOTATION_COLLECTION}/${rkey}`,
66 cid: `ann-cid-${rkey}`,
67 value: {
68 subject: `at://${TEST_DID}/${BOOKMARK_COLLECTION}/${rkey}`,
69 title: `Title for ${rkey}`,
70 createdAt: "2026-01-01T00:00:00.000Z",
71 },
72 };
73}
74
75function tagRecord(rkey: string, value: string) {
76 return {
77 uri: `at://${TEST_DID}/${TAG_COLLECTION}/${rkey}`,
78 cid: `cid-${rkey}`,
79 value: { value, createdAt: "2026-01-01T00:00:00.000Z" },
80 };
81}
82
83// ---------- First page returns bookmarks with tags ----------
84
85Deno.test({
86 name: "GET /api/initial-data - first page returns bookmarks with their tags",
87 sanitizeResources: false,
88 sanitizeOps: false,
89 async fn() {
90 const bookmarks = [
91 bookmarkRecord("bm1", ["swift", "ios"]),
92 bookmarkRecord("bm2", ["react", "web"]),
93 bookmarkRecord("bm3", []),
94 ];
95 const annotations = [annotationRecord("bm1"), annotationRecord("bm2")];
96 const tags = [
97 tagRecord("t1", "swift"),
98 tagRecord("t2", "ios"),
99 tagRecord("t3", "react"),
100 tagRecord("t4", "web"),
101 ];
102
103 setTestSessionProvider(() =>
104 Promise.resolve(
105 createSession((_method, url) => {
106 if (
107 url.includes("listRecords") &&
108 url.includes(BOOKMARK_COLLECTION)
109 ) {
110 return Response.json({ records: bookmarks });
111 }
112 if (
113 url.includes("listRecords") &&
114 url.includes(ANNOTATION_COLLECTION)
115 ) {
116 return Response.json({ records: annotations });
117 }
118 if (url.includes("listRecords") && url.includes(TAG_COLLECTION)) {
119 return Response.json({ records: tags });
120 }
121 if (
122 url.includes("getRecord") &&
123 url.includes(PREFERENCES_COLLECTION)
124 ) {
125 return Response.json({}, { status: 400 });
126 }
127 return Response.json({});
128 }),
129 )
130 );
131
132 try {
133 const req = new Request("https://kipclip.com/api/initial-data");
134 const res = await handler(req);
135 assertEquals(res.status, 200);
136
137 const body = await res.json();
138
139 // Verify bookmarks include tags
140 assertEquals(body.bookmarks.length, 3);
141 assertEquals(body.bookmarks[0].tags, ["swift", "ios"]);
142 assertEquals(body.bookmarks[1].tags, ["react", "web"]);
143 assertEquals(body.bookmarks[2].tags, []);
144
145 // Verify tags are returned
146 assertEquals(body.tags.length, 4);
147 assertEquals(body.tags[0].value, "swift");
148
149 // Verify enrichment from annotations
150 assertEquals(body.bookmarks[0].title, "Title for bm1");
151 } finally {
152 setTestSessionProvider(null);
153 }
154 },
155});
156
157// ---------- Bookmarks with undefined tags get empty array ----------
158
159Deno.test({
160 name: "GET /api/initial-data - bookmarks without tags field get empty array",
161 sanitizeResources: false,
162 sanitizeOps: false,
163 async fn() {
164 // Simulate old bookmark records that don't have a tags field
165 const bookmarks = [{
166 uri: `at://${TEST_DID}/${BOOKMARK_COLLECTION}/old1`,
167 cid: "cid-old1",
168 value: {
169 subject: "https://example.com/old-page",
170 createdAt: "2024-01-01T00:00:00.000Z",
171 // No tags field — simulates pre-tag bookmarks
172 },
173 }];
174
175 setTestSessionProvider(() =>
176 Promise.resolve(
177 createSession((_method, url) => {
178 if (
179 url.includes("listRecords") &&
180 url.includes(BOOKMARK_COLLECTION)
181 ) {
182 return Response.json({ records: bookmarks });
183 }
184 if (url.includes("listRecords")) {
185 return Response.json({ records: [] });
186 }
187 if (url.includes("getRecord")) {
188 return Response.json({}, { status: 400 });
189 }
190 return Response.json({});
191 }),
192 )
193 );
194
195 try {
196 const req = new Request("https://kipclip.com/api/initial-data");
197 const res = await handler(req);
198 assertEquals(res.status, 200);
199
200 const body = await res.json();
201 assertEquals(body.bookmarks.length, 1);
202 // Should default to empty array, not undefined
203 assertEquals(body.bookmarks[0].tags, []);
204 } finally {
205 setTestSessionProvider(null);
206 }
207 },
208});
209
210// ---------- Subsequent pages also include tags ----------
211
212Deno.test(
213 "GET /api/initial-data - subsequent pages include tags on bookmarks",
214 async () => {
215 const page2Bookmarks = [
216 bookmarkRecord("bm101", ["2d", "art"]),
217 bookmarkRecord("bm102", ["gaming"]),
218 ];
219
220 setTestSessionProvider(() =>
221 Promise.resolve(
222 createSession((_method, url) => {
223 if (
224 url.includes("listRecords") &&
225 url.includes(BOOKMARK_COLLECTION)
226 ) {
227 return Response.json({ records: page2Bookmarks });
228 }
229 if (url.includes("listRecords")) {
230 return Response.json({ records: [] });
231 }
232 return Response.json({});
233 }),
234 )
235 );
236
237 try {
238 // Request a subsequent page (with cursor)
239 const req = new Request(
240 "https://kipclip.com/api/initial-data?bookmarkCursor=page2cursor",
241 );
242 const res = await handler(req);
243 assertEquals(res.status, 200);
244
245 const body = await res.json();
246 assertEquals(body.bookmarks.length, 2);
247 assertEquals(body.bookmarks[0].tags, ["2d", "art"]);
248 assertEquals(body.bookmarks[1].tags, ["gaming"]);
249 } finally {
250 setTestSessionProvider(null);
251 }
252 },
253);
254
255// ---------- First page returns cursor when more pages available ----------
256
257Deno.test({
258 name: "GET /api/initial-data - returns cursor when more bookmarks exist",
259 sanitizeResources: false,
260 sanitizeOps: false,
261 async fn() {
262 const bookmarks = [bookmarkRecord("bm1", ["tag1"])];
263
264 setTestSessionProvider(() =>
265 Promise.resolve(
266 createSession((_method, url) => {
267 if (
268 url.includes("listRecords") &&
269 url.includes(BOOKMARK_COLLECTION)
270 ) {
271 return Response.json({
272 records: bookmarks,
273 cursor: "next-page-cursor",
274 });
275 }
276 if (url.includes("listRecords")) {
277 return Response.json({ records: [] });
278 }
279 if (url.includes("getRecord")) {
280 return Response.json({}, { status: 400 });
281 }
282 return Response.json({});
283 }),
284 )
285 );
286
287 try {
288 const req = new Request("https://kipclip.com/api/initial-data");
289 const res = await handler(req);
290 assertEquals(res.status, 200);
291
292 const body = await res.json();
293 assertExists(body.bookmarkCursor);
294 assertEquals(body.bookmarkCursor, "next-page-cursor");
295 assertEquals(body.bookmarks[0].tags, ["tag1"]);
296 } finally {
297 setTestSessionProvider(null);
298 }
299 },
300});