515 lines
16 KiB
TypeScript
515 lines
16 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("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,
|
|
]);
|
|
});
|