v3: dependencies upgraded and deno audit clean, every API response typed end to end, no explicit any left — and the site search stops handing out password hashes — deno audit reported 19 advisories (11 high); none remain. nodemailer 9 → 10.0.10 closes four of them (a quadratic address parser, two recipient-domain allow-list bypasses, resolveContent ignoring disableFileAccess/disableUrlAccess); 10's one break is Node ≥ 20, and 10.0.11 is a day too young for the minimum release age. react-router 8.0.1 → 8.4.0 closes the RSC-mode CSRF bypass. Refreshing the lockfile clears the transitive ones that came in through Vite, ESLint and Lingui: postcss, nanoid, picomatch, browserslist, brace-expansion and baseline-browser-mapping. Everything else moves to its latest too — Lingui 6.8 (its SWC plugin 6.7), React 19.3, Vite 8.3.1, react-hook-form 7.89, marked, jose, ESLint 10.11, typescript-eslint 8.70, the @types — and frimousse 0.3 → 0.4, whose declarations only add (custom emoji data resolvers, a cache). TypeScript stays on 6.0: typescript-eslint supports < 6.1, so 7.0 would stop the lint. allowScripts names @swc/core 1.16.2 and drops esbuild, which Vite 8 no longer installs.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s

Search: GET /api/search sent each matching user's full row — password hash and email included — to anyone, signed in or not, because searchUsersGlobal returned User[] and the route passed it through. It returns profiles only now, stripped where the rows are read, and the search test fails on the old code. Types alone wouldn't have caught it (a User is structurally a PublicUser), so the strip is explicit and tested.

Typed routes. Every JSON response is declared as APIResponse<T>, the way the typed routes already did, with the missing shapes named in api/model/interfaces.ts: PublicUser (no hash, no email) and SelfUser (your own profile, with email) plus toPublicUser/toSelfUser in place of ten hand-written destructurings, AuthTokens, AuthResponse, MediaTokenResponse, InviteResponse, AttachmentResponse, SearchResults, LinkPreview, UserSuggestion and Invitee. Request bodies are `unknown` and narrowed by the existing guards, or by a new fieldOf(obj, key) where a route reads one field — no more reading off Oak's `any` or casting to Record<string, unknown>. The password-reset routes answer `{ success: true, data: null }` rather than a bare `{ success: true }`, so every success fits the envelope; the frontend only reads `success`.

Socket protocol: routes/ws.ts had its own send() taking Record<string, unknown>, so nothing checked what it sent. It takes ServerToClientMessage now, which turned up two mismatches: `pong` was missing from the union although the server answers every client ping with one, and vote_ack carried a `success: true` no type declares and no client reads. The union has PongMessage and vote_ack lost the field.

No explicit any. The YouTube playlist parse reads ytInitialData as `unknown` through a small at(node, …path) that checks each step, instead of `type Json = any`. The backend test helpers type the response body as the server's envelope: request<T>() and get/post/put/patch/del<T>() name what `data` holds, res.data throws with the server's message when the call didn't succeed and res.error when it didn't fail, and each test says which API type it expects, so a test reading a field the route doesn't send no longer compiles. Assertions that a field is absent are `assertFalse("passwordHash" in …)` rather than reading a field the type says isn't there. The socket tests' messages are ServerToClientMessage, with next()/all() keyed by type so each returns that message's shape.

No double casts in src/ either. window.YT and onYouTubeIframeAPIReady are declared on Window; the test's fake player is a class forwarding to the spies the tests inspect. WSContext, FollowContext and PlayerContext export their defaults (defaultWSValue, defaultFollowValue, defaultPlayerValue), which the tests import instead of reading React's private _currentValue. handleMentionKeyDown takes a MentionKey — the key fields it reads — so the test builds a plain object; useUserDumpFeed declares sentinelRef as the callback ref it is. In src/test/setup.ts the IntersectionObserver fake implements IntersectionObserver, and report(isIntersecting, top?) builds complete entries in place of partial ones cast in five files; the Animation fake implements Animation; the canvas getContext stub goes in through defineProperty. The XHR fake's last request is optional behind a getter that fails if none was made, the keyframes are checked with matchers, and the roleless legacy session is built by leaving the role out. One test changes meaning slightly: "leaving the view…" relied on entries with no position at all, and now leaves and re-enters at the same height, which is what it describes. The one cast left is in api/test/ws.test.ts: deno.json's "dom" lib hides Deno's WebSocket overload that takes handshake headers, and dropping that lib would break the editor's view of src/; it's commented and names the real signature.

Also: the notifications test answered a Response-typed promise with a plain object, which failed `tsc -b`; it uses the fetch stub's deferred() now. ESLint didn't read the `deno-lint-ignore no-explicit-any` comments, so `eslint .` failed on three files; those anys are gone. `deno fmt` over api/ and src/ (not the compiled src/locales/*.js); the reflow left all 332 message ids intact, and the .po files only have their source line references updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 16:05:29 +00:00
parent c8bf0d583c
commit 364c498a24
116 changed files with 5266 additions and 2503 deletions

View File

@@ -17,10 +17,18 @@ import {
test,
TEST_DIR,
} from "./helpers.ts";
import type {
Dump,
DumpUrlMatch,
PaginatedData,
Playlist,
PublicUser,
} from "../model/interfaces.ts";
import {
assert,
assertEquals,
assertExists,
assertFalse,
assertMatch,
assertNotEquals,
} from "@std/assert";
@@ -41,18 +49,18 @@ function readFile(path: string): Uint8Array | null {
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", {
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.body.data;
const dump = res.data;
assertEquals(dump.kind, "url");
assertEquals(dump.title, "Scraped title");
assertMatch(dump.slug, /^scraped-title-[0-9a-f]{8}$/);
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");
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.
@@ -64,22 +72,22 @@ test("POST /api/dumps: a typed title wins; an unreachable page still posts", asy
const user = await createUser();
{
using _ = stubNetwork(() => PAGE.clone());
const res = await post("/api/dumps", {
const res = await post<Dump>("/api/dumps", {
auth: user.auth,
json: { url: "https://example.com/b", title: " My title " },
});
assertEquals(res.body.data.title, "My title");
assertEquals(res.data.title, "My title");
}
{
using _ = stubNetwork(() => {
throw new TypeError("offline");
});
const res = await post("/api/dumps", {
const res = await post<Dump>("/api/dumps", {
auth: user.auth,
json: { url: "https://www.offline.example/c" },
});
assertEquals(res.status, 201);
assertEquals(res.body.data.title, "offline.example");
assertEquals(res.data.title, "offline.example");
}
});
@@ -108,14 +116,14 @@ 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", {
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.body.data.categoryIds, [music]);
assertEquals(res.data.categoryIds, [music]);
assertEquals(
(await get(`/api/dumps/${res.body.data.id}`)).body.data.categoryIds,
(await get<Dump>(`/api/dumps/${res.data.id}`)).data.categoryIds,
[music],
);
});
@@ -133,12 +141,12 @@ test("POST /api/dumps: an unknown category is refused and nothing is left behind
test("POST /api/dumps: a file dump is stored and served back", async () => {
const user = await createUser();
const res = await post("/api/dumps", {
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.body.data;
const dump = res.data;
assertEquals(dump.kind, "file");
assertEquals(dump.title, "pixel.png");
assertEquals(dump.fileMime, "image/png");
@@ -202,7 +210,7 @@ test("POST /api/dumps: followers are notified of public dumps, not private ones"
json: { url: "https://example.com/private", isPrivate: true },
});
assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 0);
const pub = await post("/api/dumps", {
const pub = await post<Dump>("/api/dumps", {
auth: poster.auth,
json: { url: "https://example.com/public" },
});
@@ -212,7 +220,7 @@ test("POST /api/dumps: followers are notified of public dumps, not private ones"
assertEquals(notes.length, 1);
assertEquals(notes[0].user_id, follower.id);
assertEquals(notes[0].type, "user_dump_posted");
assertEquals(JSON.parse(notes[0].data).dumpId, pub.body.data.id);
assertEquals(JSON.parse(notes[0].data).dumpId, pub.data.id);
});
// ── Read ─────────────────────────────────────────────────────────────────────
@@ -220,8 +228,8 @@ test("POST /api/dumps: followers are notified of public dumps, not private ones"
test("GET /api/dumps/:id works by id and by slug", async () => {
const user = await createUser();
const { id, slug } = insertDump({ userId: user.id, title: "Hello" });
assertEquals((await get(`/api/dumps/${id}`)).body.data.id, id);
assertEquals((await get(`/api/dumps/${slug}`)).body.data.id, id);
assertEquals((await get<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);
});
@@ -265,22 +273,25 @@ test("GET /api/dumps: newest first, paginated, private ones for the owner only",
isPrivate: true,
createdAt: new Date(Date.UTC(2024, 0, 10)).toISOString(),
});
const page1 = await get("/api/dumps?limit=2");
assertEquals(page1.body.data.items.map((d: { title: string }) => d.title), [
const page1 = await get<PaginatedData<Dump>>("/api/dumps?limit=2");
assertEquals(page1.data.items.map((d) => d.title), [
"d4",
"d3",
]);
assertEquals(page1.body.data.total, 5);
assertEquals(page1.body.data.hasMore, true);
const page3 = await get("/api/dumps?limit=2&page=3");
assertEquals(page3.body.data.items.length, 1);
assertEquals(page3.body.data.hasMore, false);
assertEquals(page1.data.total, 5);
assertEquals(page1.data.hasMore, true);
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("/api/dumps?limit=1", { auth: owner.auth });
assertEquals(mine.body.data.items[0].title, "secret");
assertEquals(mine.body.data.total, 6);
const mine = await get<PaginatedData<Dump>>("/api/dumps?limit=1", {
auth: owner.auth,
});
assertEquals(mine.data.items[0].title, "secret");
assertEquals(mine.data.total, 6);
assertEquals(
(await get("/api/dumps", { auth: other.auth })).body.data.total,
(await get<PaginatedData<Dump>>("/api/dumps", { auth: other.auth })).data
.total,
5,
);
});
@@ -300,19 +311,26 @@ test("GET /api/dumps/by-url: matches on the canonical key, respects privacy", as
isPrivate: true,
});
const q = encodeURIComponent("https://m.youtube.com/watch?v=abc&t=10");
const anon = await get(`/api/dumps/by-url?url=${q}`);
assertEquals(anon.body.data.map((d: { title: string }) => d.title), [
const anon = await get<DumpUrlMatch[]>(`/api/dumps/by-url?url=${q}`);
assertEquals(anon.data.map((d) => d.title), [
"public",
]);
assertEquals(anon.body.data[0].username, owner.username);
const mine = await get(`/api/dumps/by-url?url=${q}`, { auth: owner.auth });
assertEquals(mine.body.data.length, 2);
assertEquals(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(`/api/dumps/by-url?url=${q}`, { auth: other.auth })).body.data
(await get<DumpUrlMatch[]>(`/api/dumps/by-url?url=${q}`, {
auth: other.auth,
})).data
.length,
1,
);
assertEquals((await get(`/api/dumps/by-url?url=garbage`)).body.data, []);
assertEquals(
(await get<DumpUrlMatch[]>(`/api/dumps/by-url?url=garbage`)).data,
[],
);
});
test("GET /api/dumps/:id/voters lists voters without private fields", async () => {
@@ -322,11 +340,11 @@ test("GET /api/dumps/:id/voters lists voters without private fields", async () =
db.prepare(
`INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`,
).run(id, voter.id, new Date().toISOString());
const res = await get(`/api/dumps/${id}/voters`);
assertEquals(res.body.data.total, 1);
assertEquals(res.body.data.items[0].username, voter.username);
assertEquals(res.body.data.items[0].email, undefined);
assertEquals(res.body.data.items[0].passwordHash, undefined);
const res = await get<PaginatedData<PublicUser>>(`/api/dumps/${id}/voters`);
assertEquals(res.data.total, 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 () => {
@@ -345,7 +363,7 @@ test("GET /api/dumps/:id/related: links in both directions, hiding private ones"
using _ = stubNetwork(() => PAGE.clone());
// A new dump whose comment links the target by its URL and the secret dump by
// its page — and itself, which is ignored.
const src = await post("/api/dumps", {
const src = await post<Dump>("/api/dumps", {
auth: other.auth,
json: {
url: "https://example.com/src",
@@ -353,21 +371,21 @@ test("GET /api/dumps/:id/related: links in both directions, hiding private ones"
`see https://x.test/t and http://anywhere/dumps/${secret.slug} and http://gerbeur.test/dumps/self`,
},
});
const srcId = src.body.data.id;
const fromSrc = await get(`/api/dumps/${srcId}/related`);
assertEquals(fromSrc.body.data.map((d: { title: string }) => d.title), [
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(`/api/dumps/${srcId}/related`, {
const fromSrcAsOwner = await get<Dump[]>(`/api/dumps/${srcId}/related`, {
auth: other.auth,
});
assertEquals(fromSrcAsOwner.body.data.length, 2);
const toTarget = await get(`/api/dumps/${target.id}/related`);
assertEquals(toTarget.body.data.map((d: { id: string }) => d.id), [srcId]);
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(`/api/dumps/${target.id}/related`)).body.data, []);
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 () => {
@@ -386,12 +404,14 @@ test("GET /api/dumps/:id/playlists: public playlists, plus the viewer's private
).run(p.id, id, new Date().toISOString());
}
assertEquals(
(await get(`/api/dumps/${id}/playlists`, { auth: other.auth })).body.data
.map((p: { title: string }) => p.title),
(await get<Playlist[]>(`/api/dumps/${id}/playlists`, { auth: other.auth }))
.data
.map((p) => p.title),
["Public"],
);
assertEquals(
(await get(`/api/dumps/${id}/playlists`, { auth: user.auth })).body.data
(await get<Playlist[]>(`/api/dumps/${id}/playlists`, { auth: user.auth }))
.data
.length,
2,
);
@@ -413,21 +433,21 @@ test("PUT /api/dumps/:id: owner and moderator may edit, others may not", async (
(await put(`/api/dumps/${id}`, { json: { title: "X" } })).status,
401,
);
const res = await put(`/api/dumps/${slug}`, {
const res = await put<Dump>(`/api/dumps/${slug}`, {
auth: owner.auth,
json: { title: "New title", comment: "hello" },
});
assertEquals(res.status, 200);
assertEquals(res.body.data.title, "New title");
assertMatch(res.body.data.slug, /^new-title-/);
assertNotEquals(res.body.data.slug, slug);
assertExists(res.body.data.updatedAt);
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}`)).body.data.title, "Moderated");
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 () => {
@@ -453,11 +473,11 @@ test("PUT /api/dumps/:id: invalid input is 422, a new URL refetches metadata", a
// 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}`, {
const moved = await put<Dump>(`/api/dumps/${id}`, {
auth: owner.auth,
json: { url: "https://b.test/?utm_medium=z" },
});
assertEquals(moved.body.data.title, "Scraped title");
assertEquals(moved.data.title, "Scraped title");
assertEquals(net.calls.length, 1);
const row = db.prepare(`SELECT url_canonical FROM dumps WHERE id = ?;`).get(
id,
@@ -476,21 +496,21 @@ test("PUT /api/dumps/:id: categories are replaced, or kept when omitted", async
auth: owner.auth,
json: { categoryIds: [a] },
});
const kept = await put(`/api/dumps/${id}`, {
const kept = await put<Dump>(`/api/dumps/${id}`, {
auth: owner.auth,
json: { title: "t" },
});
assertEquals(kept.body.data.categoryIds, [a]);
const replaced = await put(`/api/dumps/${id}`, {
assertEquals(kept.data.categoryIds, [a]);
const replaced = await put<Dump>(`/api/dumps/${id}`, {
auth: owner.auth,
json: { categoryIds: [b] },
});
assertEquals(replaced.body.data.categoryIds, [b]);
const cleared = await put(`/api/dumps/${id}`, {
assertEquals(replaced.data.categoryIds, [b]);
const cleared = await put<Dump>(`/api/dumps/${id}`, {
auth: owner.auth,
json: { categoryIds: [] },
});
assertEquals(cleared.body.data.categoryIds, []);
assertEquals(cleared.data.categoryIds, []);
});
test("PUT /api/dumps/:id: an unknown category changes nothing", async () => {
@@ -506,7 +526,7 @@ test("PUT /api/dumps/:id: an unknown category changes nothing", async () => {
json: { title: "After", categoryIds: [a, "nope"] },
});
assertEquals(res.status, 400);
const dump = (await get(`/api/dumps/${id}`)).body.data;
const dump = (await get<Dump>(`/api/dumps/${id}`)).data;
assertEquals(dump.title, "Before");
assertEquals(dump.categoryIds, [a]);
});
@@ -528,7 +548,7 @@ test("PUT /api/dumps/:id/file: an unknown category keeps the old file", async ()
});
assertEquals(res.status, 400);
assertEquals(readFile(`dumps/${id}`), original);
assertEquals((await get(`/api/dumps/${id}`)).body.data.fileName, "a.txt");
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 () => {
@@ -553,7 +573,7 @@ test("PUT /api/dumps/:id: a file dump edits title, comment and privacy", async (
title: "file",
fileName: "a.txt",
});
const res = await put(`/api/dumps/${id}`, {
const res = await put<Dump>(`/api/dumps/${id}`, {
auth: owner.auth,
json: {
title: "Renamed",
@@ -563,9 +583,9 @@ test("PUT /api/dumps/:id: a file dump edits title, comment and privacy", async (
},
});
assertEquals(res.status, 200);
assertEquals(res.body.data.title, "Renamed");
assertEquals(res.body.data.isPrivate, true);
assertEquals(res.body.data.url, undefined);
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 () => {
@@ -583,13 +603,13 @@ test("PUT /api/dumps/:id/file replaces a file dump's contents", async () => {
.status,
403,
);
const res = await put(`/api/dumps/${id}/file`, {
const res = await put<Dump>(`/api/dumps/${id}/file`, {
auth: owner.auth,
form: form(),
});
assertEquals(res.status, 200);
assertEquals(res.body.data.fileName, "b.png");
assertEquals(res.body.data.title, "Replaced");
assertEquals(res.data.fileName, "b.png");
assertEquals(res.data.title, "Replaced");
assertEquals(readFile(`dumps/${id}`), PNG);
const urlDump = insertDump({ userId: owner.id });
@@ -608,7 +628,7 @@ test("PUT /api/dumps/:id/file: visibility changes with the file, or stays", asyn
const other = await createUser();
const { id } = insertDump({ userId: owner.id, fileName: "a.txt" });
const replace = (fields: Record<string, string>) =>
put(`/api/dumps/${id}/file`, {
put<Dump>(`/api/dumps/${id}/file`, {
auth: owner.auth,
form: formWithFile(PNG, "b.png", "image/png", fields),
});
@@ -616,15 +636,15 @@ test("PUT /api/dumps/:id/file: visibility changes with the file, or stays", asyn
(await get(`/api/dumps/${id}`, { auth: other.auth })).status === 200;
let res = await replace({ isPrivate: "true" });
assertEquals(res.body.data.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.body.data.isPrivate, true);
assertEquals(res.data.isPrivate, true);
assertEquals(await visible(), false);
res = await replace({ isPrivate: "false" });
assertEquals(res.body.data.isPrivate, false);
assertEquals(res.data.isPrivate, false);
assertEquals(await visible(), true);
// A refused replacement changes neither.
res = await replace({ isPrivate: "true", categoryIds: '["nope"]' });
@@ -654,19 +674,21 @@ test("custom thumbnails: set, served, removed", async () => {
})).status,
400,
);
const set = await put(`/api/dumps/${id}/thumbnail`, {
const set = await put<Dump>(`/api/dumps/${id}/thumbnail`, {
auth: owner.auth,
form: formWithFile(PNG),
});
assertEquals(set.status, 200);
assertEquals(set.body.data.thumbnailMime, "image/png");
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.body.data.thumbnailMime, undefined);
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);
@@ -688,19 +710,19 @@ test("POST /api/dumps/:id/refresh-metadata: updates, or keeps what it had on fai
using _ = stubNetwork(() => {
throw new TypeError("down");
});
const res = await post(`/api/dumps/${id}/refresh-metadata`, {
const res = await post<Dump>(`/api/dumps/${id}/refresh-metadata`, {
auth: owner.auth,
});
assertEquals(res.status, 502);
assertEquals((await get(`/api/dumps/${id}`)).body.data.title, "Kept");
assertEquals((await get<Dump>(`/api/dumps/${id}`)).data.title, "Kept");
}
{
using _ = stubNetwork(() => PAGE.clone());
const res = await post(`/api/dumps/${id}/refresh-metadata`, {
const res = await post<Dump>(`/api/dumps/${id}/refresh-metadata`, {
auth: owner.auth,
});
assertEquals(res.status, 200);
assertEquals(res.body.data.title, "Scraped title");
assertEquals(res.data.title, "Scraped title");
}
const file = insertDump({ userId: owner.id, fileName: "f.txt" });
assertEquals(
@@ -760,14 +782,12 @@ test("GET /api/users/:username/dumps and /votes respect privacy", async () => {
}
for (const sub of ["dumps", "votes"]) {
const path = `/api/users/${owner.username}/${sub}`;
assertEquals((await get(path)).body.data.total, 1, sub);
const total = async (opts?: { auth: { Authorization: string } }) =>
(await get<PaginatedData<Dump>>(path, opts)).data.total;
assertEquals(await total(), 1, sub);
assertEquals(await total({ auth: other.auth }), 1, sub);
assertEquals(
(await get(path, { auth: other.auth })).body.data.total,
1,
sub,
);
assertEquals(
(await get(path, { auth: owner.auth })).body.data.total,
await total({ auth: owner.auth }),
2,
sub,
);