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 ( 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(, { session, ws }); return user; } const created = (over: Partial) => (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", ); }); });