All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
449 lines
15 KiB
TypeScript
449 lines
15 KiB
TypeScript
import {
|
|
count,
|
|
createUser,
|
|
db,
|
|
del,
|
|
formWithFile,
|
|
get,
|
|
insertDump,
|
|
insertPlaylist,
|
|
PNG,
|
|
post,
|
|
put,
|
|
test,
|
|
TEST_DIR,
|
|
} 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);
|
|
});
|
|
|
|
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(`/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(`/api/playlists/${id}/image`, {
|
|
auth: owner.auth,
|
|
form: formWithFile(other),
|
|
});
|
|
assert(second.body.data.updatedAt !== first.body.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,
|
|
]);
|
|
});
|