438 lines
15 KiB
TypeScript
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();
|
|
});
|
|
});
|