bo5tym52p: List and create comments on post cards

This commit is contained in:
2026-10-19 14:29:07 -06:00
parent de535a500d
commit 5a33546af2
11 changed files with 596 additions and 2 deletions
+144
View File
@@ -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<T>(fn: typeof fetch, run: () => Promise<T>) {
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(/<button type="submit" disabled/);
const on = renderToStaticMarkup(createElement(CommentsPanel, { comments: [], value: "hi" }));
expect(on).not.toMatch(/<button type="submit" disabled/);
});
it("an error names the action and the list stays", () => {
const html = renderToStaticMarkup(createElement(CommentsPanel, { comments: toComments(raw), value: "x", error: commentErrorText("post") }));
expect(html).toContain("Could not post comment.");
expect(html).toContain("Ana Ruiz");
});
});
describe("comment client", () => {
const proxy = (comments: unknown[]) => vi.fn(async (_u: string, init?: RequestInit) => {
if (init?.method === "POST") {
const made = { key: "c9", id: "c9", authorName: "Me", createdAt: "2026-10-19T12:00:00Z", removed: false, content: JSON.parse(String(init.body)).content };
return new Response(JSON.stringify({ comment: made }), { status: 200 });
}
return new Response(JSON.stringify({ comments }), { status: 200 });
}) as unknown as typeof fetch;
it("posting adds the new comment to the list", async () => {
const f = proxy([]);
const list = await loadComments("p1", f);
const next = await postComment("p1", " new one ", toComments(raw).concat(list), f);
expect(next).toHaveLength(3);
expect(next[2]).toMatchObject({ id: "c9", content: "new one" });
});
it("empty content does not submit", async () => {
const f = proxy([]);
const cur = toComments(raw);
expect(await postComment("p1", " ", cur, f)).toBe(cur);
expect(f).not.toHaveBeenCalled();
});
it("a failed post throws naming the action; a 401 marks signed out", async () => {
const f = vi.fn(async () => new Response("{}", { status: 502 })) as unknown as typeof fetch;
await expect(postComment("p1", "x", [], f)).rejects.toMatchObject({ action: "post", signedOut: false });
const out = vi.fn(async () => new Response("{}", { status: 401 })) as unknown as typeof fetch;
await expect(loadComments("p1", out)).rejects.toBeInstanceOf(CommentError);
await expect(loadComments("p1", out)).rejects.toMatchObject({ action: "load", signedOut: true });
expect(commentErrorText("load")).toBe("Could not load comments.");
});
});