import { beforeEach, describe, expect, it } from "vitest"; import { type CommentInput, countCommentsByStatus, createComment, deleteComment, listApprovedComments, listCommentsForAdmin, setCommentStatus, } from "@/lib/services/comments"; import { createPost, type PostInput } from "@/lib/services/posts"; import { resetDb } from "../helpers/db"; const postInput = (overrides: Partial = {}): PostInput => ({ title: `Post ${Math.random().toString(36).slice(2, 8)}`, slug: "", body: "body", authorName: "Tester", featuredImageUrl: null, featuredImageAlt: null, status: "published", tagIds: [], newTagNames: [], ...overrides, }); const commentInput = ( postId: number, overrides: Partial = {}, ): CommentInput => ({ postId, parentId: null, authorName: "Alice", authorEmail: "alice@example.com", emailPublic: false, body: "Nice post!", ...overrides, }); beforeEach(resetDb); describe("comment moderation", () => { it("stores new comments as pending and hides them publicly", async () => { const post = await createPost(postInput()); const result = await createComment(commentInput(post.id)); expect(result.ok).toBe(true); expect(await listApprovedComments(post.id)).toEqual([]); const counts = await countCommentsByStatus(); expect(counts).toEqual({ pending: 1, approved: 0 }); }); it("shows comments publicly only after approval", async () => { const post = await createPost(postInput()); const created = await createComment(commentInput(post.id)); if (!created.ok) throw new Error("expected ok"); await setCommentStatus(created.comment.id, "approved"); const visible = await listApprovedComments(post.id); expect(visible).toHaveLength(1); expect(visible[0].authorName).toBe("Alice"); }); it("rejects comments on drafts and unknown posts", async () => { const draft = await createPost(postInput({ status: "draft" })); expect((await createComment(commentInput(draft.id))).ok).toBe(false); expect((await createComment(commentInput(999_999))).ok).toBe(false); }); it("hides private emails and shows public ones", async () => { const post = await createPost(postInput()); const priv = await createComment(commentInput(post.id)); const pub = await createComment( commentInput(post.id, { authorName: "Bob", authorEmail: "bob@example.com", emailPublic: true }), ); if (!priv.ok || !pub.ok) throw new Error("expected ok"); await setCommentStatus(priv.comment.id, "approved"); await setCommentStatus(pub.comment.id, "approved"); const visible = await listApprovedComments(post.id); expect(visible.find((c) => c.authorName === "Alice")?.authorEmail).toBeNull(); expect(visible.find((c) => c.authorName === "Bob")?.authorEmail).toBe("bob@example.com"); // The admin list always carries the address. const admin = await listCommentsForAdmin(); expect(admin.map((c) => c.authorEmail).sort()).toEqual([ "alice@example.com", "bob@example.com", ]); }); }); describe("threading", () => { it("only allows replies to approved comments on the same post", async () => { const post = await createPost(postInput()); const other = await createPost(postInput()); const parent = await createComment(commentInput(post.id)); if (!parent.ok) throw new Error("expected ok"); // Parent still pending → no replies. const early = await createComment( commentInput(post.id, { parentId: parent.comment.id, body: "too early" }), ); expect(early.ok).toBe(false); await setCommentStatus(parent.comment.id, "approved"); const reply = await createComment( commentInput(post.id, { parentId: parent.comment.id, authorName: "Bob", body: "A reply" }), ); expect(reply.ok).toBe(true); // Approved parent, but wrong post. const crossPost = await createComment( commentInput(other.id, { parentId: parent.comment.id }), ); expect(crossPost.ok).toBe(false); }); it("builds a nested tree and hides replies under pending parents", async () => { const post = await createPost(postInput()); const a = await createComment(commentInput(post.id, { body: "root A" })); if (!a.ok) throw new Error(); await setCommentStatus(a.comment.id, "approved"); const b = await createComment( commentInput(post.id, { parentId: a.comment.id, authorName: "Bob", body: "reply B" }), ); if (!b.ok) throw new Error(); await setCommentStatus(b.comment.id, "approved"); const c = await createComment( commentInput(post.id, { parentId: b.comment.id, authorName: "Cleo", body: "reply C" }), ); if (!c.ok) throw new Error(); // C not yet approved → depth-2 tree shows A > B only. let tree = await listApprovedComments(post.id); expect(tree).toHaveLength(1); expect(tree[0].replies).toHaveLength(1); expect(tree[0].replies[0].replies).toHaveLength(0); // Approve C → full chain visible. await setCommentStatus(c.comment.id, "approved"); tree = await listApprovedComments(post.id); expect(tree[0].replies[0].replies[0].body).toBe("reply C"); // Unapprove B → C disappears with it (thread context preserved). await setCommentStatus(b.comment.id, "pending"); tree = await listApprovedComments(post.id); expect(tree[0].replies).toHaveLength(0); }); it("deleting a comment removes its whole subtree", async () => { const post = await createPost(postInput()); const a = await createComment(commentInput(post.id)); if (!a.ok) throw new Error(); await setCommentStatus(a.comment.id, "approved"); const b = await createComment(commentInput(post.id, { parentId: a.comment.id })); if (!b.ok) throw new Error(); await deleteComment(a.comment.id); const counts = await countCommentsByStatus(); expect(counts).toEqual({ pending: 0, approved: 0 }); }); });