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, ]); });