From 5a33546af2f69ed0af66e5733597ed5afff8457d Mon Sep 17 00:00:00 2001 From: Cesar Alcazar Date: Mon, 19 Oct 2026 14:29:07 -0600 Subject: [PATCH] bo5tym52p: List and create comments on post cards --- src/app/api/posts/[id]/comments/route.ts | 34 ++++++ src/app/globals.css | 14 +++ src/components/CommentsPanel.tsx | 61 ++++++++++ src/components/CommentsSection.tsx | 66 +++++++++++ src/components/Feed.tsx | 1 + src/components/FeedView.tsx | 2 + src/components/PostCard.tsx | 19 ++- src/lib/comment-client.ts | 70 +++++++++++ src/lib/comment-model.ts | 71 +++++++++++ src/lib/comments.ts | 116 ++++++++++++++++++ tests/comments.test.ts | 144 +++++++++++++++++++++++ 11 files changed, 596 insertions(+), 2 deletions(-) create mode 100644 src/app/api/posts/[id]/comments/route.ts create mode 100644 src/components/CommentsPanel.tsx create mode 100644 src/components/CommentsSection.tsx create mode 100644 src/lib/comment-client.ts create mode 100644 src/lib/comment-model.ts create mode 100644 src/lib/comments.ts create mode 100644 tests/comments.test.ts diff --git a/src/app/api/posts/[id]/comments/route.ts b/src/app/api/posts/[id]/comments/route.ts new file mode 100644 index 0000000..0fbcebd --- /dev/null +++ b/src/app/api/posts/[id]/comments/route.ts @@ -0,0 +1,34 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { createComment, listComments } from "@/lib/comments"; +import { SESSION_COOKIE } from "@/lib/cookie"; + +// GET /api/posts/:id/comments -> feed GET /api/v1/posts/{id}/comments?limit=20&page=1 +// POST /api/posts/:id/comments -> feed POST /api/v1/posts/{id}/comments +// body { content } -> feed { content, type: "text", status: "published" } +// 401 { error: "sign_in" } without a session. + +export const runtime = "nodejs"; + +type Ctx = { params: Promise<{ id: string }> }; + +export async function GET(req: NextRequest, ctx: Ctx) { + const sessionId = req.cookies.get(SESSION_COOKIE)?.value; + const { id } = await ctx.params; + const r = await listComments(sessionId, id); + if (!r.ok) return NextResponse.json({ error: r.error }, { status: r.status }); + return NextResponse.json({ comments: r.comments }); +} + +export async function POST(req: NextRequest, ctx: Ctx) { + const sessionId = req.cookies.get(SESSION_COOKIE)?.value; + const { id } = await ctx.params; + let content: unknown = null; + try { + content = ((await req.json()) as { content?: unknown })?.content; + } catch { + content = null; + } + const r = await createComment(sessionId, id, content); + if (!r.ok) return NextResponse.json({ error: r.error }, { status: r.status }); + return NextResponse.json({ comment: r.comment }); +} diff --git a/src/app/globals.css b/src/app/globals.css index 2c90508..08582dd 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -47,3 +47,17 @@ button:disabled { opacity: .5; cursor: not-allowed; } .composer textarea { font: inherit; width: 100%; padding: 12px; border: 1px solid #999; border-radius: 0; resize: vertical; background: #fff; color: var(--ink); } .composer textarea:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; } .composer .composer-post { align-self: flex-end; margin: 0; padding: 10px 24px; border-radius: 0; } + +.comments { display: flex; flex-direction: column; gap: 10px; padding-top: 10px; border-top: 1px solid #eee; } +.comment-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; } +.comment p { margin: 2px 0 0; font-size: 14px; white-space: pre-wrap; overflow-wrap: anywhere; } +.comment-head { display: flex; gap: 8px; align-items: baseline; font-size: 13px; } +.comment-head time { color: var(--grey); font-size: 12px; } +.comment.removed .comment-removed { color: var(--grey); font-style: italic; } +.comments-empty { margin: 0; color: var(--grey); font-size: 14px; } +.comment-error { margin: 0; color: #a8141b; font-size: 13px; } +.comment-form { display: flex; gap: 8px; } +.comment-form input { flex: 1; margin: 0; } +.comment-form button { margin: 0; } +.link-btn { background: transparent; color: inherit; text-decoration: underline; padding: 0; margin: 0; font-size: inherit; width: auto; } +.comments-toggle { background: transparent; color: var(--grey); padding: 4px 8px; margin: 0; font-size: 14px; font-weight: 400; width: auto; } diff --git a/src/components/CommentsPanel.tsx b/src/components/CommentsPanel.tsx new file mode 100644 index 0000000..ab23296 --- /dev/null +++ b/src/components/CommentsPanel.tsx @@ -0,0 +1,61 @@ +import type { CommentItem } from "@/lib/comment-model"; +import { relativeTime } from "@/lib/feed-model"; + +export interface CommentsPanelProps { + comments: CommentItem[]; + loading?: boolean; + /** Error text naming the action that failed. The list stays shown. */ + error?: string | null; + value: string; + busy?: boolean; + onChange?: (v: string) => void; + onSubmit?: () => void; + onRetry?: () => void; + now?: number; +} + +export function CommentsPanel(p: CommentsPanelProps) { + const empty = !p.value.trim(); + return ( +
+ {p.loading ? : null} + {!p.loading && p.comments.length === 0 && !p.error ? ( +

No comments yet.

+ ) : null} +
    + {p.comments.map((c) => ( +
  • +
    + {c.authorName} + +
    + {c.removed ?

    This comment was removed.

    :

    {c.content}

    } +
  • + ))} +
+ {p.error ? ( +

+ {p.error}{" "} + {p.onRetry ? : null} +

+ ) : null} +
{ + e.preventDefault(); + if (!empty && !p.busy) p.onSubmit?.(); + }} + > + p.onChange?.(e.target.value)} + /> + +
+
+ ); +} diff --git a/src/components/CommentsSection.tsx b/src/components/CommentsSection.tsx new file mode 100644 index 0000000..0d13c03 --- /dev/null +++ b/src/components/CommentsSection.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { CommentError, commentErrorText, loadComments, postComment } from "@/lib/comment-client"; +import type { CommentItem } from "@/lib/comment-model"; +import { CommentsPanel } from "./CommentsPanel"; + +/** Opened comments for one post: loads on mount, posts a text comment into the list. */ +export function CommentsSection({ postId, onSignedOut }: { postId: string; onSignedOut?: () => void }) { + const [comments, setComments] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [value, setValue] = useState(""); + const [busy, setBusy] = useState(false); + + const fail = useCallback( + (e: unknown, action: "load" | "post") => { + if (e instanceof CommentError && e.signedOut) onSignedOut?.(); + setError(commentErrorText(action)); + }, + [onSignedOut], + ); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + setComments(await loadComments(postId)); + } catch (e) { + fail(e, "load"); + } finally { + setLoading(false); + } + }, [postId, fail]); + + useEffect(() => { + void load(); + }, [load]); + + const submit = useCallback(async () => { + if (!value.trim() || busy) return; + setBusy(true); + setError(null); + try { + setComments(await postComment(postId, value, comments)); + setValue(""); + } catch (e) { + fail(e, "post"); // the list and the typed text stay in place + } finally { + setBusy(false); + } + }, [postId, value, busy, comments, fail]); + + return ( + void submit()} + onRetry={error === commentErrorText("load") ? () => void load() : undefined} + /> + ); +} diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index 8ed3f5d..0660b5c 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -199,6 +199,7 @@ export function Feed({ onSignedOut }: { onSignedOut: () => void }) { onToggleLike={(p) => void toggleLike(p)} likeBusy={likeBusy} likeErrors={likeErrors} + onSignedOut={onSignedOut} composer={{ value: draft, busy: posting, error: postFailed, onChange: changeDraft, onSubmit: () => void publish() }} sentinelRef={sentinel} /> diff --git a/src/components/FeedView.tsx b/src/components/FeedView.tsx index f5f122b..0046891 100644 --- a/src/components/FeedView.tsx +++ b/src/components/FeedView.tsx @@ -16,6 +16,7 @@ export interface FeedViewProps { onToggleLike?: (post: CardPost) => void; likeBusy?: ReadonlySet; likeErrors?: ReadonlySet; + onSignedOut?: () => void; composer?: ComposerProps; sentinelRef?: Ref; now?: number; @@ -63,6 +64,7 @@ export function FeedView(p: FeedViewProps) { onToggleLike={p.onToggleLike} likeBusy={p.likeBusy?.has(post.key)} likeError={p.likeErrors?.has(post.key)} + onSignedOut={p.onSignedOut} />) )} {p.loadMoreFailed ? ( diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index 98c7fa3..5c9d130 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -1,3 +1,7 @@ +"use client"; + +import { useState } from "react"; +import { CommentsSection } from "@/components/CommentsSection"; import { VideoPlayer } from "@/components/VideoPlayer"; import { initials, relativeTime, type CardPost } from "@/lib/feed-model"; @@ -7,6 +11,7 @@ export interface PostCardProps { onToggleLike?: (post: CardPost) => void; likeBusy?: boolean; likeError?: boolean; + onSignedOut?: () => void; } function Heart({ filled }: { filled: boolean }) { @@ -19,7 +24,8 @@ function Heart({ filled }: { filled: boolean }) { ); } -export function PostCard({ post, now, onToggleLike, likeBusy, likeError }: PostCardProps) { +export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSignedOut }: PostCardProps) { + const [open, setOpen] = useState(false); return (
@@ -60,11 +66,20 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError }: PostC > {post.likesCount} - {post.commentsCount} comments + {likeError ? ( Could not update like. ) : null} + {open && post.id ? : null}
); } diff --git a/src/lib/comment-client.ts b/src/lib/comment-client.ts new file mode 100644 index 0000000..616556a --- /dev/null +++ b/src/lib/comment-client.ts @@ -0,0 +1,70 @@ +// Browser-side comment calls. Each throws CommentError naming the action. + +import { appendComment, type CommentItem } from "./comment-model"; + +export type CommentAction = "load" | "post"; + +export class CommentError extends Error { + constructor(public readonly action: CommentAction, public readonly signedOut: boolean) { + super(signedOut ? "sign_in" : `comment_${action}_failed`); + } +} + +export function commentErrorText(action: CommentAction): string { + return action === "load" ? "Could not load comments." : "Could not post comment."; +} + +async function parse(res: Response, action: CommentAction): Promise> { + if (res.status === 401) throw new CommentError(action, true); + if (!res.ok) throw new CommentError(action, false); + try { + return (await res.json()) as Record; + } catch { + throw new CommentError(action, false); + } +} + +export async function loadComments(postId: string, fetchImpl: typeof fetch = fetch): Promise { + let res: Response; + try { + res = await fetchImpl(`/api/posts/${encodeURIComponent(postId)}/comments`, { credentials: "same-origin" }); + } catch { + throw new CommentError("load", false); + } + const b = await parse(res, "load"); + return Array.isArray(b.comments) ? (b.comments as CommentItem[]) : []; +} + +/** Post a comment and return the list as it should now read. Throws and returns nothing on failure. */ +export async function postComment( + postId: string, + text: string, + current: CommentItem[], + fetchImpl: typeof fetch = fetch, +): Promise { + const content = text.trim(); + if (!content) return current; + let res: Response; + try { + res = await fetchImpl(`/api/posts/${encodeURIComponent(postId)}/comments`, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }); + } catch { + throw new CommentError("post", false); + } + const b = await parse(res, "post"); + const made = b.comment as CommentItem | null | undefined; + if (made && typeof made === "object") return appendComment(current, made); + // The feed did not return the new comment; read the list back. + try { + return await loadComments(postId, fetchImpl); + } catch { + return appendComment(current, { + key: `local#${current.length}`, id: null, authorName: "You", + createdAt: new Date().toISOString(), removed: false, content, + }); + } +} diff --git a/src/lib/comment-model.ts b/src/lib/comment-model.ts new file mode 100644 index 0000000..c83202b --- /dev/null +++ b/src/lib/comment-model.ts @@ -0,0 +1,71 @@ +// Pure comment model: normalise raw feed comments into what the panel shows. +// A comment with deletedAt set is "removed" and never carries its old text. + +export interface CommentItem { + key: string; + id: string | null; + authorName: string; + createdAt: string | null; + removed: boolean; + /** Empty when removed. */ + content: string; +} + +type Obj = Record; + +function isObj(v: unknown): v is Obj { + return typeof v === "object" && v !== null && !Array.isArray(v); +} +function str(v: unknown): string | null { + return typeof v === "string" && v.trim() ? v : null; +} + +function nameOf(author: Obj): string { + const display = str(author.displayName); + if (display) return display.trim(); + const full = [str(author.firstName), str(author.lastName)].filter(Boolean).join(" ").trim(); + return full || "Unknown"; +} + +export function toComment(raw: unknown, index = 0): CommentItem | null { + if (!isObj(raw)) return null; + const id = typeof raw.id === "string" || typeof raw.id === "number" ? String(raw.id) : null; + const createdAt = str(raw.createdAt); + const removed = raw.deletedAt !== null && raw.deletedAt !== undefined && raw.deletedAt !== false && raw.deletedAt !== ""; + const author = isObj(raw.author) ? raw.author : isObj(raw.user) ? raw.user : {}; + return { + key: id ?? `${createdAt ?? "unknown"}#${index}`, + id, + authorName: nameOf(author), + createdAt, + removed, + content: removed ? "" : typeof raw.content === "string" ? raw.content : "", + }; +} + +/** Find the comment array in a feed response body. */ +export function commentsFromBody(body: unknown): unknown[] { + const root = isObj(body) ? body : {}; + const data = root.data; + const candidates: unknown[] = [ + data, isObj(data) ? data.comments : null, isObj(data) ? data.items : null, + root.comments, root.items, + ]; + for (const c of candidates) if (Array.isArray(c)) return c; + return []; +} + +export function toComments(raws: unknown[]): CommentItem[] { + const out: CommentItem[] = []; + raws.forEach((r, i) => { + const c = toComment(r, i); + if (c) out.push(c); + }); + return out; +} + +/** Add a comment to the end of the list unless its id is already there. */ +export function appendComment(list: CommentItem[], c: CommentItem): CommentItem[] { + if (c.id && list.some((x) => x.id === c.id)) return list; + return [...list, c]; +} diff --git a/src/lib/comments.ts b/src/lib/comments.ts new file mode 100644 index 0000000..93a2d91 --- /dev/null +++ b/src/lib/comments.ts @@ -0,0 +1,116 @@ +import { FEED_BASE_URL } from "./env"; +import { commentsFromBody, toComment, toComments, type CommentItem } from "./comment-model"; +import { ensureFreshSession, refreshSession } from "./session-refresh"; + +// Comment list / create proxy. One feed call per request with the person's +// server-held feed token, plus one refresh-and-retry after a 401 rejection. +// Neither the token nor comment text is logged. + +export const MAX_COMMENT_LENGTH = 5_000; +const POST_ID = /^[A-Za-z0-9_-]{1,128}$/; + +type Err = { ok: false; status: 400 | 401 | 502; error: "sign_in" | "bad_post" | E }; +export type ListCommentsResult = { ok: true; comments: CommentItem[] } | Err<"comments_unavailable">; +export type CreateCommentResult = + | { ok: true; comment: CommentItem | null } + | Err<"empty_comment" | "comment_failed">; + +interface Options { + fetchImpl?: typeof fetch; + now?: number; +} + +function rec(v: unknown): Record { + return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : {}; +} + +function url(postId: string, query = "") { + return `${FEED_BASE_URL}/api/v1/posts/${encodeURIComponent(postId)}/comments${query}`; +} + +async function callWithRefresh( + sessionId: string, + first: Awaited>, + make: (token: string) => Promise, + options: Options, +): Promise { + let session = first; + if (!session) return "sign_in"; + let res = await make(session.feedAccessToken); + if (res.status === 401) { + session = await refreshSession(sessionId, options); + if (!session) return "sign_in"; + res = await make(session.feedAccessToken); + if (res.status === 401) return "sign_in"; + } + return res; +} + +export async function listComments( + sessionId: string | undefined, + postId: string, + options: Options = {}, +): Promise { + const signIn = { ok: false, status: 401, error: "sign_in" } as const; + const failed = { ok: false, status: 502, error: "comments_unavailable" } as const; + const doFetch = options.fetchImpl ?? fetch; + const session = await ensureFreshSession(sessionId, options); + if (!session || !sessionId) return signIn; + if (!POST_ID.test(postId)) return { ok: false, status: 400, error: "bad_post" }; + try { + const res = await callWithRefresh( + sessionId, session, + (t) => doFetch(url(postId, "?limit=20&page=1"), { + method: "GET", headers: { Accept: "application/json", Authorization: `Bearer ${t}` }, + }), + options, + ); + if (res === "sign_in") return signIn; + if (res.status !== 200) return failed; + return { ok: true, comments: toComments(commentsFromBody(await res.json())) }; + } catch { + return failed; + } +} + +export async function createComment( + sessionId: string | undefined, + postId: string, + content: unknown, + options: Options = {}, +): Promise { + const signIn = { ok: false, status: 401, error: "sign_in" } as const; + const failed = { ok: false, status: 502, error: "comment_failed" } as const; + const doFetch = options.fetchImpl ?? fetch; + const session = await ensureFreshSession(sessionId, options); + if (!session || !sessionId) return signIn; + if (!POST_ID.test(postId)) return { ok: false, status: 400, error: "bad_post" }; + if (typeof content !== "string" || !content.trim() || content.length > MAX_COMMENT_LENGTH) { + return { ok: false, status: 400, error: "empty_comment" }; + } + try { + const res = await callWithRefresh( + sessionId, session, + (t) => doFetch(url(postId), { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${t}` }, + body: JSON.stringify({ content, type: "text", status: "published" }), + }), + options, + ); + if (res === "sign_in") return signIn; + if (res.status !== 200 && res.status !== 201) return failed; + let comment: CommentItem | null = null; + try { + const body = rec(await res.json()); + const data = rec(body.data); + const raw = Object.keys(rec(data.comment)).length ? data.comment : data.id !== undefined ? data : null; + comment = raw ? toComment(raw) : null; + } catch { + comment = null; + } + return { ok: true, comment }; + } catch { + return failed; + } +} diff --git a/tests/comments.test.ts b/tests/comments.test.ts new file mode 100644 index 0000000..5e5276d --- /dev/null +++ b/tests/comments.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from "vitest"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { NextRequest } from "next/server"; +import { GET, POST } from "@/app/api/posts/[id]/comments/route"; +import { CommentsPanel } from "@/components/CommentsPanel"; +import { CommentError, commentErrorText, loadComments, postComment } from "@/lib/comment-client"; +import { toComments } from "@/lib/comment-model"; +import { createSession } from "@/lib/session-store"; + +const ctx = (id: string) => ({ params: Promise.resolve({ id }) }); + +function session() { + return createSession({ + profileAccessToken: "p", profileAccessTokenExpiresAt: null, profileRefreshToken: "r", + feedAccessToken: "T", feedAccessTokenExpiresAt: null, + person: { userId: "u", name: "A", email: "a@b.co", pictureUrl: null }, createdAt: Date.now(), + }); +} + +async function withFetch(fn: typeof fetch, run: () => Promise) { + const orig = globalThis.fetch; + globalThis.fetch = fn; + try { return await run(); } finally { globalThis.fetch = orig; } +} + +const raw = [ + { id: "c1", content: "first", createdAt: "2026-10-19T10:00:00Z", author: { displayName: "Ana Ruiz" } }, + { id: "c2", content: "SECRET OLD TEXT", createdAt: "2026-10-19T10:05:00Z", deletedAt: "2026-10-19T11:00:00Z", author: { firstName: "Bo", lastName: "Li" } }, +]; + +describe("comment routes", () => { + it("401 sign_in without a session", async () => { + const g = await GET(new NextRequest("http://localhost/api/posts/p1/comments"), ctx("p1")); + expect(g.status).toBe(401); + expect(await g.json()).toEqual({ error: "sign_in" }); + const p = await POST(new NextRequest("http://localhost/x", { method: "POST", body: JSON.stringify({ content: "hi" }) }), ctx("p1")); + expect(p.status).toBe(401); + expect(await p.json()).toEqual({ error: "sign_in" }); + }); + + it("GET reads the feed list with limit and page; removed comments lose their text", async () => { + const sid = session(); + const urls: string[] = []; + const f = vi.fn(async (u: string) => { urls.push(String(u)); return new Response(JSON.stringify({ data: { comments: raw } }), { status: 200 }); }); + await withFetch(f as unknown as typeof fetch, async () => { + const r = await GET(new NextRequest("http://localhost/x", { headers: { cookie: `mfb_session=${sid}` } }), ctx("p1")); + const body = await r.json(); + expect(body.comments).toHaveLength(2); + expect(body.comments[0]).toMatchObject({ content: "first", authorName: "Ana Ruiz" }); + expect(body.comments[1]).toMatchObject({ removed: true, content: "", authorName: "Bo Li" }); + expect(JSON.stringify(body)).not.toContain("SECRET OLD TEXT"); + }); + expect(urls[0]).toMatch(/\/api\/v1\/posts\/p1\/comments\?limit=20&page=1$/); + }); + + it("POST sends the text comment payload and rejects empty content without calling the feed", async () => { + const sid = session(); + const h = { cookie: `mfb_session=${sid}` }; + const sent: unknown[] = []; + const f = vi.fn(async (_u: string, init?: RequestInit) => { + sent.push(JSON.parse(String(init?.body))); + return new Response(JSON.stringify({ data: { id: "c9", content: "hello", createdAt: "2026-10-19T12:00:00Z", author: { displayName: "Me" } } }), { status: 201 }); + }); + await withFetch(f as unknown as typeof fetch, async () => { + const ok = await POST(new NextRequest("http://localhost/x", { method: "POST", headers: h, body: JSON.stringify({ content: "hello" }) }), ctx("p1")); + expect((await ok.json()).comment).toMatchObject({ id: "c9", content: "hello" }); + const empty = await POST(new NextRequest("http://localhost/x", { method: "POST", headers: h, body: JSON.stringify({ content: " " }) }), ctx("p1")); + expect(empty.status).toBe(400); + }); + expect(sent).toEqual([{ content: "hello", type: "text", status: "published" }]); + expect(f).toHaveBeenCalledTimes(1); + }); + + it("a feed failure is 502 with the action in the error", async () => { + const sid = session(); + const h = { cookie: `mfb_session=${sid}` }; + const bad = vi.fn(async () => new Response("{}", { status: 500 })) as unknown as typeof fetch; + await withFetch(bad, async () => { + const g = await GET(new NextRequest("http://localhost/x", { headers: h }), ctx("p1")); + expect([g.status, await g.json()]).toEqual([502, { error: "comments_unavailable" }]); + const p = await POST(new NextRequest("http://localhost/x", { method: "POST", headers: h, body: JSON.stringify({ content: "x" }) }), ctx("p1")); + expect([p.status, await p.json()]).toEqual([502, { error: "comment_failed" }]); + }); + }); +}); + +describe("comments panel", () => { + it("opening lists comments with author and time; a removed one shows no old text", () => { + const html = renderToStaticMarkup(createElement(CommentsPanel, { comments: toComments(raw), value: "", now: Date.parse("2026-10-19T12:00:00Z") })); + expect(html).toContain("Ana Ruiz"); + expect(html).toContain("first"); + expect(html).toContain("2h ago"); + expect(html).toContain("This comment was removed."); + expect(html).not.toContain("SECRET OLD TEXT"); + }); + + it("empty or whitespace input disables submit", () => { + const off = renderToStaticMarkup(createElement(CommentsPanel, { comments: [], value: " " })); + expect(off).toMatch(/