The Appview for the kipclip.com atproto bookmarking service
1/**
2 * Tests for bulk bookmark operations API.
3 * Tests POST /api/bookmarks/bulk for delete, add-tags, remove-tags.
4 */
5
6import "./test-setup.ts";
7
8import { assertEquals } from "@std/assert";
9import { app } from "../main.ts";
10import { initOAuth } from "../lib/oauth-config.ts";
11import { setTestSessionProvider } from "../lib/session.ts";
12import type { SessionResult } from "../lib/session.ts";
13import type { BulkOperationResponse } from "../shared/types.ts";
14
15initOAuth(new Request("https://kipclip.com"));
16const handler = app.handler();
17
18const TEST_DID = "did:plc:test123";
19const BOOKMARK_COLLECTION = "community.lexicon.bookmarks.bookmark";
20const ANNOTATION_COLLECTION = "com.kipclip.annotation";
21const TAG_COLLECTION = "com.kipclip.tag";
22
23function bulkRequest(body: unknown): Request {
24 return new Request("https://kipclip.com/api/bookmarks/bulk", {
25 method: "POST",
26 headers: { "Content-Type": "application/json" },
27 body: JSON.stringify(body),
28 });
29}
30
31/** Create a mock session with a flexible makeRequest handler. */
32function createBulkSession(
33 onRequest: (method: string, url: string, options?: any) => Response,
34): SessionResult {
35 return {
36 session: {
37 did: TEST_DID,
38 pdsUrl: "https://test.pds.example",
39 handle: "test.handle",
40 makeRequest: (
41 method: string,
42 url: string,
43 options?: any,
44 ): Promise<Response> => {
45 return Promise.resolve(onRequest(method, url, options));
46 },
47 } as any,
48 setCookieHeader: "sid=mock; Path=/; HttpOnly",
49 };
50}
51
52/** Standard bookmark record as returned by getRecord. */
53function bookmarkRecord(rkey: string, tags: string[] = []) {
54 return {
55 uri: `at://${TEST_DID}/${BOOKMARK_COLLECTION}/${rkey}`,
56 cid: `cid-${rkey}`,
57 value: {
58 subject: `https://example.com/${rkey}`,
59 createdAt: "2026-01-01T00:00:00.000Z",
60 tags,
61 },
62 };
63}
64
65// ---------- Validation Tests ----------
66
67Deno.test("POST /api/bookmarks/bulk - returns 401 without auth", async () => {
68 setTestSessionProvider(() =>
69 Promise.resolve({ session: null, setCookieHeader: undefined } as any)
70 );
71
72 const res = await handler(bulkRequest({ action: "delete", uris: ["a"] }));
73 assertEquals(res.status, 401);
74});
75
76Deno.test("POST /api/bookmarks/bulk - rejects missing action", async () => {
77 setTestSessionProvider(() =>
78 Promise.resolve(
79 createBulkSession(() => new Response("{}", { status: 200 })),
80 )
81 );
82
83 const res = await handler(bulkRequest({ uris: ["a"] }));
84 assertEquals(res.status, 400);
85 const body = await res.json();
86 assertEquals(body.error, "action and uris[] are required");
87});
88
89Deno.test("POST /api/bookmarks/bulk - rejects empty uris", async () => {
90 setTestSessionProvider(() =>
91 Promise.resolve(
92 createBulkSession(() => new Response("{}", { status: 200 })),
93 )
94 );
95
96 const res = await handler(bulkRequest({ action: "delete", uris: [] }));
97 assertEquals(res.status, 400);
98});
99
100Deno.test("POST /api/bookmarks/bulk - rejects add-tags without tags", async () => {
101 setTestSessionProvider(() =>
102 Promise.resolve(
103 createBulkSession(() => new Response("{}", { status: 200 })),
104 )
105 );
106
107 const res = await handler(bulkRequest({ action: "add-tags", uris: ["a"] }));
108 assertEquals(res.status, 400);
109 const body = await res.json();
110 assertEquals(body.error, "tags[] is required for tag operations");
111});
112
113// ---------- Delete Tests ----------
114
115Deno.test("POST /api/bookmarks/bulk - delete succeeds", async () => {
116 const applyWritesCalls: any[] = [];
117
118 setTestSessionProvider(() =>
119 Promise.resolve(
120 createBulkSession((_method, url, options) => {
121 if (url.includes("applyWrites")) {
122 const body = JSON.parse(options?.body);
123 applyWritesCalls.push(body);
124 return new Response(JSON.stringify({ results: [] }), {
125 status: 200,
126 headers: { "Content-Type": "application/json" },
127 });
128 }
129 return new Response("{}", { status: 200 });
130 }),
131 )
132 );
133
134 const uris = [
135 `at://${TEST_DID}/${BOOKMARK_COLLECTION}/rkey1`,
136 `at://${TEST_DID}/${BOOKMARK_COLLECTION}/rkey2`,
137 ];
138
139 const res = await handler(bulkRequest({ action: "delete", uris }));
140 assertEquals(res.status, 200);
141
142 const body: BulkOperationResponse = await res.json();
143 assertEquals(body.success, true);
144 assertEquals(body.succeeded, 2);
145 assertEquals(body.failed, 0);
146 assertEquals(body.deletedUris, uris);
147
148 // Should have called applyWrites for bookmark deletes
149 const bookmarkDeletes = applyWritesCalls.find((call) =>
150 call.writes.some((w: any) => w.collection === BOOKMARK_COLLECTION)
151 );
152 assertEquals(bookmarkDeletes.writes.length, 2);
153 assertEquals(
154 bookmarkDeletes.writes[0].$type,
155 "com.atproto.repo.applyWrites#delete",
156 );
157});
158
159Deno.test("POST /api/bookmarks/bulk - delete handles partial failure", async () => {
160 let callCount = 0;
161
162 setTestSessionProvider(() =>
163 Promise.resolve(
164 createBulkSession((_method, url) => {
165 if (url.includes("applyWrites")) {
166 callCount++;
167 // First batch succeeds, second fails (if batching by 10)
168 // With 12 items, first batch of 10 succeeds, second of 2 fails
169 if (callCount === 2) {
170 return new Response("PDS error", { status: 500 });
171 }
172 return new Response(JSON.stringify({ results: [] }), {
173 status: 200,
174 headers: { "Content-Type": "application/json" },
175 });
176 }
177 return new Response("{}", { status: 200 });
178 }),
179 )
180 );
181
182 // Create 12 URIs to force 2 batches (10 + 2)
183 const uris = Array.from(
184 { length: 12 },
185 (_, i) => `at://${TEST_DID}/${BOOKMARK_COLLECTION}/rkey${i}`,
186 );
187
188 const res = await handler(bulkRequest({ action: "delete", uris }));
189 assertEquals(res.status, 200);
190
191 const body: BulkOperationResponse = await res.json();
192 assertEquals(body.success, false);
193 assertEquals(body.succeeded, 10);
194 assertEquals(body.failed, 2);
195 assertEquals(body.errors!.length, 1);
196 // deletedUris should contain exactly the first 10 URIs (first batch succeeded)
197 assertEquals(body.deletedUris!.length, 10);
198 assertEquals(body.deletedUris, uris.slice(0, 10));
199});
200
201// ---------- Add Tags Tests ----------
202
203Deno.test("POST /api/bookmarks/bulk - add-tags succeeds", async () => {
204 setTestSessionProvider(() =>
205 Promise.resolve(
206 createBulkSession((_method, url, options) => {
207 // getRecord for bookmark
208 if (
209 url.includes("getRecord") &&
210 url.includes(BOOKMARK_COLLECTION)
211 ) {
212 const rkey = new URL(url).searchParams.get("rkey") || "rkey1";
213 return new Response(
214 JSON.stringify(bookmarkRecord(rkey, ["existing"])),
215 {
216 status: 200,
217 headers: { "Content-Type": "application/json" },
218 },
219 );
220 }
221 // getRecord for annotation - return 404 (no annotation)
222 if (
223 url.includes("getRecord") &&
224 url.includes(ANNOTATION_COLLECTION)
225 ) {
226 return new Response("Not found", { status: 404 });
227 }
228 // putRecord for bookmark update
229 if (url.includes("putRecord")) {
230 const body = JSON.parse(options?.body);
231 return new Response(
232 JSON.stringify({
233 uri: `at://${TEST_DID}/${BOOKMARK_COLLECTION}/${body.rkey}`,
234 cid: "updated-cid",
235 }),
236 {
237 status: 200,
238 headers: { "Content-Type": "application/json" },
239 },
240 );
241 }
242 // listRecords for tag existence check
243 if (url.includes("listRecords")) {
244 return new Response(
245 JSON.stringify({ records: [] }),
246 {
247 status: 200,
248 headers: { "Content-Type": "application/json" },
249 },
250 );
251 }
252 // createRecord for new tag
253 if (url.includes("createRecord")) {
254 return new Response(
255 JSON.stringify({
256 uri: `at://${TEST_DID}/${TAG_COLLECTION}/newtag`,
257 cid: "tag-cid",
258 }),
259 {
260 status: 200,
261 headers: { "Content-Type": "application/json" },
262 },
263 );
264 }
265 return new Response("{}", { status: 200 });
266 }),
267 )
268 );
269
270 const uris = [`at://${TEST_DID}/${BOOKMARK_COLLECTION}/rkey1`];
271 const res = await handler(
272 bulkRequest({ action: "add-tags", uris, tags: ["newtag"] }),
273 );
274 assertEquals(res.status, 200);
275
276 const body: BulkOperationResponse = await res.json();
277 assertEquals(body.success, true);
278 assertEquals(body.succeeded, 1);
279 assertEquals(body.failed, 0);
280 assertEquals(body.bookmarks!.length, 1);
281 // Should have both existing and new tag
282 assertEquals(body.bookmarks![0].tags!.includes("existing"), true);
283 assertEquals(body.bookmarks![0].tags!.includes("newtag"), true);
284});
285
286// ---------- Remove Tags Tests ----------
287
288Deno.test("POST /api/bookmarks/bulk - remove-tags succeeds", async () => {
289 setTestSessionProvider(() =>
290 Promise.resolve(
291 createBulkSession((_method, url, options) => {
292 if (
293 url.includes("getRecord") &&
294 url.includes(BOOKMARK_COLLECTION)
295 ) {
296 const rkey = new URL(url).searchParams.get("rkey") || "rkey1";
297 return new Response(
298 JSON.stringify(bookmarkRecord(rkey, ["keep", "remove-me"])),
299 {
300 status: 200,
301 headers: { "Content-Type": "application/json" },
302 },
303 );
304 }
305 if (
306 url.includes("getRecord") &&
307 url.includes(ANNOTATION_COLLECTION)
308 ) {
309 return new Response("Not found", { status: 404 });
310 }
311 if (url.includes("putRecord")) {
312 const body = JSON.parse(options?.body);
313 return new Response(
314 JSON.stringify({
315 uri: `at://${TEST_DID}/${BOOKMARK_COLLECTION}/${body.rkey}`,
316 cid: "updated-cid",
317 }),
318 {
319 status: 200,
320 headers: { "Content-Type": "application/json" },
321 },
322 );
323 }
324 return new Response("{}", { status: 200 });
325 }),
326 )
327 );
328
329 const uris = [`at://${TEST_DID}/${BOOKMARK_COLLECTION}/rkey1`];
330 const res = await handler(
331 bulkRequest({ action: "remove-tags", uris, tags: ["remove-me"] }),
332 );
333 assertEquals(res.status, 200);
334
335 const body: BulkOperationResponse = await res.json();
336 assertEquals(body.success, true);
337 assertEquals(body.succeeded, 1);
338 assertEquals(body.bookmarks!.length, 1);
339 assertEquals(body.bookmarks![0].tags, ["keep"]);
340});