The Appview for the kipclip.com atproto bookmarking service
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});