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(); }); });