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 } 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");
assertEquals(dump.slug, "scraped-title");
assertEquals(dump.userId, user.id);
assertEquals(dump.ownerUsername, user.username);
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 playlist category is refused", async () => {
const user = await createUser();
const lists = insertCategory({ slug: "mixes", kind: "playlist" });
using _ = stubNetwork(() => PAGE.clone());
const res = await post("/api/dumps", {
auth: user.auth,
json: { url: "https://example.com/e", categoryIds: [lists] },
});
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/dumps/${dump.id}/file`);
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 only", async () => {
const user = await createUser();
const { id, slug } = insertDump({ userId: user.id, title: "Hello" });
assertEquals((await get(`/api/dumps/${id}`)).data.id, id);
// A slug is only unique under its owner: it is read by page path.
assertEquals((await get(`/api/dumps/${slug}`)).status, 404);
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?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?url=${q}`);
assertEquals(anon.data.map((d) => d.title), [
"public",
]);
assertEquals(anon.data[0].ownerUsername, owner.username);
const mine = await get(`/api/dumps?url=${q}`, {
auth: owner.auth,
});
assertEquals(mine.data.length, 2);
assertEquals(
(await get(`/api/dumps?url=${q}`, {
auth: other.auth,
})).data
.length,
1,
);
assertEquals(
(await get(`/api/dumps?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${secret.path}#comment-x and http://gerbeur.test/@${other.username}/dumps/scraped-title`,
},
});
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: a slug changes when sent, the old one stays an alias, 3 a day", async () => {
const owner = await createUser();
const mod = await createUser({ role: "moderator" });
const { id } = insertDump({ userId: owner.id, title: "First" });
const other = insertDump({ userId: owner.id, title: "Taken" });
const byPath = (slug: string) =>
get(`/api/users/@${owner.username}/dumps/${slug}`, {
auth: owner.auth,
});
const setSlug = (slug: string, auth = owner.auth) =>
put(`/api/dumps/${id}`, { auth, json: { slug } });
assertEquals((await setSlug("Not A Slug")).status, 400);
// Another dump's slug, current or former, is refused.
assertEquals((await setSlug(other.slug)).status, 409);
assertEquals((await setSlug("second")).data.slug, "second");
// The old slug answers the dump, which carries its current one.
assertEquals((await byPath("first")).data.slug, "second");
assertEquals((await byPath("second")).data.id, id);
// Taking back its own former slug swaps them; the limit counts the aliases
// left, so a swap doesn't add to it.
assertEquals((await setSlug("first")).data.slug, "first");
assertEquals((await byPath("second")).data.slug, "first");
assertEquals((await setSlug("third")).data.slug, "third");
assertEquals((await setSlug("fourth")).data.slug, "fourth");
// A fourth alias in a day is refused, but not to a moderator.
assertEquals((await setSlug("fifth")).status, 429);
assertEquals((await setSlug("fifth", mod.auth)).data.slug, "fifth");
// Every former slug still leads to it, without chaining.
for (const slug of ["first", "second", "third", "fourth", "fifth"]) {
assertEquals((await byPath(slug)).data.slug, "fifth", slug);
}
// Deleting the dump frees its aliases.
await del(`/api/dumps/${id}`, { auth: owner.auth });
assertEquals(
count(`SELECT COUNT(*) AS n FROM dump_slug_aliases WHERE dump_id = ?;`, id),
0,
);
const reborn = insertDump({ userId: owner.id, title: "Second" });
assertEquals(reborn.slug, "second");
});
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/${id}`, {
auth: owner.auth,
json: { title: "New title", comment: "hello" },
});
assertEquals(res.status, 200);
assertEquals(res.data.title, "New title");
// A new title never changes the slug by itself.
assertEquals(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: going public notifies the poster's and its playlists' followers, once", async () => {
const owner = await createUser();
const fan = await createUser();
const listener = await createUser();
db.prepare(
`INSERT INTO follows (id, follower_id, followed_user_id, created_at) VALUES (?, ?, ?, ?);`,
).run(crypto.randomUUID(), fan.id, owner.id, new Date().toISOString());
const mix = insertPlaylist({ userId: owner.id, title: "Mix" });
const hidden = insertPlaylist({
userId: owner.id,
title: "Hidden",
isPublic: false,
});
await put(`/api/me/follow/playlists/${mix.id}`, { auth: listener.auth });
const { id } = insertDump({ userId: owner.id, isPrivate: true });
for (const p of [mix, hidden]) {
await post(`/api/playlists/${p.id}/dumps/${id}`, { auth: owner.auth });
}
db.exec(`DELETE FROM notifications;`); // the follow notified the owner
const setPrivate = (isPrivate: boolean) =>
put(`/api/dumps/${id}`, { auth: owner.auth, json: { isPrivate } });
await setPrivate(false);
const notes = db.prepare(
`SELECT user_id, type, data FROM notifications ORDER BY type;`,
).all() as { user_id: string; type: string; data: string }[];
assertEquals(
notes.map((n) => [n.user_id, n.type, JSON.parse(n.data).playlistId]),
[
[listener.id, "playlist_dump_added", mix.id],
[fan.id, "user_dump_posted", undefined],
],
);
// Neither going private and public again nor an edit notifies twice.
await setPrivate(true);
await setPrivate(false);
await put(`/api/dumps/${id}`, { auth: owner.auth, json: { title: "New" } });
assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 2);
});
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/dumps/${id}/file`)).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/dumps/${id}/thumbnail`);
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/dumps/${id}/thumbnail`)).status, 404);
});
test("POST /api/dumps/:id/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}/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}/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}/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,
);
});