import { describe, expect, test, vi } from "vitest"; import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Route } from "react-router"; import { Dump } from "../../pages/Dump.tsx"; import { DumpEdit } from "../../pages/DumpEdit.tsx"; import { RestrictedLoggedIn } from "../../pages/RestrictedLoggedIn.tsx"; import { calls, fail, route } from "../fetch.ts"; import { makeCategory, makeComment, makeDump, makePlaylist, makeSession, makeUser, wire, } from "../fixtures.ts"; import { currentPath, renderApp } from "../render.tsx"; import { FakeWebSocket } from "../ws.ts"; const owner = makeSession({ id: "owner", username: "owner" }); const other = makeSession({ id: "other", username: "other" }); const music = makeCategory({ id: "c1", slug: "music", name: "Music" }); function dumpRoutes( dump = makeDump({ id: "d1", slug: "a-find", title: "A find", userId: "owner", comment: "Because **reasons**", categoryIds: ["c1"], }), ) { route("GET /api/dumps/:id", () => wire(dump)); route( "GET /api/users/by-id/:id", () => wire(makeUser({ id: "owner", username: "owner" })), ); route("GET /api/dumps/:id/comments", () => []); route("GET /api/dumps/:id/related", () => []); route("GET /api/dumps/:id/playlists", () => []); return dump; } function openDump( session: ReturnType | null = null, url = "/dumps/a-find", ) { const user = userEvent.setup(); renderApp(, { route: url, path: "/dumps/:selectedDump", session, categories: [music], extraRoutes: ( edit page

} /> ), }); const socket = FakeWebSocket.last; socket.welcome(); return { user, socket }; } describe("Dump page", () => { test("shows the dump, its author, categories and note", async () => { dumpRoutes(); openDump(); expect(await screen.findByRole("heading", { name: "A find" })) .toBeInTheDocument(); expect(await screen.findByRole("link", { name: "owner" })).toHaveAttribute( "href", "/users/owner", ); expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute( "href", "/music", ); expect(screen.getByText("reasons").tagName).toBe("STRONG"); await waitFor(() => expect(document.title).toMatch(/^A find · /)); expect(calls("GET /api/dumps/:id")[0].params.id).toBe("a-find"); }); test("a missing dump is an error page", async () => { route("GET /api/dumps/:id", () => fail("Dump not found", 404)); route("GET /api/dumps/:id/comments", () => []); route("GET /api/dumps/:id/related", () => []); route("GET /api/dumps/:id/playlists", () => []); openDump(); expect(await screen.findByRole("alert")).toHaveTextContent( "Dump not found", ); expect(screen.getByRole("button", { name: "← Back to all dumps" })) .toBeInTheDocument(); }); test("the owner can rename it in place, following the new slug", async () => { dumpRoutes(); route( "PUT /api/dumps/:id", () => wire( makeDump({ id: "d1", slug: "a-better-find", title: "A better find", userId: "owner", }), ), ); const { user } = openDump(owner); await user.click(await screen.findByRole("heading", { name: "A find" })); const input = screen.getByDisplayValue("A find"); await user.clear(input); await user.type(input, "A better find{Enter}"); expect(await screen.findByRole("heading", { name: "A better find" })) .toBeInTheDocument(); await waitFor(() => expect(currentPath()).toBe("/dumps/a-better-find")); expect(calls("PUT /api/dumps/:id")[0].body).toEqual({ title: "A better find", }); }); test("a refused rename says why; Escape backs out", async () => { dumpRoutes(); route("PUT /api/dumps/:id", () => fail("Title taken", 400)); const { user } = openDump(owner); await user.click(await screen.findByRole("heading", { name: "A find" })); await user.type(screen.getByDisplayValue("A find"), "!{Enter}"); expect(await screen.findByText("Title taken")).toBeInTheDocument(); await user.keyboard("{Escape}"); expect(screen.getByRole("heading", { name: "A find" })).toBeInTheDocument(); }); test("others can't rename or edit it", async () => { dumpRoutes(); const { user } = openDump(other); await user.click(await screen.findByRole("heading", { name: "A find" })); expect(screen.queryByDisplayValue("A find")).not.toBeInTheDocument(); expect(screen.queryByRole("link", { name: "Edit" })).not .toBeInTheDocument(); }); test("the owner gets an Edit link", async () => { dumpRoutes(); const { user } = openDump(owner); await user.click(await screen.findByRole("link", { name: "Edit" })); expect(currentPath()).toBe("/dumps/a-find/edit"); }); test("the title can be edited from the keyboard", async () => { dumpRoutes(); const { user } = openDump(owner); await screen.findByRole("heading", { name: "A find" }); screen.getByTitle("Edit title").focus(); await user.keyboard("{Enter}"); expect(screen.getByDisplayValue("A find")).toBeInTheDocument(); }); test("votes go through the socket", async () => { dumpRoutes( makeDump({ id: "d1", slug: "a-find", title: "A find", voteCount: 2 }), ); const { user, socket } = openDump(other); await user.click(await screen.findByRole("button", { name: "Upvote" })); expect(socket.messages).toContainEqual({ type: "vote_cast", dumpId: "d1" }); expect(screen.getByRole("button", { name: "Remove vote" })) .toHaveTextContent("3"); }); test("comments load, and live ones arrive", async () => { dumpRoutes(); route( "GET /api/dumps/:id/comments", () => wire([makeComment({ id: "c1", dumpId: "d1", body: "first" })]), ); const { socket } = openDump(other); await screen.findByText("first"); socket.receive({ type: "comment_created", comment: wire(makeComment({ id: "c2", dumpId: "d1", body: "second" })), }); expect(screen.getByText("second")).toBeInTheDocument(); socket.receive({ type: "comment_deleted", commentId: "c1", dumpId: "d1" }); expect(screen.getByText("[deleted]")).toBeInTheDocument(); // Another dump's comment isn't shown here. socket.receive({ type: "comment_created", comment: wire( makeComment({ id: "c3", dumpId: "zzz", body: "elsewhere" }), ), }); expect(screen.queryByText("elsewhere")).not.toBeInTheDocument(); }); test("related dumps and the playlists it's in", async () => { dumpRoutes(); route( "GET /api/dumps/:id/related", () => wire([makeDump({ title: "Links here" })]), ); route( "GET /api/dumps/:id/playlists", () => wire([makePlaylist({ title: "Best of" })]), ); openDump(); expect(await screen.findByRole("heading", { name: "Related" })) .toBeInTheDocument(); expect(screen.getByText("Links here")).toBeInTheDocument(); expect(await screen.findByText("Best of")).toBeInTheDocument(); }); test("a live update of this dump is applied", async () => { const d = dumpRoutes(); const { socket } = openDump(); await screen.findByRole("heading", { name: "A find" }); socket.receive({ type: "dump_updated", dump: wire({ ...d, title: "Retitled" }), }); expect(screen.getByRole("heading", { name: "Retitled" })) .toBeInTheDocument(); }); test("adding to a playlist", async () => { dumpRoutes(); route("GET /api/playlists/by-dump/:id/memberships", () => [ { playlist: wire(makePlaylist({ id: "p", title: "Mine" })), hasDump: true, }, ]); route( "DELETE /api/playlists/:pid/dumps/:did", () => new Response(null, { status: 204 }), ); const { user } = openDump(owner); await user.click(await screen.findByRole("button", { name: "+ Playlist" })); const row = await screen.findByRole("checkbox", { name: /Mine/ }); expect(row).toHaveAttribute("aria-checked", "true"); await user.click(row); await waitFor(() => expect(row).toHaveAttribute("aria-checked", "false")); }); test("a new playlist can be made from there, with the dump in it", async () => { dumpRoutes(); route("GET /api/playlists/by-dump/:id/memberships", () => []); route( "POST /api/playlists", (req) => wire( makePlaylist({ id: "np", title: (req.body as { title: string }).title, }), ), ); route("POST /api/playlists/:pid/dumps/:did", () => ({})); const { user } = openDump(owner); await user.click(await screen.findByRole("button", { name: "+ Playlist" })); await user.click( await screen.findByRole("button", { name: "+ New playlist" }), ); await user.type(screen.getByPlaceholderText("Title"), "Fresh{Enter}"); const row = await screen.findByRole("checkbox", { name: /Fresh/ }); expect(row).toHaveAttribute("aria-checked", "true"); expect(calls("POST /api/playlists/:pid/dumps/:did")[0].path).toBe( "/api/playlists/np/dumps/d1", ); }); test("scrolls to a linked comment", async () => { dumpRoutes(); route( "GET /api/dumps/:id/comments", () => wire([makeComment({ id: "c9", dumpId: "d1", body: "linked" })]), ); const scroll = vi.spyOn(Element.prototype, "scrollIntoView"); openDump(null, "/dumps/a-find#comment-c9"); await screen.findByText("linked"); await waitFor(() => expect(document.getElementById("comment-c9")).toHaveClass( "comment-node--highlight", ) ); expect(scroll).toHaveBeenCalled(); }); }); describe("DumpEdit page", () => { function openEdit(dump: ReturnType, session = owner) { route("GET /api/dumps/:id", () => wire(dump)); const user = userEvent.setup(); renderApp( , { route: `/dumps/${dump.slug ?? dump.id}/edit`, path: "/dumps/:selectedDump/edit", session, categories: [music], extraRoutes: ( <> dump page

} /> home

} /> ), }, ); FakeWebSocket.last.welcome(); return user; } const urlDump = () => makeDump({ id: "d1", slug: "a-find", title: "A find", userId: "owner", url: "https://a.test/", comment: "old why", categoryIds: [], richContent: { type: "link", url: "https://a.test/", title: "Page's own title", }, }); test("saves title, note, categories and visibility", async () => { route( "PUT /api/dumps/:id", () => wire(makeDump({ id: "d1", slug: "new-title" })), ); const user = openEdit(urlDump()); const title = await screen.findByLabelText("Title"); expect(document.title).toMatch(/^Editing A find/); await user.clear(title); await user.type(title, "New title"); await user.click(screen.getByRole("button", { name: "Music" })); await user.click(screen.getByRole("button", { name: "Private" })); await user.click(screen.getByRole("button", { name: "Save" })); await screen.findByText("dump page"); expect(currentPath()).toBe("/dumps/new-title"); expect(calls("PUT /api/dumps/:id")[0].body).toEqual({ url: "https://a.test/", title: "New title", comment: "old why", isPrivate: true, categoryIds: ["c1"], }); }); test("clearing the note removes it", async () => { route("PUT /api/dumps/:id", () => wire(makeDump({ id: "d1" }))); const user = openEdit(urlDump()); await user.clear(await screen.findByLabelText("Why?")); await user.click(screen.getByRole("button", { name: "Save" })); await screen.findByText("dump page"); expect(calls("PUT /api/dumps/:id")[0].body).toHaveProperty("comment", null); }); test("the title resets to the page's own", async () => { const user = openEdit(urlDump()); const title = await screen.findByLabelText("Title"); await user.click(screen.getByRole("button", { name: "Reset to default" })); expect(title).toHaveValue("Page's own title"); expect(screen.getByRole("button", { name: "Reset to default" })) .toBeDisabled(); }); test("a refused save stays on the form", async () => { route("PUT /api/dumps/:id", () => fail("Invalid URL", 400)); const user = openEdit(urlDump()); await user.click(await screen.findByRole("button", { name: "Save" })); const alert = await screen.findByRole("alert"); expect(alert).toHaveTextContent("Could not save"); expect(alert).toHaveTextContent("Invalid URL"); }); test("refreshing the metadata", async () => { route( "POST /api/dumps/:id/refresh-metadata", () => wire({ ...urlDump(), richContent: { type: "link", url: "https://a.test/", title: "Fresh title", description: "New blurb", }, }), ); const user = openEdit(urlDump()); await user.click( await screen.findByRole("button", { name: "Refresh metadata" }), ); expect(await screen.findByText("New blurb")).toBeInTheDocument(); }); test("replacing a file sends a visibility change in the same request", async () => { const fileDump = makeDump({ id: "f1", kind: "file", title: "Song", userId: "owner", fileName: "song.mp3", fileMime: "audio/mpeg", fileSize: 10, url: undefined, isPrivate: false, }); route( "PUT /api/dumps/:id/file", () => wire({ ...fileDump, fileName: "new.mp3", isPrivate: true }), ); const user = openEdit(fileDump); await screen.findByText("song.mp3"); const input = document.querySelector( '.fdz input[type="file"]', )!; await user.upload( input, new File([new Uint8Array(3)], "new.mp3", { type: "audio/mpeg" }), ); await user.click(screen.getByRole("button", { name: "Private" })); await user.click(screen.getByRole("button", { name: "Save" })); await screen.findByText("dump page"); const [put] = calls("PUT /api/dumps/:id/file"); const form = put.body as FormData; expect(form.get("file")).toBeInstanceOf(File); expect(form.get("isPrivate")).toBe("true"); expect(calls("PUT /api/dumps/:id")).toHaveLength(0); }); test("a failed file replacement stays on the form, with nothing half-saved", async () => { const fileDump = makeDump({ id: "f1", kind: "file", title: "Song", userId: "owner", fileName: "song.mp3", fileMime: "audio/mpeg", fileSize: 10, url: undefined, isPrivate: false, }); route("PUT /api/dumps/:id/file", () => fail("File too large (max 50 MB)")); const user = openEdit(fileDump); await screen.findByText("song.mp3"); const input = document.querySelector( '.fdz input[type="file"]', )!; await user.upload( input, new File([new Uint8Array(3)], "new.mp3", { type: "audio/mpeg" }), ); await user.click(screen.getByRole("button", { name: "Private" })); await user.click(screen.getByRole("button", { name: "Save" })); expect(await screen.findByText("File too large (max 50 MB)")) .toBeInTheDocument(); expect(screen.queryByText("dump page")).not.toBeInTheDocument(); expect(calls("PUT /api/dumps/:id/file")).toHaveLength(1); expect(calls("PUT /api/dumps/:id")).toHaveLength(0); }); test("deleting asks, then goes home", async () => { route("DELETE /api/dumps/:id", () => new Response(null, { status: 204 })); const user = openEdit(urlDump()); await user.click( await screen.findByRole("button", { name: "Delete dump" }), ); await user.click( within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete dump", }), ); await screen.findByText("home"); expect( JSON.parse( document.querySelector('[data-testid="location"]')!.getAttribute( "data-state", )!, ), ) .toEqual({ deletedDumpId: "d1" }); }); test("a delete that can't reach the server says so", async () => { route("DELETE /api/dumps/:id", () => new TypeError("Failed to fetch")); const user = openEdit(urlDump()); await user.click( await screen.findByRole("button", { name: "Delete dump" }), ); await user.click( within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete dump", }), ); expect(await screen.findByRole("alert")).toHaveTextContent( /Could not connect/, ); }); test("a thumbnail that won't upload says so", async () => { route("PUT /api/dumps/:id/thumbnail", () => fail("Not an image", 400)); const user = openEdit(urlDump()); await screen.findByLabelText("Title"); const picker = document.querySelector( '.dump-edit-thumbnail-row input[type="file"]', )!; await user.upload( picker, new File([new Uint8Array(3)], "t.png", { type: "image/png" }), ); expect(await screen.findByText("Not an image")).toBeInTheDocument(); }); test("guests are sent to log in", () => { renderApp( , { route: "/dumps/x/edit", path: "/dumps/:selectedDump/edit", extraRoutes: login page

} />, }, ); expect(screen.getByText("login page")).toBeInTheDocument(); }); });