The Appview for the kipclip.com atproto bookmarking service
at main 575 lines 17 kB view raw
1/** 2 * Integration tests for the two-phase import API. 3 * Tests prepare (POST /api/import) and process (POST /api/import/:jobId/process). 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 { 13 createMockSessionResult, 14 listRecordsResponse, 15} from "./test-helpers.ts"; 16 17initOAuth(new Request("https://kipclip.com")); 18const handler = app.handler(); 19 20// Helper to create a multipart form request with a file 21function createImportRequest( 22 content: string, 23 filename: string, 24): Request { 25 const formData = new FormData(); 26 formData.append("file", new File([content], filename)); 27 return new Request("https://kipclip.com/api/import", { 28 method: "POST", 29 body: formData, 30 }); 31} 32 33/** Create a mock session that returns OK for applyWrites and listRecords. */ 34function createImportSession( 35 existingRecords: Array<{ uri: string; cid: string; value: unknown }> = [], 36) { 37 const pdsResponses = new Map<string, Response>(); 38 pdsResponses.set("listRecords", listRecordsResponse(existingRecords)); 39 pdsResponses.set( 40 "applyWrites", 41 new Response(JSON.stringify({ results: [] }), { 42 status: 200, 43 headers: { "Content-Type": "application/json" }, 44 }), 45 ); 46 // Default OK for createRecord (tag creation) 47 return createMockSessionResult({ pdsResponses }); 48} 49 50/** Helper to drive an import to completion: prepare + process loop. */ 51async function runFullImport( 52 content: string, 53 filename: string, 54 existingRecords: Array<{ uri: string; cid: string; value: unknown }> = [], 55): Promise<{ prepareBody: any; processBody?: any }> { 56 setTestSessionProvider(() => 57 Promise.resolve(createImportSession(existingRecords)) 58 ); 59 60 const req = createImportRequest(content, filename); 61 const res = await handler(req); 62 const prepareBody = await res.json(); 63 64 if (!prepareBody.jobId) { 65 return { prepareBody }; 66 } 67 68 // Process all chunks 69 let processBody; 70 let done = false; 71 while (!done) { 72 const processReq = new Request( 73 `https://kipclip.com/api/import/${prepareBody.jobId}/process`, 74 { method: "POST" }, 75 ); 76 const processRes = await handler(processReq); 77 processBody = await processRes.json(); 78 done = processBody.done === true; 79 } 80 81 setTestSessionProvider(null); 82 return { prepareBody, processBody }; 83} 84 85Deno.test("POST /api/import - returns 401 when not authenticated", async () => { 86 setTestSessionProvider(null); 87 const req = createImportRequest("test", "bookmarks.html"); 88 const res = await handler(req); 89 90 assertEquals(res.status, 401); 91 const body = await res.json(); 92 assertEquals(body.error, "Authentication required"); 93}); 94 95Deno.test({ 96 name: "POST /api/import - returns 400 for unrecognized format", 97 async fn() { 98 const pdsResponses = new Map<string, Response>(); 99 pdsResponses.set("listRecords", listRecordsResponse([])); 100 101 setTestSessionProvider(() => 102 Promise.resolve(createMockSessionResult({ pdsResponses })) 103 ); 104 105 const req = createImportRequest( 106 "just some random text that is not a bookmark file", 107 "random.txt", 108 ); 109 const res = await handler(req); 110 111 assertEquals(res.status, 400); 112 const body = await res.json(); 113 assertEquals(body.success, false); 114 assertEquals(body.error?.includes("Unrecognized file format"), true); 115 116 setTestSessionProvider(null); 117 }, 118}); 119 120Deno.test({ 121 name: "POST /api/import - returns 400 for empty file", 122 async fn() { 123 setTestSessionProvider(() => Promise.resolve(createMockSessionResult())); 124 125 const req = createImportRequest("", "empty.html"); 126 const res = await handler(req); 127 128 assertEquals(res.status, 400); 129 const body = await res.json(); 130 assertEquals(body.success, false); 131 assertEquals(body.error, "File is empty"); 132 133 setTestSessionProvider(null); 134 }, 135}); 136 137Deno.test({ 138 name: "POST /api/import - returns 400 when no file provided", 139 async fn() { 140 setTestSessionProvider(() => Promise.resolve(createMockSessionResult())); 141 142 // Send form data without a file 143 const formData = new FormData(); 144 formData.append("notfile", "something"); 145 const req = new Request("https://kipclip.com/api/import", { 146 method: "POST", 147 body: formData, 148 }); 149 150 const res = await handler(req); 151 152 assertEquals(res.status, 400); 153 const body = await res.json(); 154 assertEquals(body.success, false); 155 assertEquals(body.error, "No file provided"); 156 157 setTestSessionProvider(null); 158 }, 159}); 160 161Deno.test({ 162 name: 163 "POST /api/import - returns success with 0 imported for empty bookmark file", 164 async fn() { 165 const pdsResponses = new Map<string, Response>(); 166 pdsResponses.set("listRecords", listRecordsResponse([])); 167 168 setTestSessionProvider(() => 169 Promise.resolve(createMockSessionResult({ pdsResponses })) 170 ); 171 172 // Valid Pinboard JSON but no valid HTTP entries 173 const req = createImportRequest( 174 '[{"href":"ftp://not-http.com","description":"Skip me"}]', 175 "pinboard.json", 176 ); 177 const res = await handler(req); 178 179 assertEquals(res.status, 200); 180 const body = await res.json(); 181 assertEquals(body.success, true); 182 assertEquals(body.result.total, 0); 183 assertEquals(body.result.imported, 0); 184 185 setTestSessionProvider(null); 186 }, 187}); 188 189Deno.test({ 190 name: "POST /api/import - prepare creates job for valid import", 191 async fn() { 192 setTestSessionProvider(() => Promise.resolve(createImportSession())); 193 194 const pinboardJson = JSON.stringify([ 195 { 196 href: "https://example.com/one", 197 description: "First Link", 198 extended: "", 199 tags: "tech", 200 time: "2024-01-15T10:30:00Z", 201 }, 202 { 203 href: "https://example.com/two", 204 description: "Second Link", 205 extended: "A description", 206 tags: "blog reading", 207 time: "2024-02-20T14:00:00Z", 208 }, 209 ]); 210 211 const req = createImportRequest(pinboardJson, "pinboard.json"); 212 const res = await handler(req); 213 214 assertEquals(res.status, 200); 215 const body = await res.json(); 216 assertEquals(body.success, true); 217 assertEquals(typeof body.jobId, "string"); 218 assertEquals(body.total, 2); 219 assertEquals(body.skipped, 0); 220 assertEquals(body.toImport, 2); 221 assertEquals(body.totalChunks, 1); 222 assertEquals(body.format, "pinboard"); 223 224 setTestSessionProvider(null); 225 }, 226}); 227 228Deno.test({ 229 name: "POST /api/import - returns result directly when all duplicates", 230 async fn() { 231 setTestSessionProvider(() => 232 Promise.resolve( 233 createImportSession([ 234 { 235 uri: 236 "at://did:plc:test123/community.lexicon.bookmarks.bookmark/existing1", 237 cid: "bafyexisting1", 238 value: { 239 subject: "https://example.com/one", 240 createdAt: "2024-01-01T00:00:00Z", 241 tags: [], 242 }, 243 }, 244 { 245 uri: 246 "at://did:plc:test123/community.lexicon.bookmarks.bookmark/existing2", 247 cid: "bafyexisting2", 248 value: { 249 subject: "https://example.com/two", 250 createdAt: "2024-01-01T00:00:00Z", 251 tags: [], 252 }, 253 }, 254 ]), 255 ) 256 ); 257 258 const pinboardJson = JSON.stringify([ 259 { href: "https://example.com/one", description: "Dup1", tags: "" }, 260 { href: "https://example.com/two", description: "Dup2", tags: "" }, 261 ]); 262 263 const req = createImportRequest(pinboardJson, "pinboard.json"); 264 const res = await handler(req); 265 266 assertEquals(res.status, 200); 267 const body = await res.json(); 268 assertEquals(body.success, true); 269 assertEquals(body.jobId, undefined); 270 assertEquals(body.result.total, 2); 271 assertEquals(body.result.skipped, 2); 272 assertEquals(body.result.imported, 0); 273 274 setTestSessionProvider(null); 275 }, 276}); 277 278Deno.test({ 279 name: "Process: imports chunk and returns done", 280 async fn() { 281 const { prepareBody, processBody } = await runFullImport( 282 JSON.stringify([ 283 { 284 href: "https://example.com/one", 285 description: "First Link", 286 tags: "tech", 287 time: "2024-01-15T10:30:00Z", 288 }, 289 { 290 href: "https://example.com/two", 291 description: "Second Link", 292 tags: "blog", 293 time: "2024-02-20T14:00:00Z", 294 }, 295 ]), 296 "pinboard.json", 297 ); 298 299 assertEquals(prepareBody.success, true); 300 assertEquals(typeof prepareBody.jobId, "string"); 301 assertEquals(processBody.success, true); 302 assertEquals(processBody.done, true); 303 assertEquals(processBody.result.imported, 2); 304 assertEquals(processBody.result.format, "pinboard"); 305 }, 306}); 307 308Deno.test({ 309 name: "Process: partial dedup imports only new bookmarks", 310 async fn() { 311 const { prepareBody, processBody } = await runFullImport( 312 JSON.stringify([ 313 { href: "https://example.com/one", description: "Dup", tags: "" }, 314 { 315 href: "https://example.com/new", 316 description: "New Link", 317 tags: "", 318 }, 319 ]), 320 "pinboard.json", 321 [ 322 { 323 uri: 324 "at://did:plc:test123/community.lexicon.bookmarks.bookmark/existing1", 325 cid: "bafyexisting1", 326 value: { 327 subject: "https://example.com/one", 328 createdAt: "2024-01-01T00:00:00Z", 329 tags: [], 330 }, 331 }, 332 ], 333 ); 334 335 assertEquals(prepareBody.success, true); 336 assertEquals(prepareBody.toImport, 1); 337 assertEquals(prepareBody.skipped, 1); 338 assertEquals(processBody.success, true); 339 assertEquals(processBody.done, true); 340 assertEquals(processBody.result.imported, 1); 341 assertEquals(processBody.result.skipped, 1); 342 assertEquals(processBody.result.total, 2); 343 }, 344}); 345 346Deno.test({ 347 name: "Process: Netscape HTML imports via two-phase flow", 348 async fn() { 349 const { processBody } = await runFullImport( 350 `<!DOCTYPE NETSCAPE-Bookmark-file-1> 351<DL><p> 352<DT><A HREF="https://example.com/page" ADD_DATE="1700000000" TAGS="tech">A Page</A> 353</DL>`, 354 "bookmarks.html", 355 ); 356 357 assertEquals(processBody.success, true); 358 assertEquals(processBody.done, true); 359 assertEquals(processBody.result.format, "netscape"); 360 assertEquals(processBody.result.imported, 1); 361 }, 362}); 363 364Deno.test({ 365 name: "Process: Pocket CSV imports via two-phase flow", 366 async fn() { 367 const { processBody } = await runFullImport( 368 "url,title,tags,time_added\nhttps://example.com/pocket,Pocket Article,tech,1700000000\n", 369 "pocket.csv", 370 ); 371 372 assertEquals(processBody.success, true); 373 assertEquals(processBody.done, true); 374 assertEquals(processBody.result.format, "pocket"); 375 assertEquals(processBody.result.imported, 1); 376 }, 377}); 378 379Deno.test({ 380 name: "Process: Instapaper CSV imports via two-phase flow", 381 async fn() { 382 const { processBody } = await runFullImport( 383 "URL,Title,Selection,Folder\nhttps://example.com/insta,Instapaper Article,,Tech\n", 384 "instapaper.csv", 385 ); 386 387 assertEquals(processBody.success, true); 388 assertEquals(processBody.done, true); 389 assertEquals(processBody.result.format, "instapaper"); 390 assertEquals(processBody.result.imported, 1); 391 }, 392}); 393 394Deno.test({ 395 name: "Process: wrong DID returns 403", 396 async fn() { 397 // Create job with default DID (did:plc:test123) 398 setTestSessionProvider(() => Promise.resolve(createImportSession())); 399 400 const req = createImportRequest( 401 JSON.stringify([ 402 { 403 href: "https://example.com/forbidden", 404 description: "Test", 405 tags: "", 406 }, 407 ]), 408 "pinboard.json", 409 ); 410 const res = await handler(req); 411 const prepareBody = await res.json(); 412 const jobId = prepareBody.jobId; 413 414 // Switch to different DID session 415 const otherSession = createMockSessionResult({ did: "did:plc:other999" }); 416 setTestSessionProvider(() => Promise.resolve(otherSession)); 417 418 const processReq = new Request( 419 `https://kipclip.com/api/import/${jobId}/process`, 420 { method: "POST" }, 421 ); 422 const processRes = await handler(processReq); 423 424 assertEquals(processRes.status, 403); 425 const processBody = await processRes.json(); 426 assertEquals(processBody.success, false); 427 assertEquals(processBody.error, "Forbidden"); 428 429 setTestSessionProvider(null); 430 }, 431}); 432 433Deno.test({ 434 name: "Process: unknown jobId returns 404", 435 async fn() { 436 setTestSessionProvider(() => Promise.resolve(createMockSessionResult())); 437 438 const processReq = new Request( 439 "https://kipclip.com/api/import/nonexistent-job-id/process", 440 { method: "POST" }, 441 ); 442 const processRes = await handler(processReq); 443 444 assertEquals(processRes.status, 404); 445 const body = await processRes.json(); 446 assertEquals(body.success, false); 447 assertEquals(body.error, "Import job not found"); 448 449 setTestSessionProvider(null); 450 }, 451}); 452 453Deno.test({ 454 name: "Process: handles applyWrites failure gracefully", 455 async fn() { 456 const pdsResponses = new Map<string, Response>(); 457 pdsResponses.set("listRecords", listRecordsResponse([])); 458 pdsResponses.set( 459 "applyWrites", 460 new Response(JSON.stringify({ error: "Internal error" }), { 461 status: 500, 462 headers: { "Content-Type": "application/json" }, 463 }), 464 ); 465 466 setTestSessionProvider(() => 467 Promise.resolve(createMockSessionResult({ pdsResponses })) 468 ); 469 470 // Prepare 471 const req = createImportRequest( 472 JSON.stringify([ 473 { 474 href: "https://example.com/fail-test", 475 description: "Will fail", 476 tags: "", 477 }, 478 ]), 479 "pinboard.json", 480 ); 481 const res = await handler(req); 482 const prepareBody = await res.json(); 483 assertEquals(prepareBody.success, true); 484 assertEquals(typeof prepareBody.jobId, "string"); 485 486 // Process — should handle failure 487 const processReq = new Request( 488 `https://kipclip.com/api/import/${prepareBody.jobId}/process`, 489 { method: "POST" }, 490 ); 491 const processRes = await handler(processReq); 492 const processBody = await processRes.json(); 493 494 assertEquals(processRes.status, 200); 495 assertEquals(processBody.success, true); 496 assertEquals(processBody.done, true); 497 assertEquals(processBody.result.imported, 0); 498 assertEquals(processBody.result.failed, 1); 499 500 setTestSessionProvider(null); 501 }, 502}); 503 504Deno.test({ 505 name: "Process: multi-chunk import updates cumulative counters", 506 async fn() { 507 // Generate 201 bookmarks to force 2 chunks (CHUNK_SIZE = 200) 508 const bookmarks = Array.from({ length: 201 }, (_, i) => ({ 509 href: `https://example.com/multi-chunk-${i}`, 510 description: `Link ${i}`, 511 tags: "", 512 })); 513 514 setTestSessionProvider(() => Promise.resolve(createImportSession())); 515 516 const req = createImportRequest( 517 JSON.stringify(bookmarks), 518 "pinboard.json", 519 ); 520 const res = await handler(req); 521 const prepareBody = await res.json(); 522 523 assertEquals(prepareBody.success, true); 524 assertEquals(prepareBody.totalChunks, 2); 525 assertEquals(prepareBody.toImport, 201); 526 527 // Process first chunk 528 const process1Req = new Request( 529 `https://kipclip.com/api/import/${prepareBody.jobId}/process`, 530 { method: "POST" }, 531 ); 532 const process1Res = await handler(process1Req); 533 const process1Body = await process1Res.json(); 534 535 assertEquals(process1Body.success, true); 536 assertEquals(process1Body.done, false); 537 assertEquals(process1Body.imported, 200); 538 assertEquals(process1Body.totalImported, 200); 539 assertEquals(process1Body.remaining, 1); 540 541 // Process second chunk 542 const process2Req = new Request( 543 `https://kipclip.com/api/import/${prepareBody.jobId}/process`, 544 { method: "POST" }, 545 ); 546 const process2Res = await handler(process2Req); 547 const process2Body = await process2Res.json(); 548 549 assertEquals(process2Body.success, true); 550 assertEquals(process2Body.done, true); 551 assertEquals(process2Body.imported, 1); 552 assertEquals(process2Body.totalImported, 201); 553 assertEquals(process2Body.result.imported, 201); 554 assertEquals(process2Body.result.total, 201); 555 556 setTestSessionProvider(null); 557 }, 558}); 559 560Deno.test({ 561 name: "Process: 401 without session", 562 async fn() { 563 setTestSessionProvider(null); 564 565 const processReq = new Request( 566 "https://kipclip.com/api/import/some-job-id/process", 567 { method: "POST" }, 568 ); 569 const processRes = await handler(processReq); 570 571 assertEquals(processRes.status, 401); 572 const body = await processRes.json(); 573 assertEquals(body.error, "Authentication required"); 574 }, 575});