import { describe, expect, test, vi } from "vitest";
import {
act,
fireEvent,
screen,
waitFor,
within,
} from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { Route } from "react-router";
import { PlaylistDetail } from "../../pages/PlaylistDetail.tsx";
import type { Dump } from "../../../shared/dump.ts";
import type { Playlist } from "../../../shared/playlist.ts";
import { calls, fail, route } from "../fetch.ts";
import {
makeCategory,
makeDump,
makePlaylist,
makeSession,
wire,
} from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
const owner = makeSession({ id: "owner", username: "owner" });
const visitor = makeSession({ id: "visitor" });
const dumps = () => [
makeDump({ id: "a", title: "Alpha" }),
makeDump({ id: "b", title: "Bravo" }),
makeDump({ id: "c", title: "Charlie" }),
];
function open(
{
session = owner as ReturnType | null,
playlist = {} as Partial,
items = dumps() as Dump[],
url = "/@owner/playlists/mix",
categories = [] as ReturnType[],
} = {},
) {
const pl = makePlaylist({
id: "p1",
slug: "mix",
title: "Mix",
userId: "owner",
ownerUsername: "owner",
description: "Good **tunes**",
...playlist,
});
route(
"GET /api/users/:u/playlists/:slug",
() => wire({ ...pl, dumps: items }),
);
const user = userEvent.setup();
renderApp(, {
route: url,
path: "/:handle/playlists/:slug",
session,
categories,
extraRoutes: (
<>
home
} />
}
/>
>
),
});
const socket = FakeWebSocket.last;
socket.welcome();
return { user, socket, pl };
}
const titles = () =>
Array.from(document.querySelectorAll(".dump-card-title")).map((a) =>
a.textContent
);
describe("PlaylistDetail", () => {
test("shows the playlist and its dumps in order", async () => {
open({ session: null });
expect(await screen.findByRole("heading", { name: "Mix" }))
.toBeInTheDocument();
expect(screen.getByText("tunes").tagName).toBe("STRONG");
expect(screen.getByRole("link", { name: "@owner" })).toHaveAttribute(
"href",
"/@owner",
);
expect(calls("GET /api/users/:u/playlists/:slug")[0].path).toBe(
"/api/users/@owner/playlists/mix",
);
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]);
await waitFor(() => expect(document.title).toMatch(/^Mix ยท /));
expect(screen.queryByRole("button", { name: "Edit" })).not
.toBeInTheDocument();
});
test("not found", async () => {
route(
"GET /api/users/:u/playlists/:slug",
() => fail("Playlist not found", 404),
);
renderApp(, {
route: "/@owner/playlists/x",
path: "/:handle/playlists/:slug",
});
expect(await screen.findByRole("alert")).toHaveTextContent(
"Playlist not found",
);
});
test("a visitor can follow a public playlist, not edit it", async () => {
open({ session: visitor });
await screen.findByRole("heading", { name: "Mix" });
expect(screen.getByRole("button", { name: "Follow playlist" }))
.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Remove from playlist" })).not
.toBeInTheDocument();
});
test("the cover is versioned on the last edit", async () => {
open({ playlist: { imageMime: "image/png", updatedAt: new Date(5000) } });
await screen.findByRole("heading", { name: "Mix" });
expect(document.querySelector(".playlist-detail-img")).toHaveAttribute(
"src",
"http://localhost:8000/api/playlists/p1/cover?v=5000&w=960",
);
});
test("editing the description shows at once, without waiting for the socket", async () => {
route(
"PUT /api/playlists/:id",
() =>
wire(
makePlaylist({
id: "p1",
slug: "mix",
title: "Mix",
userId: "owner",
description: "Better tunes",
}),
),
);
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
const box = screen.getByRole("textbox", { name: "Description" });
await user.clear(box);
await user.type(box, "Better tunes");
await user.click(screen.getByRole("button", { name: "Save" }));
// Saving closes the editor, whose text would be found first.
await waitFor(() => expect(currentPath()).toBe("/@owner/playlists/mix"));
expect(screen.getByText("Better tunes")).toBeInTheDocument();
expect(calls("PUT /api/playlists/:id")[0].body).toEqual({
description: "Better tunes",
isPublic: true,
});
});
test("the editor is a page: /edit opens it, closing goes back to the page", async () => {
const { user } = open({ url: "/@owner/playlists/mix/edit" });
expect(await screen.findByRole("textbox", { name: "Description" }))
.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(currentPath()).toBe("/@owner/playlists/mix");
await user.click(screen.getByRole("button", { name: "Edit" }));
expect(currentPath()).toBe("/@owner/playlists/mix/edit");
expect(calls("GET /api/users/:u/playlists/:slug")).toHaveLength(1);
});
test("at /edit, anyone else gets the page", async () => {
open({ session: visitor, url: "/@owner/playlists/mix/edit" });
await waitFor(() => expect(currentPath()).toBe("/@owner/playlists/mix"));
expect(screen.queryByRole("textbox", { name: "Description" })).toBeNull();
});
test("at /edit, a guest is asked to sign in first", async () => {
open({ session: null, url: "/@owner/playlists/mix/edit" });
await waitFor(() =>
expect(currentPath()).toBe(
"/login?next=%2F%40owner%2Fplaylists%2Fmix%2Fedit",
)
);
});
test("its categories show, and editing them sends the new set", async () => {
const mixes = makeCategory({
id: "pc1",
kind: "playlist",
slug: "mixes",
name: "Mixes",
});
const live = makeCategory({
id: "pc2",
kind: "playlist",
slug: "live",
name: "Live",
});
const music = makeCategory({ id: "dc1", slug: "music", name: "Music" });
route(
"PUT /api/playlists/:id",
(req) =>
wire(
makePlaylist({
id: "p1",
slug: "mix",
title: "Mix",
userId: "owner",
...(req.body as object),
}),
),
);
const { user } = open({
playlist: { categoryIds: ["pc1"] },
categories: [music, mixes, live],
});
expect(await screen.findByRole("link", { name: "Mixes" }))
.toHaveAttribute("href", "/playlists/mixes");
await user.click(screen.getByRole("button", { name: "Edit" }));
// The dump categories aren't offered.
expect(screen.queryByRole("button", { name: "Music" })).toBeNull();
expect(screen.getByRole("button", { name: "Mixes" }))
.toHaveAttribute("aria-pressed", "true");
await user.click(screen.getByRole("button", { name: "Mixes" }));
await user.click(screen.getByRole("button", { name: "Live" }));
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByRole("link", { name: "Live" }))
.toHaveAttribute("href", "/playlists/live");
expect(calls("PUT /api/playlists/:id")[0].body).toEqual({
categoryIds: ["pc2"],
isPublic: true,
});
});
test("a new title proposes a new link, which saving moves to", async () => {
route(
"PUT /api/playlists/:id",
(req) =>
wire(
makePlaylist({
id: "p1",
slug: (req.body as { slug: string }).slug,
title: "Remix",
userId: "owner",
}),
),
);
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
const title = screen.getByRole("textbox", { name: "Title" });
await user.clear(title);
await user.type(title, "Re-mix!");
const link = screen.getByRole("textbox", { name: "Link" });
expect(link).toHaveValue("re-mix");
expect(screen.getByText("/@owner/playlists/")).toBeInTheDocument();
await user.clear(link);
await user.type(link, "remix");
// Typed in, the link no longer follows the title.
await user.type(title, "!");
expect(link).toHaveValue("remix");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(currentPath()).toBe("/@owner/playlists/remix"));
expect(calls("PUT /api/playlists/:id")[0].body).toMatchObject({
title: "Re-mix!!",
slug: "remix",
});
// The new path names the playlist on screen, which isn't fetched again.
expect(calls("GET /api/users/:u/playlists/:slug")).toHaveLength(1);
});
test("a former slug or name: the URL takes the current one", async () => {
open({ url: "/@OWNER/playlists/old-mix" });
await screen.findByRole("heading", { name: "Mix" });
await waitFor(() => expect(currentPath()).toBe("/@owner/playlists/mix"));
expect(calls("GET /api/users/:u/playlists/:slug")).toHaveLength(1);
});
test("a new cover shows once saved; a refused one says why", async () => {
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:preview");
route(
"PUT /api/playlists/:id",
() =>
wire(
makePlaylist({
id: "p1",
slug: "mix",
title: "Mix",
userId: "owner",
}),
),
);
route("PUT /api/playlists/:id/cover", () => fail("Not an image", 400), {
once: true,
});
route(
"PUT /api/playlists/:id/cover",
() =>
wire(
makePlaylist({
id: "p1",
slug: "mix",
title: "Mix",
userId: "owner",
imageMime: "image/png",
updatedAt: new Date(9000),
}),
),
);
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
const picker = document.querySelector(
'.img-picker input[type="file"]',
)!;
await user.upload(
picker,
new File([new Uint8Array(3)], "c.png", { type: "image/png" }),
);
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Not an image");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(document.querySelector(".playlist-detail-img")).toHaveAttribute(
"src",
"http://localhost:8000/api/playlists/p1/cover?v=9000&w=960",
)
);
});
test("Escape leaves the editor", async () => {
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
await user.type(screen.getByRole("textbox", { name: "Title" }), "{Escape}");
expect(screen.getByRole("heading", { name: "Mix" })).toBeInTheDocument();
});
test("a removal is sent once its fade ends, and an undo sends nothing", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
route(
"DELETE /api/playlists/:pid/dumps/:did",
() => new Response(null, { status: 204 }),
);
const { user } = open();
await screen.findByText("Bravo");
const bravo = screen.getByText("Bravo").closest(
".playlist-dump-item",
) as HTMLElement;
await user.click(
within(bravo).getByRole("button", { name: "Remove from playlist" }),
);
await user.click(
within(bravo).getByRole("button", { name: "Cancel removal" }),
);
act(() => vi.advanceTimersByTime(2_400));
expect(screen.getByText("Bravo")).toBeInTheDocument();
expect(calls("DELETE /api/playlists/:pid/dumps/:did")).toHaveLength(0);
await user.click(
within(bravo).getByRole("button", { name: "Remove from playlist" }),
);
act(() => vi.advanceTimersByTime(2_400));
expect(screen.queryByText("Bravo")).not.toBeInTheDocument();
expect(calls("DELETE /api/playlists/:pid/dumps/:did")[0].path).toBe(
"/api/playlists/p1/dumps/b",
);
} finally {
vi.useRealTimers();
}
});
test("leaving the page during the fade still sends the removal", async () => {
route(
"DELETE /api/playlists/:pid/dumps/:did",
() => new Response(null, { status: 204 }),
);
const { user } = open();
await screen.findByText("Bravo");
const bravo = screen.getByText("Bravo").closest(
".playlist-dump-item",
) as HTMLElement;
await user.click(
within(bravo).getByRole("button", { name: "Remove from playlist" }),
);
act(() => globalThis.dispatchEvent(new Event("pagehide")));
expect(calls("DELETE /api/playlists/:pid/dumps/:did")).toHaveLength(1);
});
test("a refused removal puts the dump back and says so", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
route(
"DELETE /api/playlists/:pid/dumps/:did",
() => fail("Forbidden", 403),
);
const { user } = open();
await screen.findByText("Bravo");
const bravo = screen.getByText("Bravo").closest(
".playlist-dump-item",
) as HTMLElement;
await user.click(
within(bravo).getByRole("button", { name: "Remove from playlist" }),
);
act(() => vi.advanceTimersByTime(2_400));
expect(await screen.findByRole("alert")).toHaveTextContent(
"couldn't be removed",
);
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]);
} finally {
vi.useRealTimers();
}
});
test("dragging reorders, and a refused reorder goes back to the server's order", async () => {
route("PUT /api/playlists/:pid/order", () => fail("Forbidden", 403));
open();
await screen.findByText("Charlie");
const items = () =>
Array.from(document.querySelectorAll(".playlist-dump-item"));
for (const el of items()) {
el.getBoundingClientRect = () => ({ top: 0, height: 100 }) as DOMRect;
}
const [a, , c] = items();
fireEvent.dragStart(a);
fireEvent.dragOver(c, { clientY: 90 });
expect(titles()).toEqual(["Bravo", "Charlie", "Alpha"]);
fireEvent.dragEnd(items()[2]);
await waitFor(() =>
expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1)
);
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({
dumpIds: ["b", "c", "a"],
});
expect(await screen.findByRole("alert")).toHaveTextContent(
"order couldn't be saved",
);
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]);
});
test("a dump that goes private leaves, and comes back at its place", async () => {
route(
"GET /api/dumps/:id",
() => wire(makeDump({ id: "b", title: "Bravo" })),
);
const { socket } = open({ session: visitor });
await screen.findByText("Bravo");
// What the server sends others when it goes private, then public again.
socket.receive({ type: "dump_deleted", dumpId: "b" });
socket.receive({
type: "playlist_dumps_updated",
playlistId: "p1",
dumpIds: ["a", "c"],
});
expect(titles()).toEqual(["Alpha", "Charlie"]);
socket.receive({
type: "dump_created",
dump: wire(makeDump({ id: "b", title: "Bravo" })),
});
socket.receive({
type: "playlist_dumps_updated",
playlistId: "p1",
dumpIds: ["a", "b", "c"],
});
expect(await screen.findByText("Bravo")).toBeInTheDocument();
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]);
});
test("a playlist made private by its owner sends a visitor home", async () => {
const { socket } = open({ session: visitor });
await screen.findByText("Alpha");
// What the server sends everyone but the owner.
socket.receive({
type: "playlist_deleted",
playlistId: "p1",
userId: "owner",
});
expect(await screen.findByText("home")).toBeInTheDocument();
});
test("deleting the playlist asks, then goes home", async () => {
route(
"DELETE /api/playlists/:id",
() => new Response(null, { status: 204 }),
);
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Delete playlist" }));
await user.click(
within(screen.getByRole("alertdialog")).getByRole("button", {
name: "Delete playlist",
}),
);
expect(await screen.findByText("home")).toBeInTheDocument();
});
test("a refused delete stays and says why", async () => {
route("DELETE /api/playlists/:id", () => fail("Forbidden", 403));
const { user } = open();
await user.click(await screen.findByRole("button", { name: "Edit" }));
await user.click(screen.getByRole("button", { name: "Delete playlist" }));
await user.click(
within(screen.getByRole("alertdialog")).getByRole("button", {
name: "Delete playlist",
}),
);
expect(await screen.findByRole("alert")).toHaveTextContent("Forbidden");
expect(currentPath()).toBe("/@owner/playlists/mix/edit");
});
test("live: added dumps appear, removed ones fade, a deleted playlist sends you home", async () => {
route(
"GET /api/dumps/:id",
() => wire(makeDump({ id: "d", title: "Delta" })),
);
const { socket } = open({ session: visitor });
await screen.findByText("Alpha");
socket.receive({
type: "playlist_dumps_updated",
playlistId: "p1",
dumpIds: ["d", "a", "b", "c"],
});
expect(await screen.findByText("Delta")).toBeInTheDocument();
socket.receive({
type: "playlist_updated",
playlist: wire(
makePlaylist({ id: "p1", title: "Renamed", userId: "owner" }),
),
});
expect(screen.getByRole("heading", { name: "Renamed" }))
.toBeInTheDocument();
socket.receive({ type: "dump_deleted", dumpId: "a" });
expect(screen.queryByText("Alpha")).not.toBeInTheDocument();
socket.receive({
type: "playlist_deleted",
playlistId: "p1",
userId: "owner",
});
expect(await screen.findByText("home")).toBeInTheDocument();
});
});