import { type ApiResponse, count, createUser, db, del, formWithFile, get, insertCategory, insertDump, insertPlaylist, PNG, post, put, test, TEST_DIR, } from "../helpers.ts"; import type { PaginatedData } from "../../../shared/api.ts"; import { comparePlaylists, type Playlist, PLAYLIST_SORTS, type PlaylistMembership, type PlaylistWithDumps, } from "../../../shared/playlist.ts"; import { assert, assertEquals, assertMatch } from "@std/assert"; function ids(res: ApiResponse): string[] { return res.data.dumps.map((d) => d.id); } test("POST /api/playlists creates; GET finds it by id, or by path under its owner", 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.data; assertEquals(slug, "road-trip"); assertEquals(res.data.ownerUsername, user.username); assertEquals( (await get(`/api/playlists/${id}`)).data.title, "Road Trip", ); assertEquals((await get(`/api/playlists/${slug}`)).status, 404); const bySlug = await get( `/api/users/@${user.username}/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>(path)).data.items.length, 1); assertEquals( (await get>(path, { auth: owner.auth })).data.items .length, 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}`)).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("a playlist's dumpCount is of the dumps the viewer may see", async () => { const owner = await createUser(); const author = await createUser(); const pl = insertPlaylist({ userId: owner.id, title: "Counted banana" }); const pub = insertDump({ userId: owner.id }); const mine = insertDump({ userId: owner.id, isPrivate: true }); // Someone else's, gone private after it was added: its owner no longer // sees it there, and neither does its poster. const theirs = insertDump({ userId: author.id, isPrivate: true }); for (const [i, d] of [pub, mine, theirs].entries()) { db.prepare( `INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) VALUES (?, ?, ?, ?);`, ).run(pl.id, d.id, i, new Date().toISOString()); } await put(`/api/me/follow/playlists/${pl.id}`, { auth: author.auth }); const lists = [ "/api/playlists", `/api/users/@${owner.username}/playlists`, `/api/users/@${author.username}/follow/playlists`, ]; for ( const [auth, expected] of [ [owner.auth, 2], [author.auth, 1], [undefined, 1], ] as const ) { for (const url of lists) { const { items } = (await get>(url, { auth })) .data; assertEquals(items.map((p) => p.dumpCount), [expected], url); } assertEquals( (await get<{ playlists: Playlist[] }>("/api/search?q=banana", { auth })) .data.playlists.map((p) => p.dumpCount), [expected], ); assertEquals( (await get(`/api/dumps/${pub.id}/playlists`, { auth })).data .map((p) => p.dumpCount), [expected], ); const page = await get(`/api/playlists/${pl.id}`, { auth, }); assertEquals(page.data.dumpCount, expected); assertEquals(ids(page).length, expected); } assertEquals( (await get(`/api/me/playlists?contains=${pub.id}`, { auth: owner.auth, })).data.map((m) => [m.hasDump, m.playlist.dumpCount]), [[true, 2]], ); // An edit answers with the editor's count. assertEquals( (await put(`/api/playlists/${pl.id}`, { auth: owner.auth, json: { title: "Counted" }, })).data.dumpCount, 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.data.title, "New"); // A new title never changes the slug by itself; a slug sent does. assertEquals(updated.data.slug, "old"); assertEquals(updated.data.isPublic, false); const reslugged = await put(`/api/playlists/${id}`, { auth: owner.auth, json: { slug: "new" }, }); assertEquals(reslugged.data.slug, "new"); assertEquals( (await get( `/api/users/@${owner.username}/playlists/old`, { auth: owner.auth }, )).data.slug, "new", ); assertEquals( (await put(`/api/playlists/${id}`, { auth: owner.auth, json: { slug: "Bad slug" }, })).status, 400, ); const cleared = await put(`/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( `/api/me/playlists?contains=${d.id}`, { 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 put(`/api/me/follow/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("a playlist going public notifies its followers of the public dumps added meanwhile", async () => { const owner = await createUser(); const follower = await createUser(); const { id } = insertPlaylist({ userId: owner.id, title: "Mix" }); await put(`/api/me/follow/playlists/${id}`, { auth: follower.auth }); const before = insertDump({ userId: owner.id, title: "Before" }); await post(`/api/playlists/${id}/dumps/${before.id}`, { auth: owner.auth }); db.prepare(`DELETE FROM notifications WHERE user_id = ?;`).run(owner.id); const setPublic = (isPublic: boolean) => put(`/api/playlists/${id}`, { auth: owner.auth, json: { isPublic } }); await setPublic(false); const during = insertDump({ userId: owner.id, title: "During" }); const priv = insertDump({ userId: owner.id, isPrivate: true }); for (const d of [during, priv]) { await post(`/api/playlists/${id}/dumps/${d.id}`, { auth: owner.auth }); } assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 1); await setPublic(true); const titles = (db.prepare( `SELECT data FROM notifications WHERE user_id = ? ORDER BY created_at;`, ).all(follower.id) as { data: string }[]).map((n) => JSON.parse(n.data).dumpTitle ); assertEquals(titles, ["Before", "During"]); }); test("playlist covers: upload and serve", async () => { const owner = await createUser(); const other = await createUser(); const { id } = insertPlaylist({ userId: owner.id }); assertEquals((await get(`/api/playlists/${id}/cover`)).status, 404); assertEquals( (await put(`/api/playlists/${id}/cover`, { auth: other.auth, form: formWithFile(PNG), })) .status, 403, ); const res = await put(`/api/playlists/${id}/cover`, { 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/${id}/cover`); assertEquals(served.status, 200); assertEquals(served.bytes, PNG); }); test("playlist covers: a private playlist's cover only for its owner's media token", async () => { const owner = await createUser(); const other = await createUser(); const { id } = insertPlaylist({ userId: owner.id, isPublic: false }); await put(`/api/playlists/${id}/cover`, { auth: owner.auth, form: formWithFile(PNG), }); assertEquals((await get(`/api/playlists/${id}/cover`)).status, 404); assertEquals( (await get(`/api/playlists/${id}/cover?token=${other.mediaToken}`)).status, 404, ); assertEquals( (await get(`/api/playlists/${id}/cover?token=garbage`)).status, 404, ); const served = await get( `/api/playlists/${id}/cover?token=${owner.mediaToken}`, ); assertEquals(served.status, 200); assertEquals(served.bytes, PNG); assertMatch(served.headers.get("cache-control") ?? "", /^private/); }); test("playlist covers: 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 put(`/api/playlists/${id}/cover`, { auth: owner.auth, form: formWithFile(PNG), }); assertEquals(failed.status, 500); assertEquals(row(), { image_mime: null, updated_at: null }); assertEquals((await get(`/api/playlists/${id}/cover`)).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 put(`/api/playlists/${id}/cover`, { auth: owner.auth, form: formWithFile(PNG), }); const before = row(); await Deno.chmod(dir, 0o555); try { const failed = await put(`/api/playlists/${id}/cover`, { 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}/cover`)).bytes, PNG); const left = [...Deno.readDirSync(dir)] .map((e) => e.name) .filter((name) => name.startsWith(id)); assertEquals(left, [id]); }); test("playlist covers: a replaced cover is served under the new version", async () => { const owner = await createUser(); const { id } = insertPlaylist({ userId: owner.id }); const first = await put(`/api/playlists/${id}/cover`, { 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 put(`/api/playlists/${id}/cover`, { auth: owner.auth, form: formWithFile(other), }); assert(second.data.updatedAt !== first.data.updatedAt); assertEquals((await get(`/api/playlists/${id}/cover`)).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, ]); }); test("playlist categories: filed on creation, replaced on update, dump categories refused", async () => { const user = await createUser(); const mixes = insertCategory({ slug: "mixes", kind: "playlist" }); const live = insertCategory({ slug: "live", kind: "playlist" }); const music = insertCategory({ slug: "music" }); const refused = await post("/api/playlists", { auth: user.auth, json: { title: "Wrong", isPublic: true, categoryIds: [music] }, }); assertEquals(refused.status, 400); assertEquals(count(`SELECT COUNT(*) as n FROM playlists;`), 0); assertEquals( (await post("/api/playlists", { auth: user.auth, json: { title: "Bad", isPublic: true, categoryIds: "mixes" }, })).status, 400, ); const created = await post("/api/playlists", { auth: user.auth, json: { title: "Mix", isPublic: true, categoryIds: [mixes, mixes] }, }); assertEquals(created.status, 201); assertEquals(created.data.categoryIds, [mixes]); assertEquals( (await get(`/api/playlists/${created.data.id}`)).data .categoryIds, [mixes], ); const id = created.data.id; assertEquals( (await put(`/api/playlists/${id}`, { auth: user.auth, json: { categoryIds: [music] }, })).status, 400, ); const updated = await put(`/api/playlists/${id}`, { auth: user.auth, json: { categoryIds: [live] }, }); assertEquals(updated.data.categoryIds, [live]); // A change of anything else keeps them. const renamed = await put(`/api/playlists/${id}`, { auth: user.auth, json: { title: "Live mix" }, }); assertEquals(renamed.data.categoryIds, [live]); const cleared = await put(`/api/playlists/${id}`, { auth: user.auth, json: { categoryIds: [] }, }); assertEquals(cleared.data.categoryIds, []); }); test("GET /api/playlists: every public playlist, by category", async () => { const owner = await createUser(); const other = await createUser(); const mixes = insertCategory({ slug: "mixes", kind: "playlist" }); insertCategory({ slug: "music" }); const a = insertPlaylist({ userId: owner.id, title: "A" }); insertPlaylist({ userId: other.id, title: "B" }); const hidden = insertPlaylist({ userId: owner.id, title: "Hidden", isPublic: false, }); db.prepare(`UPDATE playlists SET created_at = ? WHERE id = ?;`) .run("2026-01-01T00:00:00.000Z", a.id); for (const p of [a, hidden]) { db.prepare( `INSERT INTO playlist_categories (playlist_id, category_id) VALUES (?, ?);`, ).run(p.id, mixes); } const all = await get>("/api/playlists", { auth: owner.auth, }); assertEquals(all.data.items.map((p) => p.title), ["B", "A"]); assertEquals(all.data.items[1].categoryIds, [mixes]); assertEquals( (await get>("/api/playlists?limit=1")).data .hasMore, true, ); assertEquals( (await get>("/api/playlists?category=mixes")).data .items.map((p) => p.id), [a.id], ); // A dump category's slug names no playlist category. assertEquals((await get("/api/playlists?category=music")).status, 404); assertEquals((await get("/api/playlists?category=nope")).status, 404); }); test("GET /api/users/:key/playlists?category=: their playlists in one category", async () => { const owner = await createUser(); const mixes = insertCategory({ slug: "mixes", kind: "playlist" }); const a = insertPlaylist({ userId: owner.id, title: "A" }); insertPlaylist({ userId: owner.id, title: "B" }); db.prepare( `INSERT INTO playlist_categories (playlist_id, category_id) VALUES (?, ?);`, ).run(a.id, mixes); const url = `/api/users/${owner.id}/playlists`; assertEquals( (await get>(url)).data.items.length, 2, ); assertEquals( (await get>(`${url}?category=mixes`)).data.items .map((p) => p.id), [a.id], ); assertEquals( (await get>(`${url}?category=nope`)).data.items, [], ); }); test("?sort=: by the last dump added, by creation or by dumps, as the viewer sees them", async () => { const owner = await createUser(); const made = (title: string, createdAt: string) => { const { id } = insertPlaylist({ userId: owner.id, title }); db.prepare(`UPDATE playlists SET created_at = ? WHERE id = ?;`) .run(createdAt, id); return id; }; const add = (playlistId: string, addedAt: string, isPrivate = false) => db.prepare( `INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) VALUES (?, ?, 0, ?);`, ).run(playlistId, insertDump({ userId: owner.id, isPrivate }).id, addedAt); const a = made("A", "2026-01-01T00:00:00.000Z"); const b = made("B", "2026-02-01T00:00:00.000Z"); const c = made("C", "2026-03-01T00:00:00.000Z"); add(a, "2026-04-01T00:00:00.000Z"); add(b, "2026-02-10T00:00:00.000Z"); add(b, "2026-02-11T00:00:00.000Z"); // Only the owner sees it: to anyone else, C is empty and as it was made. add(c, "2026-05-01T00:00:00.000Z", true); type Auth = typeof owner.auth; const list = async (query: string, auth?: Auth) => (await get>(query, { auth })).data.items; const titles = async (query: string, auth?: Auth) => (await list(query, auth)).map((p) => p.title); for ( const url of ["/api/playlists", `/api/users/${owner.id}/playlists`] ) { assertEquals(await titles(`${url}?sort=updated`), ["A", "C", "B"], url); assertEquals(await titles(`${url}?sort=new`), ["C", "B", "A"], url); assertEquals(await titles(`${url}?sort=dumps`), ["B", "A", "C"], url); assertEquals( await titles(`${url}?sort=updated`, owner.auth), ["C", "A", "B"], url, ); // A and C hold one dump each to their owner: the newer goes first. assertEquals( await titles(`${url}?sort=dumps`, owner.auth), ["B", "C", "A"], url, ); // The first order is the default, which an unknown one falls back to. assertEquals(await titles(url), ["A", "C", "B"], url); assertEquals(await titles(`${url}?sort=nope`), ["A", "C", "B"], url); assertEquals(await titles(`${url}?sort=new&limit=1&page=2`), ["B"], url); } const dates = (await list("/api/playlists?sort=new")).map((p) => p.lastDumpAt ); assertEquals(dates, [ undefined, "2026-02-11T00:00:00.000Z", "2026-04-01T00:00:00.000Z", ] as unknown[]); assertEquals( (await get(`/api/playlists/${c}`, { auth: owner.auth })) .data.lastDumpAt as unknown, "2026-05-01T00:00:00.000Z", ); assertEquals( (await get(`/api/playlists/${c}`)).data.lastDumpAt, undefined, ); }); test("comparePlaylists orders playlists as the API's ?sort= pages them", async () => { const owner = await createUser(); // Ties of every kind: dates and counts shared by several playlists. for (let i = 0; i < 8; i++) { const { id } = insertPlaylist({ userId: owner.id, title: `P${i}` }); db.prepare(`UPDATE playlists SET created_at = ? WHERE id = ?;`) .run(`2026-01-0${1 + i}T00:00:00.000Z`, id); for (let n = 0; n < i % 3; n++) { db.prepare( `INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) VALUES (?, ?, ?, ?);`, ).run( id, insertDump({ userId: owner.id }).id, n, `2026-02-0${1 + ((i * 5 + n) % 9)}T00:00:00.000Z`, ); } } for (const sort of PLAYLIST_SORTS) { const { items } = (await get>( `/api/playlists?sort=${sort}`, )).data; // The dates come as strings over the wire. const playlists = items.map((p) => ({ ...p, createdAt: new Date(p.createdAt), lastDumpAt: p.lastDumpAt && new Date(p.lastDumpAt), })); assertEquals( playlists.toSorted(comparePlaylists(sort)).map((p) => p.title), items.map((p) => p.title), sort, ); } });