All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 48s
Fixed along the way. Dumps: clearing a dump's note sent `comment: undefined`, which JSON drops, so the server kept the old one; it's sent as null now. Replacing a file dropped a visibility change made in the same save (the file endpoint doesn't read it); it follows as a separate update. Renaming a dump onto a new slug kept showing the old title until reload. The thumbnail picker and delete on the edit page failed without a word, the latter as an unhandled rejection. Playlists: an edit that kept the slug waited on the socket's echo to show, and a new cover never appeared (the upload's answer was ignored, a refused one said nothing); covers were also served stale for an hour, since `max-age=3600` met an unversioned URL — setPlaylistImage now bumps updated_at and every cover URL carries `?v=` (playlistImageUrl). Removing a dump, reordering, and deleting a playlist ignored non-OK answers, leaving page and server apart; they restore, reload or report. Profile: clearing "Who am I?" sent undefined and came back on reload; your own upvoted list (and the upvoted page) was empty until the socket's welcome, and forever without one; "View all" under Following linked to a route that doesn't exist and is a Load more now. Chat: Escape on "Delete this message?" closed the whole chat — ConfirmModal takes Escape first, in the capture phase. Player: a pause or play from a media key, headset or the OS wasn't reflected (MediaPlayer follows the element's own play/pause events), and the seek bar tracked dragging with mouse events only, so on touch the thumb fought timeupdate. Feeds: a guest on /~/followed got a blank page; a server error in the followed feed read as "Could not connect". Search: a failed later page threw away the results shown. Notifications: a failed mark-read replaced a loaded list with an error. Silent failures now say so: deleting a comment, the voters/likers "Load more" (stuck on "Loading…"), and the password-reset request, which said "sent" whatever happened. Notifications also stop hiding what was new: the bell still clears on opening the page, but the dots and the "N new" count stay for the visit instead of vanishing the moment the mark-read lands, and one arriving while the page is open is marked read too, so the bell doesn't count it. Accessibility: Modal and ConfirmModal are dialogs (role, aria-modal, labelled/described); playlist membership rows are checkboxes that work from the keyboard; a dump's title, and the profile's email and description, can be opened for editing from the keyboard (the pencil shows on focus); the category editor's ✓ and the global player's video button have names. Player, rich-content and download labels were hard-coded English and are translated, with the new French strings. Lint: fast-refresh and render-purity rules are off for src/test/ only, where probes hand their context out on purpose. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
306 lines
9.8 KiB
TypeScript
306 lines
9.8 KiB
TypeScript
import {
|
|
count,
|
|
createUser,
|
|
db,
|
|
del,
|
|
formWithFile,
|
|
get,
|
|
insertDump,
|
|
insertPlaylist,
|
|
PNG,
|
|
post,
|
|
put,
|
|
test,
|
|
} from "./helpers.ts";
|
|
import { assert, assertEquals, assertMatch } from "@std/assert";
|
|
|
|
function ids(res: { body: { data: { dumps: { id: string }[] } } }): string[] {
|
|
return res.body.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("/api/playlists", {
|
|
auth: user.auth,
|
|
json: { title: "Road Trip", description: "songs", isPublic: true },
|
|
});
|
|
assertEquals(res.status, 201);
|
|
const { id, slug } = res.body.data;
|
|
assertMatch(slug, /^road-trip-/);
|
|
assertEquals(
|
|
(await get(`/api/playlists/${id}`)).body.data.title,
|
|
"Road Trip",
|
|
);
|
|
const bySlug = await get(`/api/playlists/${slug}`);
|
|
assertEquals(bySlug.body.data.id, id);
|
|
assertEquals(bySlug.body.data.ownerUsername, user.username);
|
|
assertEquals(bySlug.body.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(path)).body.data.total, 1);
|
|
assertEquals((await get(path, { auth: owner.auth })).body.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(`/api/playlists/${id}`)).body.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(`/api/playlists/${id}`, {
|
|
auth: owner.auth,
|
|
json: { title: "New", description: "d", isPublic: false },
|
|
});
|
|
assertEquals(updated.body.data.title, "New");
|
|
assertMatch(updated.body.data.slug, /^new-/);
|
|
assertEquals(updated.body.data.isPublic, false);
|
|
const cleared = await put(`/api/playlists/${id}`, {
|
|
auth: owner.auth,
|
|
json: { description: null },
|
|
});
|
|
assertEquals(cleared.body.data.description, undefined);
|
|
assertEquals(cleared.body.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(`/api/playlists/by-dump/${d.id}/memberships`, {
|
|
auth: owner.auth,
|
|
});
|
|
const byTitle = Object.fromEntries(
|
|
res.body.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(`/api/playlists/${slug}/image`, {
|
|
auth: owner.auth,
|
|
form: formWithFile(PNG),
|
|
});
|
|
assertEquals(res.status, 200);
|
|
assertEquals(res.body.data.imageMime, "image/png");
|
|
// A new cover counts as an edit, so image URLs versioned on it change.
|
|
assert(res.body.data.updatedAt);
|
|
const served = await get(`/api/playlists/${slug}/image`);
|
|
assertEquals(served.status, 200);
|
|
assertEquals(served.bytes, PNG);
|
|
});
|