v3: api/ gets one layout rule and keeps to it, and whatever crosses the wire is defined once, in shared/ — api/model/interfaces.ts and src/model.ts declared the same 40 types by hand and had drifted: the error envelope, email required on one side and optional on the other, Invitee/InviteTreeEntry and LinkPreview/UrlPreviewResponse for one shape each. The validation limits, the role permissions, the chat length cap, the @mention pattern and the Bandcamp and YouTube player responses were copied too, behind "keep in sync" comments. shared/ now holds them once, one file per domain (api, category, chat, comment, dump, follow, mention, notification, permissions, player, playlist, search, user, validation, ws), and imports nothing from either half. The server sends Dates; the client reads Wire<T>, the same type with every Date an ISO string, and turns it back with src/utils/shared/deserialize.ts. APIErrorCode becomes a const object, since the frontend's erasableSyntaxOnly rejects an enum. The shared types showed two real gaps: the avatar upload and GET /api/users/me return the full self user and the frontend read them as public users, and a stored session may predate media tokens, so the client keeps it as its own Session type. The backend rule: each folder holds one kind of module, each file is named after its domain. routes/ parse and answer; services/ hold the logic and every SQL query (lib/auth.ts's token check moves to user-service); remote/ is the only place that fetches from other sites — the guarded fetch and the HTML scraping out of rich-content-service, public-address, the Bandcamp and YouTube resolvers, the link providers, the image proxy (fetch, checks and cache, out of routes/preview.ts) and the Twemoji glyphs (out of lib/site-icons.ts). model/ holds per table a row type, its guard and its mappers; validators/ the request guards; db/connection.ts the handle and transaction(), db/startup.ts the purges and default admin that db.ts ran on import, called at the same moment. Tests mirror the source one file per module, test/<layer>/<module>.test.ts, with shared/'s under shared/test/ (deno task test runs both); the test that covered the bandcamp and soundcloud providers together is two now, and the backend can() has its own, so 202 tests. deno lint src/ is clean too: window.* becomes globalThis.*, and test bodies and act() callbacks that never awaited lose their async. The README states the rule. No behaviour changes; the catalogues only move their source references.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
767
api/test/routes/dumps.test.ts
Normal file
767
api/test/routes/dumps.test.ts
Normal file
@@ -0,0 +1,767 @@
|
||||
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(`<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");
|
||||
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<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 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/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<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 and by slug", async () => {
|
||||
const user = await createUser();
|
||||
const { id, slug } = insertDump({ userId: user.id, title: "Hello" });
|
||||
assertEquals((await get<Dump>(`/api/dumps/${id}`)).data.id, id);
|
||||
assertEquals((await get<Dump>(`/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<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/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<DumpUrlMatch[]>(`/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<DumpUrlMatch[]>(`/api/dumps/by-url?url=${q}`, {
|
||||
auth: owner.auth,
|
||||
});
|
||||
assertEquals(mine.data.length, 2);
|
||||
assertEquals(
|
||||
(await get<DumpUrlMatch[]>(`/api/dumps/by-url?url=${q}`, {
|
||||
auth: other.auth,
|
||||
})).data
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
assertEquals(
|
||||
(await get<DumpUrlMatch[]>(`/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<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 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://x.test/t and http://anywhere/dumps/${secret.slug} and http://gerbeur.test/dumps/self`,
|
||||
},
|
||||
});
|
||||
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: 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/${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<Dump>(`/api/dumps/${id}`)).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<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: 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/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("<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");
|
||||
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<Dump>(`/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<Dump>(`/api/dumps/${id}/refresh-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}/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,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user