Files
gerbeur/api/test/routes/dumps.test.ts
khannurien 90fc281e90 v4: every image the API serves comes scaled to the size it is shown at — a first visit to the feed loaded 4.8 MB of images out of 5.1 MB in all: an avatar shown at 32 pixels was the upload itself, a 1.4 MB GIF among them, and so were a cover, a custom thumbnail, an attachment and a proxied image, only an image dump having a preview, 960 pixels wide. Every route that answers an image now takes ?w= and sends a WebP copy at most that wide, of the widths shared/image.ts lists, 128, 320, 640 and 960, a width between two taking the one above, and never wider than the image; without it the upload goes out as before, a dump's thumbnail alone being scaled to the widest by default, the custom ones included. serveImage (lib/upload.ts) is where they all end, and the image proxy scales what it fetched the same way, keeping each width in its cache. lib/ffmpeg.ts makes the copies as it made the previews, which they replace: on first request, under uploads/scaled/ at the upload's own path, each given its upload's date and made again when the two differ, so a new avatar or cover needs nobody to delete the old ones. WebP keeps a PNG's transparency and a GIF's animation. A JPEG is turned by its EXIF orientation here, ffmpeg's own rotation of images depending on its version. An animated WebP, which ffmpeg can't read, and an image it fails on go out as they are, a failure being remembered until the upload changes. ?f=compat asks for the copy as a JPEG, or a PNG for what may be transparent: the digest's emails take their thumbnails that way, at 640 pixels, not every mail client reading WebP. On the client, imageProps (utils/shared/urls.ts) gives an image its srcset of those copies and the width it is laid out at, which config/imageSizes.ts states for each place from App.css: Avatar, Thumbnail, which scales an API image and leaves another site's as that site sends it, and the playlists' covers use it. The images of notes and comments still load as uploaded. The copies go with the dump, the custom thumbnail or the attachment they were made from, and generatedMediaSweepJob, a minute after the start then daily, takes those whose upload is gone, the previews under their former name and the temporary files a generation left unfinished.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:01:36 +00:00

870 lines
30 KiB
TypeScript

import {
count,
createUser,
db,
del,
formWithFile,
get,
hasFfmpeg,
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(`<title>Scraped title</title>
<meta property="og:image" content="https://example.com/cover.jpg">`);
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<Dump>("/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<Dump>("/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<Dump>("/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<Dump>("/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<Dump>(`/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<Dump>("/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<Dump>("/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<Dump>(`/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<PaginatedData<Dump>>("/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<PaginatedData<Dump>>("/api/dumps?limit=2&page=3");
assertEquals(page3.data.items.length, 1);
assertEquals(page3.data.hasMore, false);
const mine = await get<PaginatedData<Dump>>("/api/dumps", {
auth: owner.auth,
});
assertEquals(mine.data.items[0].title, "secret");
assertEquals(mine.data.items.length, 6);
assertEquals(
(await get<PaginatedData<Dump>>("/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<DumpUrlMatch[]>(`/api/dumps?url=${q}`);
assertEquals(anon.data.map((d) => d.title), [
"public",
]);
assertEquals(anon.data[0].ownerUsername, owner.username);
const mine = await get<DumpUrlMatch[]>(`/api/dumps?url=${q}`, {
auth: owner.auth,
});
assertEquals(mine.data.length, 2);
assertEquals(
(await get<DumpUrlMatch[]>(`/api/dumps?url=${q}`, {
auth: other.auth,
})).data
.length,
1,
);
assertEquals(
(await get<DumpUrlMatch[]>(`/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<PaginatedData<PublicUser>>(`/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<Dump>("/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<Dump[]>(`/api/dumps/${srcId}/related`);
assertEquals(fromSrc.data.map((d) => d.title), [
"target",
]);
const fromSrcAsOwner = await get<Dump[]>(`/api/dumps/${srcId}/related`, {
auth: other.auth,
});
assertEquals(fromSrcAsOwner.data.length, 2);
const toTarget = await get<Dump[]>(`/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<Dump[]>(`/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<Playlist[]>(`/api/dumps/${id}/playlists`, { auth: other.auth }))
.data
.map((p) => p.title),
["Public"],
);
assertEquals(
(await get<Playlist[]>(`/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<Dump>(`/api/users/@${owner.username}/dumps/${slug}`, {
auth: owner.auth,
});
const setSlug = (slug: string, auth = owner.auth) =>
put<Dump>(`/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<Dump>(`/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<Dump>(`/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<Dump>(`/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<Dump>(`/api/dumps/${id}`, {
auth: owner.auth,
json: { title: "t" },
});
assertEquals(kept.data.categoryIds, [a]);
const replaced = await put<Dump>(`/api/dumps/${id}`, {
auth: owner.auth,
json: { categoryIds: [b] },
});
assertEquals(replaced.data.categoryIds, [b]);
const cleared = await put<Dump>(`/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<Dump>(`/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<Dump>(`/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<Dump>(`/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<Dump>(`/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<string, string>) =>
put<Dump>(`/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("<svg/>"),
"x.svg",
"image/svg+xml",
),
})).status,
400,
);
const set = await put<Dump>(`/api/dumps/${id}/thumbnail`, {
auth: owner.auth,
form: formWithFile(PNG),
});
assertEquals(set.status, 200);
assertEquals(set.data.thumbnailMime, "image/png");
// A scaled copy by default, as for an upload's own thumbnail; the image
// itself where no copy can be made.
const thumb = await get(`/api/dumps/${id}/thumbnail`);
assertEquals(thumb.status, 200);
if (await hasFfmpeg()) {
assertEquals(thumb.headers.get("content-type"), "image/webp");
assertEquals(
readFile(`scaled/thumbnails/${id}-custom.w960.webp`) !== null,
true,
);
} else {
assertEquals(thumb.headers.get("content-type"), "image/png");
assertEquals(thumb.bytes, PNG);
}
const removed = await del<Dump>(`/api/dumps/${id}/thumbnail`, {
auth: owner.auth,
});
assertEquals(removed.data.thumbnailMime, undefined);
assertEquals(readFile(`thumbnails/${id}-custom`), null);
assertEquals(readFile(`scaled/thumbnails/${id}-custom.w960.webp`), 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<Dump>(`/api/dumps/${id}/metadata`, {
auth: owner.auth,
});
assertEquals(res.status, 502);
assertEquals((await get<Dump>(`/api/dumps/${id}`)).data.title, "Kept");
}
{
using _ = stubNetwork(() => PAGE.clone());
const res = await post<Dump>(`/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,
);
});