import { count, createUser, db, del, formWithFile, get, html, insertCategory, insertComment, insertDump, insertPlaylist, PNG, post, put, stubNetwork, test, TEST_DIR, } from "./helpers.ts"; import { assert, assertEquals, assertExists, assertMatch, assertNotEquals, } from "@std/assert"; const PAGE = html(`Scraped title `); function readFile(path: string): Uint8Array | null { try { return Deno.readFileSync(`${TEST_DIR}/uploads/${path}`); } catch { return null; } } // ── Create ─────────────────────────────────────────────────────────────────── test("POST /api/dumps: a URL dump takes its title from the page", async () => { const user = await createUser(); using _ = stubNetwork(() => PAGE.clone()); const res = await post("/api/dumps", { auth: user.auth, json: { url: "https://example.com/a?utm_source=x", comment: "look" }, }); assertEquals(res.status, 201); const dump = res.body.data; assertEquals(dump.kind, "url"); assertEquals(dump.title, "Scraped title"); assertMatch(dump.slug, /^scraped-title-[0-9a-f]{8}$/); assertEquals(dump.userId, user.id); assertEquals(dump.isPrivate, false); assertEquals(dump.richContent.thumbnailUrl, "https://example.com/cover.jpg"); const row = db.prepare(`SELECT url, url_canonical FROM dumps WHERE id = ?;`) .get(dump.id) as { url: string; url_canonical: string }; // The exact URL is kept; the canonical key drops the tracking parameter. assertEquals(row.url, "https://example.com/a?utm_source=x"); assertEquals(row.url_canonical, "https://example.com/a"); }); test("POST /api/dumps: a typed title wins; an unreachable page still posts", async () => { const user = await createUser(); { using _ = stubNetwork(() => PAGE.clone()); const res = await post("/api/dumps", { auth: user.auth, json: { url: "https://example.com/b", title: " My title " }, }); assertEquals(res.body.data.title, "My title"); } { using _ = stubNetwork(() => { throw new TypeError("offline"); }); const res = await post("/api/dumps", { auth: user.auth, json: { url: "https://www.offline.example/c" }, }); assertEquals(res.status, 201); assertEquals(res.body.data.title, "offline.example"); } }); test("POST /api/dumps: refuses bad URLs and bodies, and anonymous callers", async () => { const user = await createUser(); using net = stubNetwork(() => PAGE.clone()); for ( const url of ["http://localhost/", "http://192.168.0.1/", "ftp://x/", "no"] ) { const res = await post("/api/dumps", { auth: user.auth, json: { url } }); assertEquals(res.status, 400, url); } assertEquals( (await post("/api/dumps", { auth: user.auth, json: { nope: 1 } })).status, 400, ); assertEquals( (await post("/api/dumps", { json: { url: "https://example.com" } })).status, 401, ); assertEquals(net.calls.length, 0); assertEquals(count(`SELECT COUNT(*) as n FROM dumps;`), 0); }); test("POST /api/dumps: categories are stored and deduplicated", async () => { const user = await createUser(); const music = insertCategory({ slug: "music" }); using _ = stubNetwork(() => PAGE.clone()); const res = await post("/api/dumps", { auth: user.auth, json: { url: "https://example.com/d", categoryIds: [music, music] }, }); assertEquals(res.status, 201); assertEquals(res.body.data.categoryIds, [music]); assertEquals( (await get(`/api/dumps/${res.body.data.id}`)).body.data.categoryIds, [music], ); }); test("POST /api/dumps: an unknown category is refused and nothing is left behind", async () => { const user = await createUser(); using _ = stubNetwork(() => PAGE.clone()); const res = await post("/api/dumps", { auth: user.auth, json: { url: "https://example.com/e", categoryIds: ["no-such-category"] }, }); assertEquals(res.status, 400); assertEquals(count(`SELECT COUNT(*) as n FROM dumps;`), 0); }); test("POST /api/dumps: a file dump is stored and served back", async () => { const user = await createUser(); const res = await post("/api/dumps", { auth: user.auth, form: formWithFile(PNG, "pixel.png", "image/png", { comment: "tiny" }), }); assertEquals(res.status, 201); const dump = res.body.data; assertEquals(dump.kind, "file"); assertEquals(dump.title, "pixel.png"); assertEquals(dump.fileMime, "image/png"); assertEquals(dump.fileSize, PNG.byteLength); assertEquals(dump.comment, "tiny"); assertEquals(readFile(`dumps/${dump.id}`), PNG); const file = await get(`/api/files/${dump.id}`); assertEquals(file.status, 200); assertEquals(file.bytes, PNG); }); test("POST /api/dumps: file type, size of text fields, and a missing file", async () => { const user = await createUser(); const exe = await post("/api/dumps", { auth: user.auth, form: formWithFile(PNG, "x.exe", "application/x-msdownload"), }); assertEquals(exe.status, 400); const longTitle = await post("/api/dumps", { auth: user.auth, form: formWithFile(PNG, "x.png", "image/png", { title: "x".repeat(201) }), }); assertEquals(longTitle.status, 400); const longComment = await post("/api/dumps", { auth: user.auth, form: formWithFile(PNG, "x.png", "image/png", { comment: "x".repeat(5001), }), }); assertEquals(longComment.status, 400); const form = new FormData(); form.set("comment", "no file"); assertEquals( (await post("/api/dumps", { auth: user.auth, form })).status, 400, ); assertEquals(count(`SELECT COUNT(*) as n FROM dumps;`), 0); }); test("POST /api/dumps: a file dump with an unknown category leaves nothing behind", async () => { const user = await createUser(); const res = await post("/api/dumps", { auth: user.auth, form: formWithFile(PNG, "x.png", "image/png", { categoryIds: JSON.stringify(["nope"]), }), }); assertEquals(res.status, 400); assertEquals(count(`SELECT COUNT(*) as n FROM dumps;`), 0); }); test("POST /api/dumps: followers are notified of public dumps, not private ones", async () => { const poster = await createUser(); const follower = await createUser(); db.prepare( `INSERT INTO follows (id, follower_id, followed_user_id, created_at) VALUES (?, ?, ?, ?);`, ).run(crypto.randomUUID(), follower.id, poster.id, new Date().toISOString()); using _ = stubNetwork(() => PAGE.clone()); await post("/api/dumps", { auth: poster.auth, json: { url: "https://example.com/private", isPrivate: true }, }); assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 0); const pub = await post("/api/dumps", { auth: poster.auth, json: { url: "https://example.com/public" }, }); const notes = db.prepare( `SELECT user_id, type, data FROM notifications;`, ).all() as { user_id: string; type: string; data: string }[]; assertEquals(notes.length, 1); assertEquals(notes[0].user_id, follower.id); assertEquals(notes[0].type, "user_dump_posted"); assertEquals(JSON.parse(notes[0].data).dumpId, pub.body.data.id); }); // ── Read ───────────────────────────────────────────────────────────────────── test("GET /api/dumps/:id works by id and by slug", async () => { const user = await createUser(); const { id, slug } = insertDump({ userId: user.id, title: "Hello" }); assertEquals((await get(`/api/dumps/${id}`)).body.data.id, id); assertEquals((await get(`/api/dumps/${slug}`)).body.data.id, id); assertEquals((await get(`/api/dumps/nope`)).status, 404); assertEquals((await get(`/api/dumps/${crypto.randomUUID()}`)).status, 404); }); test("GET /api/dumps/:id: a private dump exists only for its owner", async () => { const owner = await createUser(); const other = await createUser(); const { id } = insertDump({ userId: owner.id, isPrivate: true }); assertEquals( (await get(`/api/dumps/${id}`, { auth: owner.auth })).status, 200, ); assertEquals( (await get(`/api/dumps/${id}`, { auth: other.auth })).status, 404, ); assertEquals((await get(`/api/dumps/${id}`)).status, 404); for (const sub of ["voters", "related", "playlists", "comments"]) { assertEquals((await get(`/api/dumps/${id}/${sub}`)).status, 404, sub); assertEquals( (await get(`/api/dumps/${id}/${sub}`, { auth: owner.auth })).status, 200, sub, ); } }); test("GET /api/dumps: newest first, paginated, private ones for the owner only", async () => { const owner = await createUser(); const other = await createUser(); for (let i = 0; i < 5; i++) { insertDump({ userId: owner.id, title: `d${i}`, createdAt: new Date(Date.UTC(2024, 0, i + 1)).toISOString(), }); } insertDump({ userId: owner.id, title: "secret", isPrivate: true, createdAt: new Date(Date.UTC(2024, 0, 10)).toISOString(), }); const page1 = await get("/api/dumps?limit=2"); assertEquals(page1.body.data.items.map((d: { title: string }) => d.title), [ "d4", "d3", ]); assertEquals(page1.body.data.total, 5); assertEquals(page1.body.data.hasMore, true); const page3 = await get("/api/dumps?limit=2&page=3"); assertEquals(page3.body.data.items.length, 1); assertEquals(page3.body.data.hasMore, false); const mine = await get("/api/dumps?limit=1", { auth: owner.auth }); assertEquals(mine.body.data.items[0].title, "secret"); assertEquals(mine.body.data.total, 6); assertEquals( (await get("/api/dumps", { auth: other.auth })).body.data.total, 5, ); }); test("GET /api/dumps/by-url: matches on the canonical key, respects privacy", async () => { const owner = await createUser(); const other = await createUser(); insertDump({ userId: owner.id, title: "public", url: "https://youtu.be/abc", }); insertDump({ userId: owner.id, title: "private", url: "https://www.youtube.com/watch?v=abc", isPrivate: true, }); const q = encodeURIComponent("https://m.youtube.com/watch?v=abc&t=10"); const anon = await get(`/api/dumps/by-url?url=${q}`); assertEquals(anon.body.data.map((d: { title: string }) => d.title), [ "public", ]); assertEquals(anon.body.data[0].username, owner.username); const mine = await get(`/api/dumps/by-url?url=${q}`, { auth: owner.auth }); assertEquals(mine.body.data.length, 2); assertEquals( (await get(`/api/dumps/by-url?url=${q}`, { auth: other.auth })).body.data .length, 1, ); assertEquals((await get(`/api/dumps/by-url?url=garbage`)).body.data, []); }); test("GET /api/dumps/:id/voters lists voters without private fields", async () => { const owner = await createUser(); const voter = await createUser(); const { id } = insertDump({ userId: owner.id }); db.prepare( `INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`, ).run(id, voter.id, new Date().toISOString()); const res = await get(`/api/dumps/${id}/voters`); assertEquals(res.body.data.total, 1); assertEquals(res.body.data.items[0].username, voter.username); assertEquals(res.body.data.items[0].email, undefined); assertEquals(res.body.data.items[0].passwordHash, undefined); }); test("GET /api/dumps/:id/related: links in both directions, hiding private ones", async () => { const user = await createUser(); const other = await createUser(); const target = insertDump({ userId: user.id, title: "target", url: "https://x.test/t", }); const secret = insertDump({ userId: other.id, title: "secret", isPrivate: true, }); using _ = stubNetwork(() => PAGE.clone()); // A new dump whose comment links the target by its URL and the secret dump by // its page — and itself, which is ignored. const src = await post("/api/dumps", { auth: other.auth, json: { url: "https://example.com/src", comment: `see https://x.test/t and http://anywhere/dumps/${secret.slug} and http://gerbeur.test/dumps/self`, }, }); const srcId = src.body.data.id; const fromSrc = await get(`/api/dumps/${srcId}/related`); assertEquals(fromSrc.body.data.map((d: { title: string }) => d.title), [ "target", ]); const fromSrcAsOwner = await get(`/api/dumps/${srcId}/related`, { auth: other.auth, }); assertEquals(fromSrcAsOwner.body.data.length, 2); const toTarget = await get(`/api/dumps/${target.id}/related`); assertEquals(toTarget.body.data.map((d: { id: string }) => d.id), [srcId]); // Editing the comment away removes the links. await put(`/api/dumps/${srcId}`, { auth: other.auth, json: { comment: "" } }); assertEquals((await get(`/api/dumps/${target.id}/related`)).body.data, []); }); test("GET /api/dumps/:id/playlists: public playlists, plus the viewer's private ones", async () => { const user = await createUser(); const other = await createUser(); const { id } = insertDump({ userId: user.id }); const pub = insertPlaylist({ userId: user.id, title: "Public" }); const priv = insertPlaylist({ userId: user.id, title: "Private", isPublic: false, }); for (const p of [pub, priv]) { db.prepare( `INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) VALUES (?, ?, 0, ?);`, ).run(p.id, id, new Date().toISOString()); } assertEquals( (await get(`/api/dumps/${id}/playlists`, { auth: other.auth })).body.data .map((p: { title: string }) => p.title), ["Public"], ); assertEquals( (await get(`/api/dumps/${id}/playlists`, { auth: user.auth })).body.data .length, 2, ); }); // ── Update ─────────────────────────────────────────────────────────────────── test("PUT /api/dumps/:id: owner and moderator may edit, others may not", async () => { const owner = await createUser(); const other = await createUser(); const mod = await createUser({ role: "moderator" }); const { id, slug } = insertDump({ userId: owner.id, title: "Old" }); assertEquals( (await put(`/api/dumps/${id}`, { auth: other.auth, json: { title: "X" } })) .status, 403, ); assertEquals( (await put(`/api/dumps/${id}`, { json: { title: "X" } })).status, 401, ); const res = await put(`/api/dumps/${slug}`, { auth: owner.auth, json: { title: "New title", comment: "hello" }, }); assertEquals(res.status, 200); assertEquals(res.body.data.title, "New title"); assertMatch(res.body.data.slug, /^new-title-/); assertNotEquals(res.body.data.slug, slug); assertExists(res.body.data.updatedAt); const byMod = await put(`/api/dumps/${id}`, { auth: mod.auth, json: { title: "Moderated" }, }); assertEquals(byMod.status, 200); assertEquals((await get(`/api/dumps/${id}`)).body.data.title, "Moderated"); }); test("PUT /api/dumps/:id: invalid input is 422, a new URL refetches metadata", async () => { const owner = await createUser(); const { id } = insertDump({ userId: owner.id, title: "Old", url: "https://a.test/", }); assertEquals( (await put(`/api/dumps/${id}`, { auth: owner.auth, json: { title: " " } })) .status, 422, ); assertEquals( (await put(`/api/dumps/${id}`, { auth: owner.auth, json: { url: "http://127.0.0.1/" }, })).status, 400, ); using net = stubNetwork(() => PAGE.clone()); // Same URL: no fetch, title kept. await put(`/api/dumps/${id}`, { auth: owner.auth, json: { comment: "c" } }); assertEquals(net.calls.length, 0); const moved = await put(`/api/dumps/${id}`, { auth: owner.auth, json: { url: "https://b.test/?utm_medium=z" }, }); assertEquals(moved.body.data.title, "Scraped title"); assertEquals(net.calls.length, 1); const row = db.prepare(`SELECT url_canonical FROM dumps WHERE id = ?;`).get( id, ) as { url_canonical: string; }; assertEquals(row.url_canonical, "https://b.test"); }); test("PUT /api/dumps/:id: categories are replaced, or kept when omitted", async () => { const owner = await createUser(); const a = insertCategory({ slug: "a" }); const b = insertCategory({ slug: "b" }); const { id } = insertDump({ userId: owner.id }); await put(`/api/dumps/${id}`, { auth: owner.auth, json: { categoryIds: [a] }, }); const kept = await put(`/api/dumps/${id}`, { auth: owner.auth, json: { title: "t" }, }); assertEquals(kept.body.data.categoryIds, [a]); const replaced = await put(`/api/dumps/${id}`, { auth: owner.auth, json: { categoryIds: [b] }, }); assertEquals(replaced.body.data.categoryIds, [b]); const cleared = await put(`/api/dumps/${id}`, { auth: owner.auth, json: { categoryIds: [] }, }); assertEquals(cleared.body.data.categoryIds, []); }); test("PUT /api/dumps/:id: an unknown category changes nothing", async () => { const owner = await createUser(); const a = insertCategory({ slug: "a" }); const { id } = insertDump({ userId: owner.id, title: "Before" }); await put(`/api/dumps/${id}`, { auth: owner.auth, json: { categoryIds: [a] }, }); const res = await put(`/api/dumps/${id}`, { auth: owner.auth, json: { title: "After", categoryIds: [a, "nope"] }, }); assertEquals(res.status, 400); const dump = (await get(`/api/dumps/${id}`)).body.data; assertEquals(dump.title, "Before"); assertEquals(dump.categoryIds, [a]); }); test("PUT /api/dumps/:id/file: an unknown category keeps the old file", async () => { const owner = await createUser(); const original = new TextEncoder().encode("original"); const { id } = insertDump({ userId: owner.id, title: "orig", fileName: "a.txt", fileData: original, }); const res = await put(`/api/dumps/${id}/file`, { auth: owner.auth, form: formWithFile(PNG, "b.png", "image/png", { categoryIds: JSON.stringify(["nope"]), }), }); assertEquals(res.status, 400); assertEquals(readFile(`dumps/${id}`), original); assertEquals((await get(`/api/dumps/${id}`)).body.data.fileName, "a.txt"); }); test("PUT /api/dumps/:id: going private hides the dump from everyone else", async () => { const owner = await createUser(); const { id } = insertDump({ userId: owner.id }); await put(`/api/dumps/${id}`, { auth: owner.auth, json: { isPrivate: true }, }); assertEquals((await get(`/api/dumps/${id}`)).status, 404); await put(`/api/dumps/${id}`, { auth: owner.auth, json: { isPrivate: false }, }); assertEquals((await get(`/api/dumps/${id}`)).status, 200); }); test("PUT /api/dumps/:id: a file dump edits title, comment and privacy", async () => { const owner = await createUser(); const { id } = insertDump({ userId: owner.id, title: "file", fileName: "a.txt", }); const res = await put(`/api/dumps/${id}`, { auth: owner.auth, json: { title: "Renamed", comment: "c", isPrivate: true, url: "https://ignored.test", }, }); assertEquals(res.status, 200); assertEquals(res.body.data.title, "Renamed"); assertEquals(res.body.data.isPrivate, true); assertEquals(res.body.data.url, undefined); }); test("PUT /api/dumps/:id/file replaces a file dump's contents", async () => { const owner = await createUser(); const other = await createUser(); const { id } = insertDump({ userId: owner.id, title: "orig", fileName: "a.txt", }); const form = () => formWithFile(PNG, "b.png", "image/png", { title: "Replaced" }); assertEquals( (await put(`/api/dumps/${id}/file`, { auth: other.auth, form: form() })) .status, 403, ); const res = await put(`/api/dumps/${id}/file`, { auth: owner.auth, form: form(), }); assertEquals(res.status, 200); assertEquals(res.body.data.fileName, "b.png"); assertEquals(res.body.data.title, "Replaced"); assertEquals(readFile(`dumps/${id}`), PNG); const urlDump = insertDump({ userId: owner.id }); assertEquals( (await put(`/api/dumps/${urlDump.id}/file`, { auth: owner.auth, form: form(), })) .status, 400, ); }); test("custom thumbnails: set, served, removed", async () => { const owner = await createUser(); const other = await createUser(); const { id } = insertDump({ userId: owner.id }); assertEquals( (await put(`/api/dumps/${id}/thumbnail`, { auth: other.auth, form: formWithFile(PNG), })).status, 403, ); assertEquals( (await put(`/api/dumps/${id}/thumbnail`, { auth: owner.auth, form: formWithFile( new TextEncoder().encode(""), "x.svg", "image/svg+xml", ), })).status, 400, ); const set = await put(`/api/dumps/${id}/thumbnail`, { auth: owner.auth, form: formWithFile(PNG), }); assertEquals(set.status, 200); assertEquals(set.body.data.thumbnailMime, "image/png"); const thumb = await get(`/api/thumbnails/${id}`); assertEquals(thumb.status, 200); assertEquals(thumb.headers.get("content-type"), "image/png"); assertEquals(thumb.bytes, PNG); const removed = await del(`/api/dumps/${id}/thumbnail`, { auth: owner.auth }); assertEquals(removed.body.data.thumbnailMime, undefined); assertEquals(readFile(`thumbnails/${id}-custom`), null); // A URL dump without its own thumbnail has none to serve. assertEquals((await get(`/api/thumbnails/${id}`)).status, 404); }); test("POST /api/dumps/:id/refresh-metadata: updates, or keeps what it had on failure", async () => { const owner = await createUser(); const { id } = insertDump({ userId: owner.id, title: "Kept", url: "https://example.com/r", richContent: { type: "generic", url: "https://example.com/r", title: "Kept", }, }); { using _ = stubNetwork(() => { throw new TypeError("down"); }); const res = await post(`/api/dumps/${id}/refresh-metadata`, { auth: owner.auth, }); assertEquals(res.status, 502); assertEquals((await get(`/api/dumps/${id}`)).body.data.title, "Kept"); } { using _ = stubNetwork(() => PAGE.clone()); const res = await post(`/api/dumps/${id}/refresh-metadata`, { auth: owner.auth, }); assertEquals(res.status, 200); assertEquals(res.body.data.title, "Scraped title"); } const file = insertDump({ userId: owner.id, fileName: "f.txt" }); assertEquals( (await post(`/api/dumps/${file.id}/refresh-metadata`, { auth: owner.auth })) .status, 400, ); }); // ── Delete ─────────────────────────────────────────────────────────────────── test("DELETE /api/dumps/:id removes the dump, its file and what hangs off it", async () => { const owner = await createUser(); const other = await createUser(); const mod = await createUser({ role: "moderator" }); const { id } = insertDump({ userId: owner.id, fileName: "f.txt" }); insertComment({ dumpId: id, userId: other.id }); db.prepare( `INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`, ) .run(id, other.id, new Date().toISOString()); assertEquals( (await del(`/api/dumps/${id}`, { auth: other.auth })).status, 403, ); assertEquals( (await del(`/api/dumps/${id}`, { auth: owner.auth })).status, 200, ); assertEquals((await get(`/api/dumps/${id}`)).status, 404); assertEquals(readFile(`dumps/${id}`), null); assertEquals(count(`SELECT COUNT(*) as n FROM comments;`), 0); assertEquals(count(`SELECT COUNT(*) as n FROM votes;`), 0); const second = insertDump({ userId: owner.id }); assertEquals( (await del(`/api/dumps/${second.id}`, { auth: mod.auth })).status, 200, ); assertEquals( (await del(`/api/dumps/${second.id}`, { auth: mod.auth })).status, 404, ); }); test("GET /api/users/:username/dumps and /votes respect privacy", async () => { const owner = await createUser(); const other = await createUser(); const pub = insertDump({ userId: owner.id, title: "pub" }); const priv = insertDump({ userId: owner.id, title: "priv", isPrivate: true }); for (const d of [pub, priv]) { db.prepare( `INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`, ) .run(d.id, owner.id, new Date().toISOString()); } for (const sub of ["dumps", "votes"]) { const path = `/api/users/${owner.username}/${sub}`; assertEquals((await get(path)).body.data.total, 1, sub); assertEquals( (await get(path, { auth: other.auth })).body.data.total, 1, sub, ); assertEquals( (await get(path, { auth: owner.auth })).body.data.total, 2, sub, ); } assert((await get(`/api/users/nobody_at_all/dumps`)).status === 404); });