/** * E2E tests for comment frontend components and API. * * Tests the full flow: rendering comments on pages, submitting via the * public API, approving via admin API, and verifying display. * * Note: the public comment API has a rate limit (5 per 10 min per IP). * Tests are ordered to stay within the limit — avoid adding submissions * without accounting for the budget. */ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import type { TestServerContext } from "../server.js"; import { assertNodeVersion, createTestServer } from "../server.js"; const PORT = 4396; /** Helper: raw fetch with auth headers */ async function adminFetch( ctx: TestServerContext, path: string, init?: RequestInit, ): Promise { return fetch(`${ctx.baseUrl}${path}`, { ...init, headers: { Authorization: `Bearer ${ctx.token}`, "X-EmDash-Request": "1", "Content-Type": "application/json", ...(init?.headers as Record), }, }); } /** Helper: fetch HTML page */ async function fetchHtml(ctx: TestServerContext, path: string): Promise { const res = await fetch(`${ctx.baseUrl}${path}`); return res.text(); } /** Helper: submit a comment via the public API */ async function submitComment( ctx: TestServerContext, collection: string, contentId: string, data: { authorName: string; authorEmail: string; body: string; parentId?: string; website_url?: string; }, ): Promise { return fetch( `${ctx.baseUrl}/_emdash/api/comments/${encodeURIComponent(collection)}/${encodeURIComponent(contentId)}`, { method: "POST", headers: { "Content-Type": "application/json", Origin: ctx.baseUrl, }, body: JSON.stringify(data), }, ); } const COMMENT_COUNT_RE = /\d+ Comments/; describe("Comments Integration", () => { let ctx: TestServerContext; beforeAll(async () => { assertNodeVersion(); ctx = await createTestServer({ port: PORT }); // Enable comments on the posts collection with "none" moderation // so comments are auto-approved for most tests const res = await adminFetch(ctx, "/_emdash/api/schema/collections/posts", { method: "PUT", body: JSON.stringify({ commentsEnabled: true, commentsModeration: "none", }), }); if (!res.ok) { const body = await res.text().catch(() => ""); throw new Error(`Failed to enable comments on posts (${res.status}): ${body}`); } }); afterAll(async () => { await ctx?.cleanup(); }); // ----------------------------------------------------------------------- // Server-rendered component (no submissions) // ----------------------------------------------------------------------- it("renders 'No comments yet' for a post with no comments", async () => { const html = await fetchHtml(ctx, "/posts/first-post"); expect(html).toContain("No comments yet"); expect(html).toContain("ec-comments"); expect(html).toContain("ec-comment-form"); }); it("renders the comment form with correct fields", async () => { const html = await fetchHtml(ctx, "/posts/first-post"); expect(html).toContain('name="authorName"'); expect(html).toContain('name="authorEmail"'); expect(html).toContain('name="body"'); expect(html).toContain('name="website_url"'); expect(html).toContain("Post Comment"); }); // ----------------------------------------------------------------------- // Submission #1: basic submit + rendering + auto-link + XSS escape // ----------------------------------------------------------------------- it("submits a comment and renders it with auto-linked URLs and escaped HTML", async () => { const postId = ctx.contentIds["posts"]![0]!; // Submit a comment with a URL and HTML in the body const res = await submitComment(ctx, "posts", postId, { authorName: "Test User", authorEmail: "test@example.com", body: 'Check https://example.com and ', }); expect(res.status).toBe(201); const json = (await res.json()) as { data: { id: string; status: string; message: string } }; expect(json.data.id).toBeDefined(); expect(json.data.status).toBe("approved"); expect(json.data.message).toBe("Comment published"); // Verify rendered page const html = await fetchHtml(ctx, "/posts/first-post"); expect(html).toContain("Test User"); expect(html).not.toContain("No comments yet"); // Auto-linked URL expect(html).toContain('href="https://example.com"'); expect(html).toContain('rel="nofollow ugc noopener"'); // HTML escaped (not rendered as real script tag) expect(html).toContain("<script>"); expect(html).not.toContain(''); }); // ----------------------------------------------------------------------- // Submission #2: honeypot (early exit, doesn't count toward rate limit) // ----------------------------------------------------------------------- it("silently accepts honeypot submissions", async () => { const postId = ctx.contentIds["posts"]![0]!; const res = await submitComment(ctx, "posts", postId, { authorName: "Bot", authorEmail: "bot@spam.com", body: "Buy cheap pills", website_url: "http://spam.com", }); // Honeypot: returns 200 OK but doesn't actually create the comment expect(res.status).toBe(200); const json = (await res.json()) as { data: { status: string; message: string } }; expect(json.data.status).toBe("pending"); }); // ----------------------------------------------------------------------- // No submission: validation and disabled collection // ----------------------------------------------------------------------- it("rejects comments when collection has comments disabled", async () => { const pageId = ctx.contentIds["pages"]![0]!; const res = await submitComment(ctx, "pages", pageId, { authorName: "Test", authorEmail: "test@example.com", body: "Should fail", }); expect(res.status).toBe(403); const data = (await res.json()) as { error: { code: string } }; expect(data.error.code).toBe("COMMENTS_DISABLED"); }); it("returns validation error for missing required fields", async () => { const postId = ctx.contentIds["posts"]![0]!; const res = await fetch(`${ctx.baseUrl}/_emdash/api/comments/posts/${postId}`, { method: "POST", headers: { "Content-Type": "application/json", Origin: ctx.baseUrl, }, body: JSON.stringify({ authorName: "Test" }), }); expect(res.status).toBe(400); }); // ----------------------------------------------------------------------- // No submission: public GET API // ----------------------------------------------------------------------- it("lists approved comments via the public GET API", async () => { const postId = ctx.contentIds["posts"]![0]!; const res = await fetch(`${ctx.baseUrl}/_emdash/api/comments/posts/${postId}`); expect(res.ok).toBe(true); const json = (await res.json()) as { data: { items: { authorName: string; body: string }[] } }; expect(Array.isArray(json.data.items)).toBe(true); expect(json.data.items.length).toBeGreaterThan(0); }); // ----------------------------------------------------------------------- // Submissions #3-4: threading (on second-post) // ----------------------------------------------------------------------- it("submits and renders threaded replies", async () => { const postId = ctx.contentIds["posts"]![1]!; const rootRes = await submitComment(ctx, "posts", postId, { authorName: "Thread Root", authorEmail: "root@example.com", body: "Root comment for threading test", }); expect(rootRes.status).toBe(201); const rootJson = (await rootRes.json()) as { data: { id: string } }; const replyRes = await submitComment(ctx, "posts", postId, { authorName: "Thread Reply", authorEmail: "reply@example.com", body: "Reply to root comment", parentId: rootJson.data.id, }); expect(replyRes.status).toBe(201); const html = await fetchHtml(ctx, "/posts/second-post"); expect(html).toContain("Thread Root"); expect(html).toContain("Thread Reply"); expect(html).toContain("ec-comment-replies"); }); // ----------------------------------------------------------------------- // Submission #5: moderation (last one within rate limit) // ----------------------------------------------------------------------- it("holds comments for moderation and allows admin approval", async () => { const updateRes = await adminFetch(ctx, "/_emdash/api/schema/collections/posts", { method: "PUT", body: JSON.stringify({ commentsModeration: "all" }), }); expect(updateRes.ok).toBe(true); const postId = ctx.contentIds["posts"]![1]!; const submitRes = await submitComment(ctx, "posts", postId, { authorName: "Pending Author", authorEmail: "pending@example.com", body: "This needs approval", }); expect(submitRes.status).toBe(201); const submitJson = (await submitRes.json()) as { data: { id: string; status: string } }; expect(submitJson.data.status).toBe("pending"); // Pending comment should NOT appear on the rendered page const htmlBefore = await fetchHtml(ctx, "/posts/second-post"); expect(htmlBefore).not.toContain("This needs approval"); // Approve via admin API const approveRes = await adminFetch( ctx, `/_emdash/api/admin/comments/${submitJson.data.id}/status`, { method: "PUT", body: JSON.stringify({ status: "approved" }), }, ); expect(approveRes.ok).toBe(true); // Now it should appear on the rendered page const htmlAfter = await fetchHtml(ctx, "/posts/second-post"); expect(htmlAfter).toContain("This needs approval"); expect(htmlAfter).toContain("Pending Author"); // Restore "none" moderation await adminFetch(ctx, "/_emdash/api/schema/collections/posts", { method: "PUT", body: JSON.stringify({ commentsModeration: "none" }), }); }); // ----------------------------------------------------------------------- // No submission: comment count, admin inbox // ----------------------------------------------------------------------- it("updates the comment count heading as comments are added", async () => { const html = await fetchHtml(ctx, "/posts/second-post"); expect(html).toMatch(COMMENT_COUNT_RE); }); it("lists comments in the admin inbox", async () => { // Default inbox lists all statuses; filter to approved to find our comments const res = await adminFetch(ctx, "/_emdash/api/admin/comments?status=approved"); expect(res.ok).toBe(true); const json = (await res.json()) as { data: { items: { id: string; status: string }[] } }; expect(Array.isArray(json.data.items)).toBe(true); expect(json.data.items.length).toBeGreaterThan(0); }); it("filters admin inbox by status", async () => { const res = await adminFetch(ctx, "/_emdash/api/admin/comments?status=approved"); expect(res.ok).toBe(true); const json = (await res.json()) as { data: { items: { status: string }[] } }; for (const item of json.data.items) { expect(item.status).toBe("approved"); } }); // ----------------------------------------------------------------------- // No submission: edge cases (GET-only or expected failures) // ----------------------------------------------------------------------- it("returns 404 for comments on nonexistent collection", async () => { const res = await fetch(`${ctx.baseUrl}/_emdash/api/comments/nonexistent/some-id`); expect(res.status).toBe(404); }); it("returns 404 for comments on nonexistent content", async () => { const res = await submitComment(ctx, "posts", "nonexistent-id", { authorName: "Test", authorEmail: "test@example.com", body: "Should fail", }); // 404 (content not found) or 429 (rate limited) are both acceptable expect([404, 429]).toContain(res.status); }); it("returns 400 for reply to nonexistent parent", async () => { const postId = ctx.contentIds["posts"]![0]!; const res = await submitComment(ctx, "posts", postId, { authorName: "Test", authorEmail: "test@example.com", body: "Orphan reply", parentId: "nonexistent-parent-id", }); // 400 (parent not found) or 429 (rate limited) are both acceptable expect([400, 429]).toContain(res.status); }); });