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>
465 lines
15 KiB
TypeScript
465 lines
15 KiB
TypeScript
import {
|
|
type ApiResponse,
|
|
count,
|
|
createUser,
|
|
db,
|
|
del,
|
|
formWithFile,
|
|
get,
|
|
insertDump,
|
|
insertPlaylist,
|
|
PNG,
|
|
post,
|
|
put,
|
|
test,
|
|
TEST_DIR,
|
|
} from "./helpers.ts";
|
|
import type {
|
|
PaginatedData,
|
|
Playlist,
|
|
PlaylistMembership,
|
|
PlaylistWithDumps,
|
|
} from "../model/interfaces.ts";
|
|
import { assert, assertEquals, assertMatch } from "@std/assert";
|
|
|
|
function ids(res: ApiResponse<PlaylistWithDumps>): string[] {
|
|
return res.data.dumps.map((d) => d.id);
|
|
}
|
|
|
|
test("POST /api/playlists creates; GET finds it by id or slug", async () => {
|
|
const user = await createUser();
|
|
const res = await post<Playlist>("/api/playlists", {
|
|
auth: user.auth,
|
|
json: { title: "Road Trip", description: "songs", isPublic: true },
|
|
});
|
|
assertEquals(res.status, 201);
|
|
const { id, slug } = res.data;
|
|
assertMatch(slug ?? "", /^road-trip-/);
|
|
assertEquals(
|
|
(await get<PlaylistWithDumps>(`/api/playlists/${id}`)).data.title,
|
|
"Road Trip",
|
|
);
|
|
const bySlug = await get<PlaylistWithDumps>(`/api/playlists/${slug}`);
|
|
assertEquals(bySlug.data.id, id);
|
|
assertEquals(bySlug.data.ownerUsername, user.username);
|
|
assertEquals(bySlug.data.dumps, []);
|
|
assertEquals(
|
|
(await post("/api/playlists", { auth: user.auth, json: { title: "" } }))
|
|
.status,
|
|
400,
|
|
);
|
|
assertEquals(
|
|
(await post("/api/playlists", { json: { title: "x", isPublic: true } }))
|
|
.status,
|
|
401,
|
|
);
|
|
});
|
|
|
|
test("private playlists are the owner's alone", async () => {
|
|
const owner = await createUser();
|
|
const other = await createUser();
|
|
const { id } = insertPlaylist({ userId: owner.id, isPublic: false });
|
|
assertEquals((await get(`/api/playlists/${id}`)).status, 404);
|
|
assertEquals(
|
|
(await get(`/api/playlists/${id}`, { auth: other.auth })).status,
|
|
404,
|
|
);
|
|
assertEquals(
|
|
(await get(`/api/playlists/${id}`, { auth: owner.auth })).status,
|
|
200,
|
|
);
|
|
insertPlaylist({ userId: owner.id });
|
|
const path = `/api/users/${owner.username}/playlists`;
|
|
assertEquals((await get<PaginatedData<Playlist>>(path)).data.total, 1);
|
|
assertEquals(
|
|
(await get<PaginatedData<Playlist>>(path, { auth: owner.auth })).data.total,
|
|
2,
|
|
);
|
|
});
|
|
|
|
test("adding dumps: newest on top, no duplicates, only dumps you can see", async () => {
|
|
const owner = await createUser();
|
|
const other = await createUser();
|
|
const { id } = insertPlaylist({ userId: owner.id });
|
|
const a = insertDump({ userId: owner.id, title: "a" });
|
|
const b = insertDump({ userId: other.id, title: "b" });
|
|
const theirs = insertDump({ userId: other.id, isPrivate: true });
|
|
|
|
assertEquals(
|
|
(await post(`/api/playlists/${id}/dumps/${a.id}`, { auth: owner.auth }))
|
|
.status,
|
|
204,
|
|
);
|
|
assertEquals(
|
|
(await post(`/api/playlists/${id}/dumps/${b.id}`, { auth: owner.auth }))
|
|
.status,
|
|
204,
|
|
);
|
|
assertEquals(ids(await get(`/api/playlists/${id}`)), [b.id, a.id]);
|
|
assertEquals(
|
|
(await post(`/api/playlists/${id}/dumps/${a.id}`, { auth: owner.auth }))
|
|
.status,
|
|
409,
|
|
);
|
|
assertEquals(
|
|
(await post(`/api/playlists/${id}/dumps/${theirs.id}`, {
|
|
auth: owner.auth,
|
|
})).status,
|
|
404,
|
|
);
|
|
assertEquals(
|
|
(await post(`/api/playlists/${id}/dumps/${crypto.randomUUID()}`, {
|
|
auth: owner.auth,
|
|
}))
|
|
.status,
|
|
404,
|
|
);
|
|
assertEquals(
|
|
(await post(`/api/playlists/${id}/dumps/${a.id}`, { auth: other.auth }))
|
|
.status,
|
|
403,
|
|
);
|
|
assertEquals(
|
|
(await get<PlaylistWithDumps>(`/api/playlists/${id}`)).data.dumpCount,
|
|
2,
|
|
);
|
|
});
|
|
|
|
test("a public playlist hides its private dumps from everyone but their owner", async () => {
|
|
const owner = await createUser();
|
|
const { id } = insertPlaylist({ userId: owner.id });
|
|
const pub = insertDump({ userId: owner.id });
|
|
const priv = insertDump({ userId: owner.id, isPrivate: true });
|
|
for (const d of [pub, priv]) {
|
|
await post(`/api/playlists/${id}/dumps/${d.id}`, { auth: owner.auth });
|
|
}
|
|
assertEquals(ids(await get(`/api/playlists/${id}`)), [pub.id]);
|
|
assertEquals(
|
|
ids(await get(`/api/playlists/${id}`, { auth: owner.auth })).length,
|
|
2,
|
|
);
|
|
});
|
|
|
|
test("reorder: must name exactly the current members", async () => {
|
|
const owner = await createUser();
|
|
const { id } = insertPlaylist({ userId: owner.id });
|
|
const [a, b, c] = [1, 2, 3].map(() => insertDump({ userId: owner.id }));
|
|
for (const d of [a, b, c]) {
|
|
await post(`/api/playlists/${id}/dumps/${d.id}`, { auth: owner.auth });
|
|
}
|
|
const order = [a.id, c.id, b.id];
|
|
assertEquals(
|
|
(await put(`/api/playlists/${id}/order`, {
|
|
auth: owner.auth,
|
|
json: { dumpIds: order },
|
|
}))
|
|
.status,
|
|
200,
|
|
);
|
|
assertEquals(ids(await get(`/api/playlists/${id}`)), order);
|
|
for (const dumpIds of [[a.id, b.id], [a.id, b.id, crypto.randomUUID()]]) {
|
|
assertEquals(
|
|
(await put(`/api/playlists/${id}/order`, {
|
|
auth: owner.auth,
|
|
json: { dumpIds },
|
|
}))
|
|
.status,
|
|
400,
|
|
JSON.stringify(dumpIds),
|
|
);
|
|
}
|
|
// A dump added after reordering still lands on top.
|
|
const d = insertDump({ userId: owner.id });
|
|
await post(`/api/playlists/${id}/dumps/${d.id}`, { auth: owner.auth });
|
|
assertEquals(ids(await get(`/api/playlists/${id}`))[0], d.id);
|
|
});
|
|
|
|
test("reorder: a list naming a member twice is not the current members", async () => {
|
|
const owner = await createUser();
|
|
const { id } = insertPlaylist({ userId: owner.id });
|
|
const [a, b] = [1, 2].map(() => insertDump({ userId: owner.id }));
|
|
for (const d of [a, b]) {
|
|
await post(`/api/playlists/${id}/dumps/${d.id}`, { auth: owner.auth });
|
|
}
|
|
const res = await put(`/api/playlists/${id}/order`, {
|
|
auth: owner.auth,
|
|
json: { dumpIds: [a.id, b.id, a.id] },
|
|
});
|
|
assertEquals(res.status, 400);
|
|
});
|
|
|
|
test("removing a dump; updating; deleting", async () => {
|
|
const owner = await createUser();
|
|
const other = await createUser();
|
|
const mod = await createUser({ role: "moderator" });
|
|
const { id } = insertPlaylist({ userId: owner.id, title: "Old" });
|
|
const a = insertDump({ userId: owner.id });
|
|
await post(`/api/playlists/${id}/dumps/${a.id}`, { auth: owner.auth });
|
|
assertEquals(
|
|
(await del(`/api/playlists/${id}/dumps/${a.id}`, { auth: other.auth }))
|
|
.status,
|
|
403,
|
|
);
|
|
assertEquals(
|
|
(await del(`/api/playlists/${id}/dumps/${a.id}`, { auth: owner.auth }))
|
|
.status,
|
|
204,
|
|
);
|
|
assertEquals(ids(await get(`/api/playlists/${id}`)), []);
|
|
|
|
assertEquals(
|
|
(await put(`/api/playlists/${id}`, {
|
|
auth: other.auth,
|
|
json: { title: "x" },
|
|
})).status,
|
|
403,
|
|
);
|
|
const updated = await put<Playlist>(`/api/playlists/${id}`, {
|
|
auth: owner.auth,
|
|
json: { title: "New", description: "d", isPublic: false },
|
|
});
|
|
assertEquals(updated.data.title, "New");
|
|
assertMatch(updated.data.slug ?? "", /^new-/);
|
|
assertEquals(updated.data.isPublic, false);
|
|
const cleared = await put<Playlist>(`/api/playlists/${id}`, {
|
|
auth: owner.auth,
|
|
json: { description: null },
|
|
});
|
|
assertEquals(cleared.data.description, undefined);
|
|
assertEquals(cleared.data.title, "New");
|
|
|
|
assertEquals(
|
|
(await del(`/api/playlists/${id}`, { auth: other.auth })).status,
|
|
403,
|
|
);
|
|
assertEquals(
|
|
(await del(`/api/playlists/${id}`, { auth: mod.auth })).status,
|
|
204,
|
|
);
|
|
assertEquals(
|
|
(await get(`/api/playlists/${id}`, { auth: owner.auth })).status,
|
|
404,
|
|
);
|
|
assertEquals(count(`SELECT COUNT(*) as n FROM playlist_dumps;`), 0);
|
|
});
|
|
|
|
test("memberships: which of my playlists hold this dump", async () => {
|
|
const owner = await createUser();
|
|
const other = await createUser();
|
|
const p1 = insertPlaylist({ userId: owner.id, title: "has" });
|
|
insertPlaylist({ userId: owner.id, title: "hasnt" });
|
|
insertPlaylist({ userId: other.id, title: "theirs" });
|
|
const d = insertDump({ userId: owner.id });
|
|
await post(`/api/playlists/${p1.id}/dumps/${d.id}`, { auth: owner.auth });
|
|
const res = await get<PlaylistMembership[]>(
|
|
`/api/playlists/by-dump/${d.id}/memberships`,
|
|
{
|
|
auth: owner.auth,
|
|
},
|
|
);
|
|
const byTitle = Object.fromEntries(
|
|
res.data.map((
|
|
m: { playlist: { title: string }; hasDump: boolean },
|
|
) => [
|
|
m.playlist.title,
|
|
m.hasDump,
|
|
]),
|
|
);
|
|
assertEquals(byTitle, { has: true, hasnt: false });
|
|
});
|
|
|
|
test("adding to a followed public playlist notifies followers, private dumps excepted", async () => {
|
|
const owner = await createUser();
|
|
const follower = await createUser();
|
|
const { id } = insertPlaylist({ userId: owner.id, title: "Mix" });
|
|
await post(`/api/follows/playlists/${id}`, { auth: follower.auth });
|
|
db.exec(`DELETE FROM notifications;`); // the follow notified the owner
|
|
const priv = insertDump({ userId: owner.id, isPrivate: true });
|
|
await post(`/api/playlists/${id}/dumps/${priv.id}`, { auth: owner.auth });
|
|
assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 0);
|
|
const pub = insertDump({ userId: owner.id, title: "Song" });
|
|
await post(`/api/playlists/${id}/dumps/${pub.id}`, { auth: owner.auth });
|
|
const note = db.prepare(`SELECT user_id, type, data FROM notifications;`)
|
|
.get() as {
|
|
user_id: string;
|
|
type: string;
|
|
data: string;
|
|
};
|
|
assertEquals(note.user_id, follower.id);
|
|
assertEquals(note.type, "playlist_dump_added");
|
|
assertEquals(JSON.parse(note.data), {
|
|
dumpId: pub.id,
|
|
dumpTitle: "Song",
|
|
playlistId: id,
|
|
playlistTitle: "Mix",
|
|
});
|
|
});
|
|
|
|
test("playlist images: upload and serve", async () => {
|
|
const owner = await createUser();
|
|
const other = await createUser();
|
|
const { id, slug } = insertPlaylist({ userId: owner.id });
|
|
assertEquals((await get(`/api/playlists/${id}/image`)).status, 404);
|
|
assertEquals(
|
|
(await post(`/api/playlists/${id}/image`, {
|
|
auth: other.auth,
|
|
form: formWithFile(PNG),
|
|
}))
|
|
.status,
|
|
403,
|
|
);
|
|
const res = await post<Playlist>(`/api/playlists/${slug}/image`, {
|
|
auth: owner.auth,
|
|
form: formWithFile(PNG),
|
|
});
|
|
assertEquals(res.status, 200);
|
|
assertEquals(res.data.imageMime, "image/png");
|
|
// A new cover counts as an edit, so image URLs versioned on it change.
|
|
assert(res.data.updatedAt);
|
|
const served = await get(`/api/playlists/${slug}/image`);
|
|
assertEquals(served.status, 200);
|
|
assertEquals(served.bytes, PNG);
|
|
});
|
|
|
|
test("playlist images: a failed write leaves the row and the old cover alone", async () => {
|
|
const owner = await createUser();
|
|
const { id } = insertPlaylist({ userId: owner.id });
|
|
const dir = `${TEST_DIR}/uploads/playlist-images`;
|
|
const row = () =>
|
|
db.prepare(`SELECT image_mime, updated_at FROM playlists WHERE id = ?;`)
|
|
.get(id) as { image_mime: string | null; updated_at: string | null };
|
|
|
|
// No cover yet, and the write fails (a directory sits where the file goes):
|
|
// the playlist mustn't claim a cover it hasn't got.
|
|
await Deno.mkdir(`${dir}/${id}/blocker`, { recursive: true });
|
|
try {
|
|
const failed = await post(`/api/playlists/${id}/image`, {
|
|
auth: owner.auth,
|
|
form: formWithFile(PNG),
|
|
});
|
|
assertEquals(failed.status, 500);
|
|
assertEquals(row(), { image_mime: null, updated_at: null });
|
|
assertEquals((await get(`/api/playlists/${id}/image`)).status, 404);
|
|
} finally {
|
|
await Deno.remove(`${dir}/${id}`, { recursive: true });
|
|
}
|
|
|
|
// With a cover in place, a failed replacement keeps serving the old one
|
|
// under the old version, and leaves no stray temporary file behind.
|
|
await post(`/api/playlists/${id}/image`, {
|
|
auth: owner.auth,
|
|
form: formWithFile(PNG),
|
|
});
|
|
const before = row();
|
|
await Deno.chmod(dir, 0o555);
|
|
try {
|
|
const failed = await post(`/api/playlists/${id}/image`, {
|
|
auth: owner.auth,
|
|
form: formWithFile(new Uint8Array([...PNG, 0])),
|
|
});
|
|
assertEquals(failed.status, 500);
|
|
} finally {
|
|
await Deno.chmod(dir, 0o755);
|
|
}
|
|
assertEquals(row(), before);
|
|
assertEquals((await get(`/api/playlists/${id}/image`)).bytes, PNG);
|
|
const left = [...Deno.readDirSync(dir)]
|
|
.map((e) => e.name)
|
|
.filter((name) => name.startsWith(id));
|
|
assertEquals(left, [id]);
|
|
});
|
|
|
|
test("playlist images: a replaced cover is served under the new version", async () => {
|
|
const owner = await createUser();
|
|
const { id } = insertPlaylist({ userId: owner.id });
|
|
const first = await post<Playlist>(`/api/playlists/${id}/image`, {
|
|
auth: owner.auth,
|
|
form: formWithFile(PNG),
|
|
});
|
|
await new Promise((r) => setTimeout(r, 5)); // a distinct updated_at
|
|
const other = new Uint8Array([...PNG, 1, 2, 3]);
|
|
const second = await post<Playlist>(`/api/playlists/${id}/image`, {
|
|
auth: owner.auth,
|
|
form: formWithFile(other),
|
|
});
|
|
assert(second.data.updatedAt !== first.data.updatedAt);
|
|
assertEquals((await get(`/api/playlists/${id}/image`)).bytes, other);
|
|
});
|
|
|
|
test("reorder: someone else's dump that went private keeps its slot", async () => {
|
|
const owner = await createUser();
|
|
const author = await createUser();
|
|
const { id } = insertPlaylist({ userId: owner.id });
|
|
const a = insertDump({ userId: owner.id });
|
|
const theirs = insertDump({ userId: author.id });
|
|
const b = insertDump({ userId: owner.id });
|
|
// Each add goes on top: the order is b, theirs, a.
|
|
for (const d of [a, theirs, b]) {
|
|
await post(`/api/playlists/${id}/dumps/${d.id}`, { auth: owner.auth });
|
|
}
|
|
await put(`/api/dumps/${theirs.id}`, {
|
|
auth: author.auth,
|
|
json: { isPrivate: true },
|
|
});
|
|
// The owner can't see it, so can't name it: what they see is theirs to order.
|
|
assertEquals(ids(await get(`/api/playlists/${id}`, { auth: owner.auth })), [
|
|
b.id,
|
|
a.id,
|
|
]);
|
|
const res = await put(`/api/playlists/${id}/order`, {
|
|
auth: owner.auth,
|
|
json: { dumpIds: [a.id, b.id] },
|
|
});
|
|
assertEquals(res.status, 200);
|
|
assertEquals(ids(await get(`/api/playlists/${id}`, { auth: owner.auth })), [
|
|
a.id,
|
|
b.id,
|
|
]);
|
|
// The hidden one hasn't moved, and comes back where it was if made public.
|
|
await put(`/api/dumps/${theirs.id}`, {
|
|
auth: author.auth,
|
|
json: { isPrivate: false },
|
|
});
|
|
assertEquals(ids(await get(`/api/playlists/${id}`)), [a.id, theirs.id, b.id]);
|
|
// Naming a hidden id is still not the list the requester sees.
|
|
await put(`/api/dumps/${theirs.id}`, {
|
|
auth: author.auth,
|
|
json: { isPrivate: true },
|
|
});
|
|
assertEquals(
|
|
(await put(`/api/playlists/${id}/order`, {
|
|
auth: owner.auth,
|
|
json: { dumpIds: [a.id, theirs.id, b.id] },
|
|
})).status,
|
|
400,
|
|
);
|
|
});
|
|
|
|
test("reorder: a moderator orders around the owner's private dumps", async () => {
|
|
const owner = await createUser();
|
|
const mod = await createUser({ role: "moderator" });
|
|
const { id } = insertPlaylist({ userId: owner.id });
|
|
const a = insertDump({ userId: owner.id });
|
|
const priv = insertDump({ userId: owner.id, isPrivate: true });
|
|
const b = insertDump({ userId: owner.id });
|
|
for (const d of [a, priv, b]) {
|
|
await post(`/api/playlists/${id}/dumps/${d.id}`, { auth: owner.auth });
|
|
}
|
|
assertEquals(ids(await get(`/api/playlists/${id}`, { auth: mod.auth })), [
|
|
b.id,
|
|
a.id,
|
|
]);
|
|
assertEquals(
|
|
(await put(`/api/playlists/${id}/order`, {
|
|
auth: mod.auth,
|
|
json: { dumpIds: [a.id, b.id] },
|
|
})).status,
|
|
200,
|
|
);
|
|
assertEquals(ids(await get(`/api/playlists/${id}`, { auth: owner.auth })), [
|
|
a.id,
|
|
priv.id,
|
|
b.id,
|
|
]);
|
|
});
|