The Appview for the kipclip.com atproto bookmarking service
at main 340 lines 11 kB view raw
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});