yap-blog/tests/integration/comments.test.ts
matt bb3ab4561d Add moderated threaded comments
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>
2026-07-04 21:08:08 -04:00

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 });
});
});