Files
gerbeur/src/test/components/dump/create/DumpCreateModal.test.tsx

438 lines
15 KiB
TypeScript

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<React.ComponentProps<typeof DumpCreateModal>> = {},
) {
const onClose = vi.fn();
const injectDump = vi.fn();
const user = userEvent.setup();
renderWithContexts(<DumpCreateModal onClose={onClose} {...props} />, {
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<string, Array<(e: unknown) => void>> = {};
private u: Record<string, Array<(e: unknown) => 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();
});
});