543 lines
18 KiB
TypeScript
543 lines
18 KiB
TypeScript
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<typeof makeSession> | null,
|
|
playlist = {} as Partial<Playlist>,
|
|
items = dumps() as Dump[],
|
|
url = "/@owner/playlists/mix",
|
|
categories = [] as ReturnType<typeof makeCategory>[],
|
|
} = {},
|
|
) {
|
|
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(<PlaylistDetail />, {
|
|
route: url,
|
|
path: "/:handle/playlists/:slug",
|
|
session,
|
|
categories,
|
|
extraRoutes: (
|
|
<>
|
|
<Route path="/" element={<p>home</p>} />
|
|
<Route
|
|
path="/:handle/playlists/:slug/edit"
|
|
element={<PlaylistDetail editing />}
|
|
/>
|
|
</>
|
|
),
|
|
});
|
|
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(<PlaylistDetail />, {
|
|
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<HTMLInputElement>(
|
|
'.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<HTMLElement>(".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();
|
|
});
|
|
});
|