262 lines
9.8 KiB
TypeScript
262 lines
9.8 KiB
TypeScript
import { describe, expect, test, vi } from "vitest";
|
|
import { screen, waitFor, within } from "@testing-library/react";
|
|
import { userEvent } from "@testing-library/user-event";
|
|
import { useState } from "react";
|
|
import { CommentThread } from "../../../components/comment/CommentThread.tsx";
|
|
import type { Session } from "../../../utils/auth/storage.ts";
|
|
import type { Comment } from "../../../../shared/comment.ts";
|
|
import { calls, fail, route } from "../../fetch.ts";
|
|
import { makeComment, makeSession, wire } from "../../fixtures.ts";
|
|
import { renderWithContexts } from "../../render.tsx";
|
|
|
|
const me = makeSession({ id: "me", username: "me" });
|
|
const mod = makeSession({ id: "mod", username: "mod", role: "moderator" });
|
|
|
|
function setup(initial: Comment[], session: Session | null = me, ws = {}) {
|
|
const user = userEvent.setup();
|
|
function Harness() {
|
|
const [comments, setComments] = useState(initial);
|
|
return (
|
|
<CommentThread
|
|
dumpId="d1"
|
|
comments={comments}
|
|
currentUser={session?.user ?? null}
|
|
onCommentCreated={(c) => setComments((prev) => [...prev, c])}
|
|
onCommentDeleted={(id) =>
|
|
setComments((prev) =>
|
|
prev.map((c) => c.id === id ? { ...c, deleted: true } : c)
|
|
)}
|
|
onCommentUpdated={(c) =>
|
|
setComments((prev) => prev.map((x) => x.id === c.id ? c : x))}
|
|
/>
|
|
);
|
|
}
|
|
renderWithContexts(<Harness />, { session, ws });
|
|
return user;
|
|
}
|
|
|
|
const created = (over: Partial<Comment>) => (req: { body: unknown }) =>
|
|
wire(
|
|
makeComment({
|
|
dumpId: "d1",
|
|
userId: "me",
|
|
authorUsername: "me",
|
|
body: (req.body as { body: string }).body,
|
|
...over,
|
|
}),
|
|
);
|
|
|
|
describe("CommentThread", () => {
|
|
test("counts visible comments and nests replies", () => {
|
|
setup([
|
|
makeComment({ id: "a", body: "top" }),
|
|
makeComment({ id: "b", parentId: "a", body: "reply" }),
|
|
makeComment({ id: "c", deleted: true, body: "" }),
|
|
]);
|
|
expect(screen.getByRole("heading", { name: "2 comments" }))
|
|
.toBeInTheDocument();
|
|
const top = screen.getByText("top").closest("li")!;
|
|
expect(within(top).getByText("reply")).toBeInTheDocument();
|
|
expect(screen.getByText("[deleted]")).toBeInTheDocument();
|
|
});
|
|
|
|
test("guests read but can't write, reply or like", () => {
|
|
setup([makeComment({ body: "hello" })], null);
|
|
expect(screen.queryByPlaceholderText("Add a comment…")).not
|
|
.toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Reply" })).not
|
|
.toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Like" })).toHaveAttribute(
|
|
"aria-disabled",
|
|
"true",
|
|
);
|
|
});
|
|
|
|
test("posting a comment", async () => {
|
|
route("POST /api/dumps/:id/comments", created({ id: "new" }));
|
|
const user = setup([]);
|
|
const box = screen.getByPlaceholderText("Add a comment…");
|
|
expect(screen.getByRole("button", { name: "Post comment" })).toBeDisabled();
|
|
await user.type(box, "First!");
|
|
await user.click(screen.getByRole("button", { name: "Post comment" }));
|
|
await screen.findByText("First!", { selector: ".comment-body *" });
|
|
expect(box).toHaveValue("");
|
|
expect(calls("POST /api/dumps/:id/comments")[0].body).toEqual({
|
|
body: "First!",
|
|
});
|
|
expect(screen.getByRole("heading", { name: "1 comment" }))
|
|
.toBeInTheDocument();
|
|
});
|
|
|
|
test("Ctrl+Enter posts too; blank text doesn't", async () => {
|
|
route("POST /api/dumps/:id/comments", created({}));
|
|
const user = setup([]);
|
|
const box = screen.getByPlaceholderText("Add a comment…");
|
|
await user.type(box, " ");
|
|
await user.keyboard("{Control>}{Enter}{/Control}");
|
|
expect(calls("POST /api/dumps/:id/comments")).toHaveLength(0);
|
|
await user.type(box, "ok");
|
|
await user.keyboard("{Control>}{Enter}{/Control}");
|
|
await waitFor(() =>
|
|
expect(calls("POST /api/dumps/:id/comments")).toHaveLength(1)
|
|
);
|
|
});
|
|
|
|
test("a failed post keeps the text and says why", async () => {
|
|
route("POST /api/dumps/:id/comments", () => fail("Comment too long", 400));
|
|
const user = setup([]);
|
|
const box = screen.getByPlaceholderText("Add a comment…");
|
|
await user.type(box, "text");
|
|
await user.click(screen.getByRole("button", { name: "Post comment" }));
|
|
const alert = await screen.findByRole("alert");
|
|
expect(alert).toHaveTextContent("Failed to post comment");
|
|
expect(alert).toHaveTextContent("Comment too long");
|
|
expect(box).toHaveValue("text");
|
|
});
|
|
|
|
test("the top-level Cancel only shows with text, and clears it", async () => {
|
|
const user = setup([]);
|
|
expect(screen.queryByRole("button", { name: "Cancel" })).not
|
|
.toBeInTheDocument();
|
|
await user.type(screen.getByPlaceholderText("Add a comment…"), "draft");
|
|
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
|
expect(screen.getByPlaceholderText("Add a comment…")).toHaveValue("");
|
|
});
|
|
|
|
test("replying", async () => {
|
|
route("POST /api/dumps/:id/comments", created({ id: "r", parentId: "a" }));
|
|
const user = setup([makeComment({ id: "a", body: "parent" })]);
|
|
await user.click(screen.getByRole("button", { name: "Reply" }));
|
|
const box = screen.getByPlaceholderText("Write a reply…");
|
|
await waitFor(() => expect(box).toHaveFocus());
|
|
await user.type(box, "child");
|
|
await user.click(screen.getByRole("button", { name: "Post reply" }));
|
|
await waitFor(() =>
|
|
expect(screen.queryByPlaceholderText("Write a reply…")).not
|
|
.toBeInTheDocument()
|
|
);
|
|
expect(calls("POST /api/dumps/:id/comments")[0].body).toEqual({
|
|
body: "child",
|
|
parentId: "a",
|
|
});
|
|
const parent = screen.getByText("parent").closest("li")!;
|
|
expect(within(parent).getByText("child")).toBeInTheDocument();
|
|
});
|
|
|
|
test("Escape backs out of a reply", async () => {
|
|
const user = setup([makeComment({ id: "a" })]);
|
|
await user.click(screen.getByRole("button", { name: "Reply" }));
|
|
await user.type(
|
|
screen.getByPlaceholderText("Write a reply…"),
|
|
"nah{Escape}",
|
|
);
|
|
expect(screen.queryByPlaceholderText("Write a reply…")).not
|
|
.toBeInTheDocument();
|
|
});
|
|
|
|
test("editing your own comment", async () => {
|
|
route(
|
|
"PATCH /api/comments/:id",
|
|
(req) =>
|
|
wire(
|
|
makeComment({
|
|
id: "a",
|
|
userId: "me",
|
|
authorUsername: "me",
|
|
body: (req.body as { body: string }).body,
|
|
updatedAt: new Date(),
|
|
}),
|
|
),
|
|
);
|
|
const user = setup([
|
|
makeComment({ id: "a", userId: "me", authorUsername: "me", body: "old" }),
|
|
]);
|
|
await user.click(screen.getByRole("button", { name: "Edit" }));
|
|
const box = screen.getByDisplayValue("old");
|
|
await user.clear(box);
|
|
await user.type(box, "new");
|
|
await user.click(screen.getByRole("button", { name: "Save" }));
|
|
await screen.findByText("new");
|
|
expect(screen.getByText(/^edited/)).toBeInTheDocument();
|
|
});
|
|
|
|
test("saving an edit unchanged doesn't stamp it edited", async () => {
|
|
const user = setup([makeComment({ id: "a", userId: "me", body: "same" })]);
|
|
await user.click(screen.getByRole("button", { name: "Edit" }));
|
|
await user.click(screen.getByRole("button", { name: "Save" }));
|
|
await waitFor(() =>
|
|
expect(screen.queryByRole("button", { name: "Save" })).not
|
|
.toBeInTheDocument()
|
|
);
|
|
expect(calls("PATCH /api/comments/:id")).toHaveLength(0);
|
|
});
|
|
|
|
test("only the author or a moderator can edit and delete", () => {
|
|
const comments = [
|
|
makeComment({ id: "mine", userId: "me", body: "mine" }),
|
|
makeComment({ id: "theirs", userId: "them", body: "theirs" }),
|
|
];
|
|
setup(comments);
|
|
const theirs = screen.getByText("theirs").closest("li")!;
|
|
expect(within(theirs).queryByRole("button", { name: "Edit" })).not
|
|
.toBeInTheDocument();
|
|
expect(within(theirs).queryByRole("button", { name: "Delete" })).not
|
|
.toBeInTheDocument();
|
|
const mine = screen.getByText("mine").closest("li")!;
|
|
expect(within(mine).getByRole("button", { name: "Delete" }))
|
|
.toBeInTheDocument();
|
|
});
|
|
|
|
test("a moderator can delete anyone's", () => {
|
|
setup([makeComment({ userId: "them", body: "theirs" })], mod);
|
|
expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument();
|
|
});
|
|
|
|
test("deleting asks first", async () => {
|
|
route(
|
|
"DELETE /api/comments/:id",
|
|
() => new Response(null, { status: 204 }),
|
|
);
|
|
const user = setup([makeComment({ id: "a", userId: "me", body: "bye" })]);
|
|
await user.click(screen.getByRole("button", { name: "Delete" }));
|
|
const dialog = screen.getByRole("alertdialog");
|
|
expect(dialog).toHaveTextContent("Delete this comment?");
|
|
await user.click(within(dialog).getByRole("button", { name: "Delete" }));
|
|
await screen.findByText("[deleted]");
|
|
});
|
|
|
|
test("a failed delete says so", async () => {
|
|
route("DELETE /api/comments/:id", () => fail("Forbidden", 403));
|
|
const user = setup([makeComment({ id: "a", userId: "me", body: "stays" })]);
|
|
await user.click(screen.getByRole("button", { name: "Delete" }));
|
|
await user.click(
|
|
within(screen.getByRole("alertdialog")).getByRole("button", {
|
|
name: "Delete",
|
|
}),
|
|
);
|
|
expect(await screen.findByRole("alert")).toHaveTextContent(
|
|
/couldn't be deleted/i,
|
|
);
|
|
expect(screen.getByText("stays")).toBeInTheDocument();
|
|
});
|
|
|
|
test("likes come from the live counts", async () => {
|
|
const castCommentLike = vi.fn();
|
|
const user = setup([makeComment({ id: "a", likeCount: 1 })], me, {
|
|
likeCounts: { a: 5 },
|
|
castCommentLike,
|
|
});
|
|
expect(screen.getByText("5")).toBeInTheDocument();
|
|
await user.click(screen.getByRole("button", { name: "Like" }));
|
|
expect(castCommentLike).toHaveBeenCalledWith("a", 5);
|
|
});
|
|
|
|
test("the timestamp links to the comment", () => {
|
|
setup([makeComment({ id: "a" })]);
|
|
expect(screen.getByRole("link", { name: /ago/ })).toHaveAttribute(
|
|
"href",
|
|
"/#comment-a",
|
|
);
|
|
});
|
|
});
|