import {
count,
createUser,
db,
del,
formWithFile,
get,
html,
insertCategory,
insertComment,
insertDump,
insertPlaylist,
PNG,
post,
put,
stubNetwork,
test,
TEST_DIR,
} from "../helpers.ts";
import type { Dump, DumpUrlMatch } from "../../../shared/dump.ts";
import type { PaginatedData } from "../../../shared/api.ts";
import type { Playlist } from "../../../shared/playlist.ts";
import type { PublicUser } from "../../../shared/user.ts";
import {
assertEquals,
assertExists,
assertFalse,
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.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.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.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.data.categoryIds, [music]);
assertEquals(
(await get(`/api/dumps/${res.data.id}`)).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.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.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}`)).data.id, id);
assertEquals((await get(`/api/dumps/${slug}`)).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.data.items.map((d) => d.title), [
"d4",
"d3",
]);
assertEquals(page1.data.hasMore, true);
// Pages are told apart by the row past the limit, not by a count.
assertEquals(page1.data.total, undefined);
const page3 = await get>("/api/dumps?limit=2&page=3");
assertEquals(page3.data.items.length, 1);
assertEquals(page3.data.hasMore, false);
const mine = await get>("/api/dumps", {
auth: owner.auth,
});
assertEquals(mine.data.items[0].title, "secret");
assertEquals(mine.data.items.length, 6);
assertEquals(
(await get>("/api/dumps", { auth: other.auth })).data
.items.length,
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.data.map((d) => d.title), [
"public",
]);
assertEquals(anon.data[0].username, owner.username);
const mine = await get(`/api/dumps/by-url?url=${q}`, {
auth: owner.auth,
});
assertEquals(mine.data.length, 2);
assertEquals(
(await get(`/api/dumps/by-url?url=${q}`, {
auth: other.auth,
})).data
.length,
1,
);
assertEquals(
(await get(`/api/dumps/by-url?url=garbage`)).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.data.items.length, 1);
assertEquals(res.data.items[0].username, voter.username);
assertFalse("email" in res.data.items[0]);
assertFalse("passwordHash" in res.data.items[0]);
});
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 another shape of 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://www.x.test/t/?utm_source=feed and http://anywhere/dumps/${secret.slug} and http://gerbeur.test/dumps/self`,
},
});
const srcId = src.data.id;
const fromSrc = await get(`/api/dumps/${srcId}/related`);
assertEquals(fromSrc.data.map((d) => d.title), [
"target",
]);
const fromSrcAsOwner = await get(`/api/dumps/${srcId}/related`, {
auth: other.auth,
});
assertEquals(fromSrcAsOwner.data.length, 2);
const toTarget = await get(`/api/dumps/${target.id}/related`);
assertEquals(toTarget.data.map((d) => 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`)).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 }))
.data
.map((p) => p.title),
["Public"],
);
assertEquals(
(await get(`/api/dumps/${id}/playlists`, { auth: user.auth }))
.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.data.title, "New title");
assertMatch(res.data.slug ?? "", /^new-title-/);
assertNotEquals(res.data.slug, slug);
assertExists(res.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}`)).data.title, "Moderated");
});
test("PUT /api/dumps/:id: invalid input is 400, 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,
400,
);
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.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.data.categoryIds, [a]);
const replaced = await put(`/api/dumps/${id}`, {
auth: owner.auth,
json: { categoryIds: [b] },
});
assertEquals(replaced.data.categoryIds, [b]);
const cleared = await put(`/api/dumps/${id}`, {
auth: owner.auth,
json: { categoryIds: [] },
});
assertEquals(cleared.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}`)).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}`)).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.data.title, "Renamed");
assertEquals(res.data.isPrivate, true);
assertEquals(res.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.data.fileName, "b.png");
assertEquals(res.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("PUT /api/dumps/:id/file: visibility changes with the file, or stays", async () => {
const owner = await createUser();
const other = await createUser();
const { id } = insertDump({ userId: owner.id, fileName: "a.txt" });
const replace = (fields: Record) =>
put(`/api/dumps/${id}/file`, {
auth: owner.auth,
form: formWithFile(PNG, "b.png", "image/png", fields),
});
const visible = async () =>
(await get(`/api/dumps/${id}`, { auth: other.auth })).status === 200;
let res = await replace({ isPrivate: "true" });
assertEquals(res.data.isPrivate, true);
assertEquals(await visible(), false);
assertEquals((await get(`/api/files/${id}`)).status, 404);
// Left out, it stays private.
res = await replace({});
assertEquals(res.data.isPrivate, true);
assertEquals(await visible(), false);
res = await replace({ isPrivate: "false" });
assertEquals(res.data.isPrivate, false);
assertEquals(await visible(), true);
// A refused replacement changes neither.
res = await replace({ isPrivate: "true", categoryIds: '["nope"]' });
assertEquals(res.status, 400);
assertEquals(await visible(), true);
});
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.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.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}`)).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.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,
);
});