import { afterEach, describe, expect, test, vi } from "vitest"; import { act, fireEvent, screen, waitFor, within, } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { DumpCreateModal } from "../../../../components/dump/create/DumpCreateModal.tsx"; import { calls, fail, route } from "../../../fetch.ts"; import { makeCategory, makeDump, makePlaylist, makeSession, wire, } from "../../../fixtures.ts"; import { currentPath, renderWithContexts } from "../../../render.tsx"; const session = makeSession({ username: "me" }); const categories = [makeCategory({ id: "c1", name: "Music" })]; function setup( props: Partial> = {}, ) { const onClose = vi.fn(); const injectDump = vi.fn(); const user = userEvent.setup(); renderWithContexts(, { session, categories, ws: { injectDump }, }); return { user, onClose, injectDump }; } function previewRoutes( { title = "Page title", reached = true, dupes = [] as unknown[] } = {}, ) { route("GET /api/preview", (req) => ({ reached, richContent: reached ? { type: "link", url: req.url.searchParams.get("url"), title, siteName: "Site", } : { type: "link", url: req.url.searchParams.get("url") }, })); route("GET /api/dumps/by-url", () => dupes); } const nextBtn = () => screen.getByRole("button", { name: "Next →" }); const linkBox = () => screen.getByPlaceholderText("Paste a link…"); describe("DumpCreateModal: a link", () => { test("Next stays disabled until there is a link or a file", async () => { const { user } = setup(); expect(screen.getByRole("dialog", { name: "New dump" })) .toBeInTheDocument(); expect(nextBtn()).toBeDisabled(); await user.type(linkBox(), "not a url at all"); expect(nextBtn()).toBeDisabled(); }); test("previews the page and suggests its title", async () => { previewRoutes(); const { user } = setup(); await user.type(linkBox(), "example.com/a"); expect(await screen.findByText("Fetching preview…")).toBeInTheDocument(); // The suggestion is written in an effect just after the field appears. const title = await screen.findByLabelText("Title"); await waitFor(() => expect(title).toHaveValue("Page title")); expect(title).toBeDisabled(); const [req] = calls("GET /api/preview"); expect(req.url.searchParams.get("url")).toBe("https://example.com/a"); expect(req.headers.get("Authorization")).toMatch(/^Bearer /); }); test("an unreachable page says so, but can still be posted", async () => { previewRoutes({ reached: false }); const { user } = setup(); await user.type(linkBox(), "https://down.test/"); expect(await screen.findByText(/Couldn't load a preview/)) .toBeInTheDocument(); expect(nextBtn()).toBeEnabled(); }); test("warns about an earlier dump of the same link", async () => { previewRoutes({ dupes: [ { id: "old", slug: "old-one", title: "Old one", username: "ann", createdAt: new Date(Date.now() - 3600_000).toISOString(), voteCount: 1, commentCount: 0, }, { id: "older", title: "Older", username: "bob", createdAt: new Date(0).toISOString(), voteCount: 0, commentCount: 0, }, ], }); const { user, onClose } = setup(); await user.type(linkBox(), "https://x.test/"); const notice = await screen.findByText(/Already dumped by ann/); expect(notice.closest("[role=status]")).toHaveTextContent( "and 1 earlier dump", ); await user.click(screen.getByRole("link", { name: "Old one" })); expect(currentPath()).toBe("/dumps/old-one"); expect(onClose).toHaveBeenCalled(); }); test("the whole way through: post, then add to a playlist", async () => { previewRoutes(); const created = makeDump({ id: "new", slug: "page-title", title: "Page title", userId: session.user.id, }); route("POST /api/dumps", () => wire(created)); const mix = makePlaylist({ id: "p1", title: "Mix" }); route( "GET /api/playlists/by-dump/:id/memberships", () => [{ playlist: wire(mix), hasDump: false }], ); route("POST /api/playlists/:pid/dumps/:did", () => ({})); const { user, injectDump, onClose } = setup(); await user.type(linkBox(), "example.com/a"); await screen.findByLabelText("Title"); await user.click(nextBtn()); expect(screen.getByText(/example\.com\/a|Page title/)).toBeInTheDocument(); const why = screen.getByLabelText("Why?"); await waitFor(() => expect(why).toHaveFocus()); await user.type(why, " Because "); await user.click(screen.getByRole("button", { name: "Music" })); await user.click(screen.getByRole("button", { name: "Private" })); await user.click(screen.getByRole("button", { name: "Dump it" })); await screen.findByRole("dialog", { name: "Add to playlist" }); expect(calls("POST /api/dumps")[0].body).toEqual({ url: "https://example.com/a", comment: "Because", isPrivate: true, categoryIds: ["c1"], }); expect(injectDump).toHaveBeenCalledWith( expect.objectContaining({ id: "new" }), ); expect(localStorage.getItem("gerbeur.dumpDraft")).toBeNull(); const row = await screen.findByRole("checkbox", { name: /Mix/ }); expect(row).toHaveAttribute("aria-checked", "false"); row.focus(); await user.keyboard(" "); await waitFor(() => expect(row).toHaveAttribute("aria-checked", "true")); expect(calls("POST /api/playlists/:pid/dumps/:did")[0].path).toBe( "/api/playlists/p1/dumps/new", ); await user.click(screen.getByRole("button", { name: "Done" })); expect(onClose).toHaveBeenCalled(); }); test("a refused playlist toggle leaves the box as it was", async () => { previewRoutes(); route("POST /api/dumps", () => wire(makeDump({ id: "new" }))); route( "GET /api/playlists/by-dump/:id/memberships", () => [{ playlist: wire(makePlaylist({ title: "Mix" })), hasDump: false, }], ); route("POST /api/playlists/:pid/dumps/:did", () => fail("Forbidden", 403)); const { user } = setup(); await user.type(linkBox(), "https://a.test/{Enter}"); await user.click(await screen.findByRole("button", { name: "Dump it" })); const row = await screen.findByRole("checkbox", { name: /Mix/ }); await user.click(row); await act(async () => {}); expect(row).toHaveAttribute("aria-checked", "false"); }); test("a title typed by the poster is sent; a suggested one isn't", async () => { previewRoutes(); route("POST /api/dumps", () => wire(makeDump())); route("GET /api/playlists/by-dump/:id/memberships", () => []); const { user } = setup(); await user.type(linkBox(), "https://a.test/"); await waitFor(() => expect(screen.getByLabelText("Title")).toHaveValue("Page title") ); await user.click(screen.getByRole("button", { name: "Edit title" })); const title = screen.getByLabelText("Title"); await waitFor(() => expect(title).toHaveFocus()); await user.clear(title); await user.type(title, "My title"); // The suggestion can be had back. await user.click( screen.getByRole("button", { name: "Reset to the suggested title" }), ); expect(title).toHaveValue("Page title"); await user.click(screen.getByRole("button", { name: "Edit title" })); await user.clear(title); await user.type(title, "Mine after all"); await user.click(nextBtn()); await user.click(screen.getByRole("button", { name: "Dump it" })); await waitFor(() => expect(calls("POST /api/dumps")).toHaveLength(1)); expect(calls("POST /api/dumps")[0].body).toMatchObject({ title: "Mine after all", }); }); test("Enter in the link field goes to the next step rather than posting", async () => { previewRoutes(); const { user } = setup(); await user.type(linkBox(), "https://a.test/{Enter}"); expect(screen.getByLabelText("Why?")).toBeInTheDocument(); expect(calls("POST /api/dumps")).toHaveLength(0); await user.click(screen.getByRole("button", { name: "← Back" })); expect(linkBox()).toHaveValue("https://a.test/"); }); test("a failed post shows the server's message and stays put", async () => { previewRoutes(); route("POST /api/dumps", () => fail("That URL isn't allowed", 400)); const { user } = setup(); await user.type(linkBox(), "https://a.test/{Enter}"); await user.click(screen.getByRole("button", { name: "Dump it" })); const alert = await screen.findByRole("alert"); expect(alert).toHaveTextContent("Failed to post"); expect(alert).toHaveTextContent("That URL isn't allowed"); expect(screen.getByRole("dialog", { name: "New dump" })) .toBeInTheDocument(); }); test("starts from a shared URL", async () => { previewRoutes(); setup({ initialUrl: "https://shared.test/x" }); expect(linkBox()).toHaveValue("https://shared.test/x"); await screen.findByLabelText("Title"); }); test("a link pasted anywhere in the modal lands in the link field", async () => { previewRoutes(); setup(); act(() => { const e = new Event("paste", { bubbles: true }) as ClipboardEvent; Object.defineProperty(e, "clipboardData", { value: { files: [], getData: () => "pasted.test/page" }, }); screen.getByRole("dialog").dispatchEvent(e); }); expect(linkBox()).toHaveValue("https://pasted.test/page"); }); }); describe("DumpCreateModal: drafts", () => { test("a dismissed draft comes back, and can be dropped", async () => { localStorage.setItem( "gerbeur.dumpDraft", JSON.stringify({ url: "https://draft.test/", title: "", titleFromUser: false, comment: "half-written", isPublic: false, categoryIds: ["c1"], }), ); previewRoutes(); const { user } = setup(); expect(screen.getByText("Picked up where you left off.")) .toBeInTheDocument(); expect(linkBox()).toHaveValue("https://draft.test/"); await user.click(nextBtn()); expect(screen.getByLabelText("Why?")).toHaveValue("half-written"); expect(screen.getByRole("button", { name: "Music" })).toHaveAttribute( "aria-pressed", "true", ); await user.click(screen.getByRole("button", { name: "← Back" })); await user.click(screen.getByRole("button", { name: "Start fresh" })); expect(linkBox()).toHaveValue(""); expect(localStorage.getItem("gerbeur.dumpDraft")).toBeNull(); }); test("what's typed is kept as it's typed", async () => { previewRoutes(); const { user } = setup(); await user.type(linkBox(), "https://keep.test/"); expect(JSON.parse(localStorage.getItem("gerbeur.dumpDraft")!).url).toBe( "https://keep.test/", ); }); test("a shared URL starts clean rather than merging an old draft", () => { localStorage.setItem( "gerbeur.dumpDraft", JSON.stringify({ url: "https://old.test/", comment: "old why" }), ); previewRoutes(); setup({ initialUrl: "https://new.test/" }); expect(screen.queryByText("Picked up where you left off.")).not .toBeInTheDocument(); expect(linkBox()).toHaveValue("https://new.test/"); }); }); describe("DumpCreateModal: a file", () => { class FakeXHR { static last: FakeXHR; status = 0; statusText = ""; responseText = ""; body: FormData | null = null; private l: Record void>> = {}; private u: Record void>> = {}; upload = { addEventListener: (t: string, f: (e: unknown) => void) => (this.u[t] ??= []).push(f), }; constructor() { FakeXHR.last = this; } open() {} setRequestHeader() {} addEventListener(t: string, f: (e: unknown) => void) { (this.l[t] ??= []).push(f); } send(body: FormData) { this.body = body; } progress(f: number) { this.u.progress?.forEach((fn) => fn({ loaded: f * 100, total: 100, lengthComputable: true }) ); } respond(status: number, data: unknown) { this.status = status; this.responseText = JSON.stringify(data); this.l.load?.forEach((fn) => fn({})); } } afterEach(() => vi.unstubAllGlobals()); const song = () => new File([new Uint8Array(10)], "my_song-final.mp3", { type: "audio/mpeg" }); test("a dropped file names the dump and uploads with progress", async () => { vi.stubGlobal("XMLHttpRequest", FakeXHR); vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x"); vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); route("GET /api/playlists/by-dump/:id/memberships", () => []); const { user } = setup(); fireEvent.drop(screen.getByRole("dialog").querySelector(".dump-create")!, { dataTransfer: { files: [song()] }, }); expect(screen.queryByPlaceholderText("Paste a link…")).not .toBeInTheDocument(); expect(screen.getByText("my_song-final.mp3")).toBeInTheDocument(); expect(screen.getByLabelText("Title")).toHaveValue("my song-final"); await user.click(nextBtn()); await user.click(screen.getByRole("button", { name: "Dump it" })); act(() => FakeXHR.last.progress(0.42)); expect(screen.getByRole("progressbar")).toHaveAttribute( "aria-valuenow", "42", ); expect(screen.getByRole("button", { name: "Uploading 42%" })) .toBeDisabled(); const fd = FakeXHR.last.body!; expect(fd.get("title")).toBe("my song-final"); expect(fd.get("isPrivate")).toBe("false"); expect(fd.get("categoryIds")).toBe("[]"); await act(async () => FakeXHR.last.respond(201, { success: true, data: wire(makeDump({ id: "f" })), }) ); await screen.findByRole("dialog", { name: "Add to playlist" }); }); test("closing with a file attached asks first", async () => { vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x"); const { user, onClose } = setup(); fireEvent.drop(screen.getByRole("dialog").querySelector(".dump-create")!, { dataTransfer: { files: [song()] }, }); await user.keyboard("{Escape}"); expect(onClose).not.toHaveBeenCalled(); const confirm = screen.getByText(/The attached file won't be kept/).closest( "[role=alert]", )!; await user.click( within(confirm as HTMLElement).getByRole("button", { name: "Keep editing", }), ); expect(screen.queryByText(/The attached file won't be kept/)).not .toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Cancel" })); await user.click(screen.getByRole("button", { name: "Close anyway" })); expect(onClose).toHaveBeenCalled(); }); test("without a file, closing just closes", async () => { const { user, onClose } = setup(); await user.keyboard("{Escape}"); expect(onClose).toHaveBeenCalled(); }); test("removing the file offers the link field again", async () => { vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x"); const { user } = setup(); fireEvent.drop(screen.getByRole("dialog").querySelector(".dump-create")!, { dataTransfer: { files: [song()] }, }); await user.click(screen.getByRole("button", { name: "Remove file" })); expect(linkBox()).toBeInTheDocument(); }); });