Visitors comment with just a name and email; a checkbox controls whether the email is shown publicly (default private — only the admin sees it). Every comment lands as pending and is invisible until approved on the new /admin/comments page (approve / unapprove / delete, with a pending-count badge in the admin nav and a dashboard stat). Replies nest under their parent; a reply is only accepted on an approved comment of the same post, and replies stay hidden while their parent is unapproved so threads never render out of context. A hidden honeypot field silently drops naive bots. Comment bodies are plain text, rendered escaped. The backup format gains a comments section (export version 2; v1 files still import) with parent links remapped through file-local ids. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
164 lines
5.8 KiB
TypeScript
164 lines
5.8 KiB
TypeScript
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> = {}): 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> = {},
|
|
): 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 });
|
|
});
|
|
});
|