Files
gerbeur/api/test/routes/playlists.test.ts

542 lines
18 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 } from "../../../shared/api.ts";
import type {
Playlist,
PlaylistMembership,
PlaylistWithDumps,
} from "../../../shared/playlist.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 by path under its owner", 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;
assertEquals(slug, "road-trip");
assertEquals(res.data.ownerUsername, user.username);
assertEquals(
(await get<PlaylistWithDumps>(`/api/playlists/${id}`)).data.title,
"Road Trip",
);
assertEquals((await get(`/api/playlists/${slug}`)).status, 404);
const bySlug = await get<PlaylistWithDumps>(
`/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<PaginatedData<Playlist>>(path)).data.items.length, 1);
assertEquals(
(await get<PaginatedData<Playlist>>(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<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");
// 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<Playlist>(`/api/playlists/${id}`, {
auth: owner.auth,
json: { slug: "new" },
});
assertEquals(reslugged.data.slug, "new");
assertEquals(
(await get<PlaylistWithDumps>(
`/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<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/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<Playlist>(`/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<Playlist>(`/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<Playlist>(`/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,
]);
});