bo5tym52p: List and create comments on post cards
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 (
|
||||
<div className="comments" data-testid="comments-panel">
|
||||
{p.loading ? <span className="spinner" role="status" aria-label="Loading comments" /> : null}
|
||||
{!p.loading && p.comments.length === 0 && !p.error ? (
|
||||
<p className="comments-empty">No comments yet.</p>
|
||||
) : null}
|
||||
<ul className="comment-list">
|
||||
{p.comments.map((c) => (
|
||||
<li key={c.key} className={c.removed ? "comment removed" : "comment"} data-testid="comment">
|
||||
<div className="comment-head">
|
||||
<strong>{c.authorName}</strong>
|
||||
<time dateTime={c.createdAt ?? undefined}>{relativeTime(c.createdAt, p.now)}</time>
|
||||
</div>
|
||||
{c.removed ? <p className="comment-removed">This comment was removed.</p> : <p>{c.content}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{p.error ? (
|
||||
<p className="comment-error" role="alert">
|
||||
{p.error}{" "}
|
||||
{p.onRetry ? <button type="button" className="link-btn" onClick={p.onRetry}>Try again</button> : null}
|
||||
</p>
|
||||
) : null}
|
||||
<form
|
||||
className="comment-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!empty && !p.busy) p.onSubmit?.();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Add a comment"
|
||||
placeholder="Add a comment"
|
||||
value={p.value}
|
||||
maxLength={5000}
|
||||
onChange={(e) => p.onChange?.(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={empty || p.busy}>{p.busy ? "Posting" : "Post"}</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<CommentItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<CommentsPanel
|
||||
comments={comments}
|
||||
loading={loading}
|
||||
error={error}
|
||||
value={value}
|
||||
busy={busy}
|
||||
onChange={setValue}
|
||||
onSubmit={() => void submit()}
|
||||
onRetry={error === commentErrorText("load") ? () => void load() : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface FeedViewProps {
|
||||
onToggleLike?: (post: CardPost) => void;
|
||||
likeBusy?: ReadonlySet<string>;
|
||||
likeErrors?: ReadonlySet<string>;
|
||||
onSignedOut?: () => void;
|
||||
composer?: ComposerProps;
|
||||
sentinelRef?: Ref<HTMLDivElement>;
|
||||
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 ? (
|
||||
|
||||
@@ -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 (
|
||||
<article className="post" data-testid="post-card">
|
||||
<header className="post-head">
|
||||
@@ -60,11 +66,20 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError }: PostC
|
||||
>
|
||||
<Heart filled={post.isLiked} /> <span className="like-count">{post.likesCount}</span>
|
||||
</button>
|
||||
<span aria-label={`${post.commentsCount} comments`}>{post.commentsCount} comments</span>
|
||||
<button
|
||||
type="button"
|
||||
className="comments-toggle"
|
||||
aria-expanded={open}
|
||||
disabled={!post.id}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
{post.commentsCount} comments
|
||||
</button>
|
||||
{likeError ? (
|
||||
<span className="like-error" role="alert">Could not update like.</span>
|
||||
) : null}
|
||||
</footer>
|
||||
{open && post.id ? <CommentsSection postId={post.id} onSignedOut={onSignedOut} /> : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Record<string, unknown>> {
|
||||
if (res.status === 401) throw new CommentError(action, true);
|
||||
if (!res.ok) throw new CommentError(action, false);
|
||||
try {
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new CommentError(action, false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadComments(postId: string, fetchImpl: typeof fetch = fetch): Promise<CommentItem[]> {
|
||||
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<CommentItem[]> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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];
|
||||
}
|
||||
@@ -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<E extends string> = { 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<string, unknown> {
|
||||
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function url(postId: string, query = "") {
|
||||
return `${FEED_BASE_URL}/api/v1/posts/${encodeURIComponent(postId)}/comments${query}`;
|
||||
}
|
||||
|
||||
async function callWithRefresh(
|
||||
sessionId: string,
|
||||
first: Awaited<ReturnType<typeof ensureFreshSession>>,
|
||||
make: (token: string) => Promise<Response>,
|
||||
options: Options,
|
||||
): Promise<Response | "sign_in"> {
|
||||
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<ListCommentsResult> {
|
||||
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<CreateCommentResult> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user