Promote to main: Likes/comments/text posts, Image/video posts, publish-lag fix

Validated in dev by Cesar; Feed browser module acceptance pass PASSED (mdi1xkyuv). Tasks 4wswt4ex2, 3dom4wf7p, bo5tym52p, a18hvcryt, mgply09ox, t3xbu10dr.
This commit is contained in:
2026-10-19 16:13:35 -06:00
34 changed files with 2563 additions and 18 deletions
+34
View File
@@ -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 });
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse, type NextRequest } from "next/server";
import { SESSION_COOKIE } from "@/lib/cookie";
import { setPostLike } from "@/lib/post-like";
// POST /api/posts/:id/like -> feed POST /api/v1/posts/{id}/like
// DELETE /api/posts/:id/like -> feed DELETE /api/v1/posts/{id}/like
// No request body. Returns { isLiked, likesCount }; likesCount is null when the
// feed response carries no count. 401 { error: "sign_in" } without a session.
export const runtime = "nodejs";
type Ctx = { params: Promise<{ id: string }> };
async function handle(req: NextRequest, ctx: Ctx, liked: boolean) {
const sessionId = req.cookies.get(SESSION_COOKIE)?.value;
const { id } = await ctx.params;
const result = await setPostLike(sessionId, id, liked);
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: result.status });
}
return NextResponse.json({ isLiked: result.isLiked, likesCount: result.likesCount });
}
export function POST(req: NextRequest, ctx: Ctx) {
return handle(req, ctx, true);
}
export function DELETE(req: NextRequest, ctx: Ctx) {
return handle(req, ctx, false);
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse, type NextRequest } from "next/server";
import { SESSION_COOKIE } from "@/lib/cookie";
import { deletePost } from "@/lib/post-delete";
// DELETE /api/posts/:id -> feed DELETE /api/v1/posts/{id}
// 401 { error: "sign_in" } without a session. Returns { ok: true } on success.
export const runtime = "nodejs";
type Ctx = { params: Promise<{ id: string }> };
export async function DELETE(req: NextRequest, ctx: Ctx) {
const sessionId = req.cookies.get(SESSION_COOKIE)?.value;
const { id } = await ctx.params;
const result = await deletePost(sessionId, id);
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: result.status });
}
return NextResponse.json({ ok: true });
}
+52
View File
@@ -0,0 +1,52 @@
import { NextResponse, type NextRequest } from "next/server";
import { SESSION_COOKIE } from "@/lib/cookie";
import { createTextPost } from "@/lib/post-create";
import { createMediaPost } from "@/lib/post-media";
// POST /api/posts
// - JSON { content } -> public text post
// - multipart/form-data content+file -> public image or video post (one file)
// Both create a public draft on the feed, then publish it, and return { id }.
// For an image the server also uploads the bytes to a presigned URL and confirms
// the upload; the browser never receives the feed token. The create call can
// report status "draft" for a post already on the home feed, so the browser
// reloads the first page and confirms the id there.
// 401 { error: "sign_in" } without a session.
export const runtime = "nodejs";
export async function POST(req: NextRequest) {
const sessionId = req.cookies.get(SESSION_COOKIE)?.value;
const contentType = req.headers.get("content-type") ?? "";
if (contentType.includes("multipart/form-data")) {
let content = "";
let file: File | null = null;
try {
const form = await req.formData();
const c = form.get("content");
content = typeof c === "string" ? c : "";
const f = form.get("file");
file = f instanceof File ? f : null;
} catch {
return NextResponse.json({ error: "bad_file" }, { status: 400 });
}
const result = await createMediaPost(sessionId, content, file);
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: result.status });
}
return NextResponse.json({ id: result.id });
}
let content: unknown = null;
try {
content = ((await req.json()) as { content?: unknown }).content;
} catch {
content = null;
}
const result = await createTextPost(sessionId, content);
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: result.status });
}
return NextResponse.json({ id: result.id });
}
+1
View File
@@ -88,6 +88,7 @@ export async function GET(req: NextRequest) {
name: session.person.name,
email: session.person.email,
pictureUrl: session.person.pictureUrl,
feedUserId: session.person.feedUserId ?? null,
});
}
+44
View File
@@ -25,6 +25,7 @@ button:disabled { opacity: .5; cursor: not-allowed; }
.post-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.5; }
.post-media img { display: block; width: 100%; height: auto; border-radius: 8px; }
.post-video { background: #000; border-radius: 8px; overflow: hidden; }
.post-video-pending { color: #fff; padding: 48px 12px; text-align: center; }
.post-video video { display: block; width: 100%; max-height: 520px; background: #000; }
.post-stats { display: flex; gap: 16px; color: var(--grey); font-size: 14px; }
.heart { color: var(--red); }
@@ -35,3 +36,46 @@ button:disabled { opacity: .5; cursor: not-allowed; }
@keyframes spin { to { transform: rotate(360deg); } }
.new-posts { position: sticky; top: 68px; z-index: 4; align-self: center; margin: 0; padding: 8px 16px; border-radius: 999px; }
.sentinel { height: 1px; }
.like-btn { display: inline-flex; align-items: center; gap: 6px; margin: 0; padding: 4px 8px; background: transparent; color: var(--grey); border-radius: 8px; font-size: 14px; font-weight: 400; }
.like-btn:hover:not(:disabled) { color: var(--red); background: #fff3f3; }
.like-btn.liked { color: var(--red); }
.like-btn:disabled { opacity: 1; cursor: default; }
.like-btn:disabled:not(.liked) { color: var(--grey); }
.like-error { color: #a8141b; font-size: 13px; align-self: center; }
.post-stats { align-items: center; }
.composer { background: #fff; border: 1px solid var(--line); border-radius: 0; padding: 16px; display: flex; flex-direction: column; gap: 8px; }
.composer-label { margin: 0; }
.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; }
.composer-media { display: flex; flex-direction: column; gap: 8px; }
.composer-image { display: inline-flex; align-items: center; gap: 8px; align-self: flex-start; cursor: pointer; }
.composer-image span { padding: 6px 12px; border: 1px solid #999; background: #fafafa; }
.composer-image input[type="file"] { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); border: 0; }
.composer-preview { display: flex; align-items: center; gap: 10px; }
.composer-preview video { width: 96px; height: 64px; object-fit: cover; border: 1px solid var(--line); background: #000; }
.composer-preview img { width: 64px; height: 64px; object-fit: cover; border: 1px solid var(--line); }
.composer-filename { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.composer-remove { margin: 0; padding: 6px 12px; 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; }
.post-menu { position: relative; margin-left: auto; }
.menu-btn { margin: 0; padding: 2px 10px; background: transparent; color: var(--grey); border-radius: 8px; font-size: 18px; line-height: 1; letter-spacing: 1px; }
.menu { position: absolute; right: 0; top: 100%; z-index: 5; display: flex; flex-direction: column; gap: 2px; min-width: 150px; padding: 6px; background: #fff; border: 1px solid #e3e3e3; border-radius: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.12); }
.menu button { margin: 0; text-align: left; background: transparent; color: inherit; padding: 6px 8px; border-radius: 6px; font-size: 14px; font-weight: 400; }
.menu button:hover { background: #f4f4f4; }
.menu .menu-danger { color: #a8141b; }
.menu-ask { font-size: 13px; color: var(--grey); padding: 4px 8px; }
+8 -2
View File
@@ -38,7 +38,13 @@ export function App() {
);
}
if (state.kind === "out") {
return <SignInForm onSignedIn={(person) => setState({ kind: "in", person })} />;
return <SignInForm onSignedIn={(person) => {
setState({ kind: "in", person });
// POST /api/session keeps its shape; the feed user id comes from GET.
void loadSession().then((full) => {
if (full) setState({ kind: "in", person: full });
});
}} />;
}
return (
<main className="shell">
@@ -52,7 +58,7 @@ export function App() {
{signingOut ? "Signing out..." : "Sign out"}
</button>
</header>
<Feed onSignedOut={() => setState({ kind: "out" })} />
<Feed feedUserId={state.person.feedUserId} onSignedOut={() => setState({ kind: "out" })} />
</main>
);
}
+61
View File
@@ -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>
);
}
+66
View File
@@ -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}
/>
);
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import { useEffect, useState, type ChangeEvent, type FormEvent } from "react";
export interface ComposerProps {
value: string;
busy: boolean;
error: boolean;
/** The selected image or video file, when the pickers are wired. */
file?: File | null;
onChange: (value: string) => void;
/** When present, the composer shows image and video pickers and reports the choice. */
onFile?: (file: File | null) => void;
onSubmit: () => void;
}
// Content types the picker offers (spec: allowed image types).
const IMAGE_ACCEPT = "image/jpeg,image/jpg,image/png,image/gif,image/webp,image/heic";
const VIDEO_ACCEPT = "video/mp4,video/quicktime,video/webm";
export function Composer({ value, busy, error, file = null, onChange, onFile, onSubmit }: ComposerProps) {
const empty = value.trim().length === 0;
// A post needs either text or a file.
const canSubmit = (!empty || !!file) && !busy;
const [preview, setPreview] = useState<string | null>(null);
// Build a preview URL client-side only, and revoke it when the file changes.
useEffect(() => {
if (!file) {
setPreview(null);
return;
}
const url = URL.createObjectURL(file);
setPreview(url);
return () => URL.revokeObjectURL(url);
}, [file]);
function submit(e: FormEvent) {
e.preventDefault();
if (canSubmit) onSubmit();
}
function pick(e: ChangeEvent<HTMLInputElement>) {
const chosen = e.target.files && e.target.files[0] ? e.target.files[0] : null;
onFile?.(chosen);
// One file per post: picking one kind clears the other input.
e.target.value = "";
}
function remove() {
onFile?.(null);
}
const isVideo = !!file && file.type.startsWith("video/");
return (
<form className="composer" onSubmit={submit}>
<label htmlFor="composer-text" className="composer-label">New post</label>
<textarea
id="composer-text"
value={value}
rows={3}
placeholder="Share something with the feed"
disabled={busy}
onChange={(e) => onChange(e.target.value)}
/>
{onFile ? (
<div className="composer-media">
<label className="composer-image">
<span>Add image</span>
<input
type="file"
accept={IMAGE_ACCEPT}
disabled={busy}
onChange={pick}
/>
</label>
<label className="composer-image composer-video">
<span>Add video</span>
<input type="file" accept={VIDEO_ACCEPT} disabled={busy} onChange={pick} />
</label>
{file ? (
<div className="composer-preview">
{preview && isVideo ? (
<video src={preview} muted playsInline preload="metadata" aria-label={`Selected video: ${file.name}`} />
) : preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt={`Selected image: ${file.name}`} />
) : null}
<span className="composer-filename">{file.name}</span>
<button type="button" className="composer-remove" onClick={remove} disabled={busy}>
Remove
</button>
</div>
) : null}
</div>
) : null}
{error ? (
<p className="error" role="alert">Publishing the post failed. Your text is still here.</p>
) : null}
<button type="submit" className="composer-post" disabled={!canSubmit}>
{busy ? "Posting" : "Post"}
</button>
</form>
);
}
+112 -3
View File
@@ -2,18 +2,32 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client";
import { mergePosts, toCardPosts, type CardPost } from "@/lib/feed-model";
import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client";
import { mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model";
import { PostError, publishAndConfirm } from "@/lib/post-client";
import { DeleteError, sendDelete } from "@/lib/delete-client";
import { FeedView, type FeedStatus } from "./FeedView";
const POLL_MS = 45_000;
export function Feed({ onSignedOut }: { onSignedOut: () => void }) {
export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; feedUserId?: string | null }) {
const [posts, setPosts] = useState<CardPost[]>([]);
const [status, setStatus] = useState<FeedStatus>("loading");
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loadingMore, setLoadingMore] = useState(false);
const [loadMoreFailed, setLoadMoreFailed] = useState(false);
const [pending, setPending] = useState<CardPost[]>([]);
const [likeBusy, setLikeBusy] = useState<ReadonlySet<string>>(new Set());
const [likeErrors, setLikeErrors] = useState<ReadonlySet<string>>(new Set());
const [deleteBusy, setDeleteBusy] = useState<ReadonlySet<string>>(new Set());
const [deleteErrors, setDeleteErrors] = useState<ReadonlySet<string>>(new Set());
const [draft, setDraft] = useState("");
const [file, setFile] = useState<File | null>(null);
const [posting, setPosting] = useState(false);
const [postFailed, setPostFailed] = useState(false);
// Id of a post already created but not yet seen on the first page. A retry
// re-checks the feed instead of creating a duplicate.
const unconfirmed = useRef<{ id: string | null }>({ id: null });
const sentinel = useRef<HTMLDivElement | null>(null);
const busy = useRef(false);
const shown = useRef<Set<string>>(new Set());
@@ -92,7 +106,9 @@ export function Feed({ onSignedOut }: { onSignedOut: () => void }) {
const poll = async () => {
try {
const page = await fetchFeedPage(null);
const fresh = toCardPosts(page.items).filter((p) => !shown.current.has(p.key));
const all = toCardPosts(page.items);
setPosts((cur) => upgradeVideos(cur, all));
const fresh = all.filter((p) => !shown.current.has(p.key));
if (fresh.length) setPending((cur) => mergePosts(cur, fresh));
} catch (e) {
fail(e);
@@ -121,6 +137,90 @@ export function Feed({ onSignedOut }: { onSignedOut: () => void }) {
};
}, [status, fail]);
const toggleLike = useCallback(
async (post: CardPost) => {
if (!post.id) return;
const wanted = !post.isLiked;
const mark = (set: ReadonlySet<string>, on: boolean) => {
const next = new Set(set);
if (on) next.add(post.key);
else next.delete(post.key);
return next;
};
setLikeBusy((s) => mark(s, true));
setLikeErrors((s) => mark(s, false));
try {
const out = await sendLike(post.id, wanted);
setPosts((cur) => cur.map((p) => (p.key === post.key ? applyLikeOutcome(p, wanted, out) : p)));
} catch (e) {
fail(e instanceof LikeError && e.signedOut ? new FeedLoadError(true) : e);
setLikeErrors((s) => mark(s, true));
} finally {
setLikeBusy((s) => mark(s, false));
}
},
[fail],
);
const removePost = useCallback(
async (post: CardPost) => {
const mark = (set: ReadonlySet<string>, on: boolean) => {
const next = new Set(set);
if (on) next.add(post.key);
else next.delete(post.key);
return next;
};
setDeleteBusy((s) => mark(s, true));
setDeleteErrors((s) => mark(s, false));
try {
// Removes the card only after the feed answered 200.
if (!post.id) throw new DeleteError(false);
await sendDelete(post.id);
setPosts((cur) => cur.filter((p) => p.key !== post.key));
setPending((cur) => cur.filter((p) => p.key !== post.key));
} catch (e) {
if (e instanceof DeleteError && e.signedOut) onSignedOut();
setDeleteErrors((s) => mark(s, true));
} finally {
setDeleteBusy((s) => mark(s, false));
}
},
[onSignedOut],
);
const publish = useCallback(async () => {
const text = draft.trim();
// A post needs either text or a file.
if ((!text && !file) || posting) return;
setPosting(true);
setPostFailed(false);
try {
const page = await publishAndConfirm(text, file, unconfirmed.current);
setPosts(mergePosts(toCardPosts(page.items)));
setNextCursor(page.nextCursor);
setPending([]);
setDraft("");
setFile(null);
} catch (e) {
if (e instanceof PostError && e.signedOut) onSignedOut();
setPostFailed(true);
} finally {
setPosting(false);
}
}, [draft, file, posting, onSignedOut]);
function changeDraft(v: string) {
// New text is a new post; forget a stale unconfirmed id.
if (v !== draft) unconfirmed.current = { id: null };
setDraft(v);
}
function changeFile(f: File | null) {
// A different attachment is a new post; forget a stale unconfirmed id.
unconfirmed.current = { id: null };
setFile(f);
}
function showNew() {
setPosts((cur) => mergePosts(cur, pending));
setPending([]);
@@ -136,6 +236,15 @@ export function Feed({ onSignedOut }: { onSignedOut: () => void }) {
newCount={pending.length}
onRetry={() => (status === "error" ? void loadFirst() : void loadMore())}
onShowNew={showNew}
onToggleLike={(p) => void toggleLike(p)}
likeBusy={likeBusy}
likeErrors={likeErrors}
feedUserId={feedUserId}
onDelete={(p) => void removePost(p)}
deleteBusy={deleteBusy}
deleteErrors={deleteErrors}
onSignedOut={onSignedOut}
composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish() }}
sentinelRef={sentinel}
/>
);
+26 -2
View File
@@ -1,5 +1,6 @@
import type { Ref } from "react";
import type { CardPost } from "@/lib/feed-model";
import { canDelete, type CardPost } from "@/lib/feed-model";
import { Composer, type ComposerProps } from "./Composer";
import { PostCard } from "./PostCard";
export type FeedStatus = "loading" | "ready" | "error";
@@ -12,6 +13,16 @@ export interface FeedViewProps {
newCount: number;
onRetry: () => void;
onShowNew: () => void;
onToggleLike?: (post: CardPost) => void;
likeBusy?: ReadonlySet<string>;
likeErrors?: ReadonlySet<string>;
onSignedOut?: () => void;
/** The signed-in person's feed user id; delete is offered only on their own posts. */
feedUserId?: string | null;
onDelete?: (post: CardPost) => void;
deleteBusy?: ReadonlySet<string>;
deleteErrors?: ReadonlySet<string>;
composer?: ComposerProps;
sentinelRef?: Ref<HTMLDivElement>;
now?: number;
}
@@ -42,6 +53,7 @@ export function FeedView(p: FeedViewProps) {
}
return (
<section className="feed">
{p.composer ? <Composer {...p.composer} /> : null}
{p.newCount > 0 ? (
<button type="button" className="new-posts" onClick={p.onShowNew}>
{p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`}
@@ -50,7 +62,19 @@ export function FeedView(p: FeedViewProps) {
{p.posts.length === 0 ? (
<p className="feed-message">No posts yet.</p>
) : (
p.posts.map((post) => <PostCard key={post.key} post={post} now={p.now} />)
p.posts.map((post) => <PostCard
key={post.key}
post={post}
now={p.now}
onToggleLike={p.onToggleLike}
likeBusy={p.likeBusy?.has(post.key)}
likeError={p.likeErrors?.has(post.key)}
onSignedOut={p.onSignedOut}
canDelete={canDelete(post, p.feedUserId)}
onDelete={p.onDelete}
deleteBusy={p.deleteBusy?.has(post.key)}
deleteError={p.deleteErrors?.has(post.key)}
/>)
)}
{p.loadMoreFailed ? (
<div className="feed-message" role="alert">
+95 -5
View File
@@ -1,7 +1,38 @@
"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";
export function PostCard({ post, now }: { post: CardPost; now?: number }) {
export interface PostCardProps {
post: CardPost;
now?: number;
onToggleLike?: (post: CardPost) => void;
likeBusy?: boolean;
likeError?: boolean;
onSignedOut?: () => void;
/** True only on the signed-in person's own post. */
canDelete?: boolean;
onDelete?: (post: CardPost) => void;
deleteBusy?: boolean;
deleteError?: boolean;
}
function Heart({ filled }: { filled: boolean }) {
return (
<svg className="heart-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"
fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2"
strokeLinejoin="round">
<path d="M12 21s-7.5-4.6-9.6-9.3C.9 8.4 2.7 4.5 6.4 4.5c2.1 0 3.6 1.2 4.4 2.5.4.6 1.4.6 1.8 0 .8-1.3 2.3-2.5 4.4-2.5 3.7 0 5.5 3.9 4 7.2C19.5 16.4 12 21 12 21z" />
</svg>
);
}
export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSignedOut, canDelete, onDelete, deleteBusy, deleteError }: PostCardProps) {
const [open, setOpen] = useState(false);
const [menu, setMenu] = useState(false);
const [confirming, setConfirming] = useState(false);
return (
<article className="post" data-testid="post-card">
<header className="post-head">
@@ -17,7 +48,43 @@ export function PostCard({ post, now }: { post: CardPost; now?: number }) {
<strong>{post.authorName}</strong>
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time>
</div>
{canDelete && onDelete ? (
<div className="post-menu">
<button
type="button"
className="menu-btn"
aria-label="More actions"
aria-haspopup="menu"
aria-expanded={menu}
disabled={deleteBusy}
onClick={() => { setMenu((m) => !m); setConfirming(false); }}
>
...
</button>
{menu ? (
<div className="menu" role="menu">
{confirming ? (
<>
<span className="menu-ask">Delete this post?</span>
<button type="button" role="menuitem" className="menu-danger" disabled={deleteBusy}
onClick={() => { setMenu(false); setConfirming(false); onDelete(post); }}>
Confirm delete
</button>
<button type="button" role="menuitem" onClick={() => { setConfirming(false); setMenu(false); }}>
Cancel
</button>
</>
) : (
<button type="button" role="menuitem" className="menu-danger" onClick={() => setConfirming(true)}>
Delete post
</button>
)}
</div>
) : null}
</div>
) : null}
</header>
{deleteError ? <p className="like-error" role="alert">Could not delete the post.</p> : null}
{post.text ? <p className="post-text">{post.text}</p> : null}
{post.media.map((m, i) =>
m.kind === "video" && m.hlsUrl ? (
@@ -29,14 +96,37 @@ export function PostCard({ post, now }: { post: CardPost; now?: number }) {
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={m.src} alt={m.kind === "video" ? "Video thumbnail" : ""} loading="lazy" />
</div>
) : m.kind === "video" ? (
<div className="post-media post-video post-video-pending" key={i} role="img" aria-label="Video is processing">
<span>Video is processing</span>
</div>
) : null,
)}
<footer className="post-stats">
<span aria-label={`${post.likesCount} likes`}>
<span className="heart" aria-hidden="true">&#9829;</span> {post.likesCount}
</span>
<span aria-label={`${post.commentsCount} comments`}>{post.commentsCount} comments</span>
<button
type="button"
className={post.isLiked ? "like-btn liked" : "like-btn"}
aria-pressed={post.isLiked}
aria-label={post.isLiked ? `Unlike, ${post.likesCount} likes` : `Like, ${post.likesCount} likes`}
disabled={likeBusy || !post.id || !onToggleLike}
onClick={() => onToggleLike?.(post)}
>
<Heart filled={post.isLiked} /> <span className="like-count">{post.likesCount}</span>
</button>
<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>
);
}
+2 -1
View File
@@ -1,7 +1,7 @@
// Browser-side calls to this app's own /api/session. No token, password or
// session id is stored or logged here; the session lives in an httpOnly cookie.
export type Person = { name: string; email: string; pictureUrl: string | null };
export type Person = { name: string; email: string; pictureUrl: string | null; feedUserId?: string };
export const WRONG_CREDENTIALS = "Email or password is incorrect.";
export const GENERIC_FAILURE = "Could not sign you in. Try again.";
@@ -16,6 +16,7 @@ function toPerson(body: unknown): Person | null {
name: typeof r.name === "string" ? r.name : "",
email: r.email,
pictureUrl: typeof r.pictureUrl === "string" ? r.pictureUrl : null,
...(typeof r.feedUserId === "string" && r.feedUserId ? { feedUserId: r.feedUserId } : {}),
};
}
+70
View File
@@ -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,
});
}
}
+71
View File
@@ -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];
}
+116
View File
@@ -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;
}
}
+43
View File
@@ -0,0 +1,43 @@
import type { CardPost } from "./feed-model";
// Browser-side delete call.
export class DeleteError extends Error {
constructor(public readonly signedOut: boolean) {
super(signedOut ? "sign_in" : "delete_failed");
}
}
/** DELETE /api/posts/:id. Resolves on success; throws DeleteError otherwise. */
export async function sendDelete(postId: string, fetchImpl: typeof fetch = fetch): Promise<void> {
let res: Response;
try {
res = await fetchImpl(`/api/posts/${encodeURIComponent(postId)}`, {
method: "DELETE",
credentials: "same-origin",
});
} catch {
throw new DeleteError(false);
}
if (res.status === 401) throw new DeleteError(true);
if (!res.ok) throw new DeleteError(false);
}
/** The feed list after a successful delete: the card is gone. */
export function withoutPost(posts: CardPost[], key: string): CardPost[] {
return posts.filter((p) => p.key !== key);
}
/**
* Delete a post and return the new list. The card is removed only after the
* server answers 200; on any failure this throws and the caller keeps the list.
*/
export async function deleteAndRemove(
posts: CardPost[],
post: CardPost,
send: (id: string) => Promise<void> = sendDelete,
): Promise<CardPost[]> {
if (!post.id) throw new DeleteError(false);
await send(post.id);
return withoutPost(posts, post.key);
}
+35 -1
View File
@@ -12,12 +12,16 @@ export interface CardMedia {
export interface CardPost {
key: string;
id: string | null;
/** The author's id on the feed, for matching against the signed-in feed user id. */
authorId: string | null;
authorName: string;
authorPictureUrl: string | null;
publishedAt: string | null;
time: number;
text: string;
likesCount: number;
/** Whether the signed-in person has liked the post. */
isLiked: boolean;
commentsCount: number;
media: CardMedia[];
}
@@ -72,7 +76,8 @@ function toMedia(raw: unknown): CardMedia[] {
if (m.mediaType === "video") {
const thumb = str(m.thumbnailUrl);
const hls = muxHlsUrl(m.hlsUrl);
if (thumb || hls) out.push({ kind: "video", src: thumb, hlsUrl: hls });
// Keep a video even with no thumbnail or hlsUrl yet (just published).
out.push({ kind: "video", src: thumb, hlsUrl: hls });
continue;
}
const src = str(m.imageMediumWebpUrl) ?? str(m.imageMediumUrl) ?? str(m.imageUrl);
@@ -93,12 +98,15 @@ export function toCardPost(item: unknown, index = 0): CardPost | null {
return {
key: id ?? `${publishedAt ?? "unknown"}#${index}`,
id,
authorId:
typeof author.id === "string" || typeof author.id === "number" ? String(author.id) : null,
authorName: authorName(author),
authorPictureUrl: str(author.profilePictureUrl),
publishedAt,
time: Number.isFinite(t) ? t : Number.NEGATIVE_INFINITY,
text: typeof post.content === "string" ? post.content : "",
likesCount: num(item.likesCount ?? post.likesCount),
isLiked: post.isLiked === true || item.isLiked === true,
commentsCount: num(item.commentsCount ?? post.commentsCount),
media: toMedia(post.mediaItems),
};
@@ -135,6 +143,27 @@ export function mergePosts(...lists: CardPost[][]): CardPost[] {
return sortByPublishedDesc(all);
}
const hasHls = (p: CardPost) => p.media.some((m) => m.kind === "video" && m.hlsUrl);
/**
* Swap in the media of shown posts whose video gained an hlsUrl on a refresh
* (transcoding finished), so the thumbnail upgrades to the player in place.
* Returns the same array when nothing changed.
*/
export function upgradeVideos(current: CardPost[], fresh: CardPost[]): CardPost[] {
const byKey = new Map(fresh.map((p) => [p.key, p]));
let changed = false;
const next = current.map((p) => {
const f = byKey.get(p.key);
if (f && !hasHls(p) && hasHls(f)) {
changed = true;
return { ...p, media: f.media };
}
return p;
});
return changed ? next : current;
}
export function relativeTime(publishedAt: string | null, now: number = Date.now()): string {
const t = publishedAt ? Date.parse(publishedAt) : NaN;
if (!Number.isFinite(t)) return "";
@@ -148,3 +177,8 @@ export function relativeTime(publishedAt: string | null, now: number = Date.now(
if (d < 7) return `${d}d ago`;
return new Date(t).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
/** Delete is offered only on the signed-in person's own post. */
export function canDelete(post: CardPost, feedUserId: string | null | undefined): boolean {
return !!feedUserId && !!post.id && post.authorId !== null && post.authorId === feedUserId;
}
+52
View File
@@ -0,0 +1,52 @@
// Browser-side like / unlike calls and the pure card update they drive.
import type { CardPost } from "./feed-model";
export interface LikeOutcome {
isLiked: boolean;
likesCount: number | null;
}
export class LikeError extends Error {
constructor(public readonly signedOut: boolean) {
super(signedOut ? "sign_in" : "like_failed");
}
}
export async function sendLike(
postId: string,
liked: boolean,
fetchImpl: typeof fetch = fetch,
): Promise<LikeOutcome> {
let res: Response;
try {
res = await fetchImpl(`/api/posts/${encodeURIComponent(postId)}/like`, {
method: liked ? "POST" : "DELETE",
credentials: "same-origin",
});
} catch {
throw new LikeError(false);
}
if (res.status === 401) throw new LikeError(true);
if (!res.ok) throw new LikeError(false);
try {
const b = (await res.json()) as { isLiked?: unknown; likesCount?: unknown };
return {
isLiked: typeof b.isLiked === "boolean" ? b.isLiked : liked,
likesCount:
typeof b.likesCount === "number" && Number.isInteger(b.likesCount) && b.likesCount >= 0
? b.likesCount
: null,
};
} catch {
throw new LikeError(false);
}
}
/** The card after a successful call: the server count when present, else flip and adjust by one. */
export function applyLikeOutcome(post: CardPost, wanted: boolean, out: LikeOutcome): CardPost {
if (out.likesCount !== null) return { ...post, isLiked: out.isLiked, likesCount: out.likesCount };
const changed = post.isLiked !== wanted;
const delta = changed ? (wanted ? 1 : -1) : 0;
return { ...post, isLiked: wanted, likesCount: Math.max(0, post.likesCount + delta) };
}
+23 -2
View File
@@ -1,4 +1,4 @@
import { PROFILES_BASE_URL } from "./env";
import { FEED_BASE_URL, PROFILES_BASE_URL } from "./env";
import type { SessionPerson } from "./session-store";
// Talks to the Minnect profiles host to turn an email + password into the
@@ -217,6 +217,23 @@ export async function revokeToken(
}
}
/** `GET /api/v1/auth/me` on the feed host. Returns data.id, or null on any failure. */
async function fetchFeedUserId(feedToken: string, doFetch: typeof fetch): Promise<string | null> {
try {
const res = await doFetch(`${FEED_BASE_URL}/api/v1/auth/me`, {
method: "GET",
headers: jsonHeaders(feedToken),
});
if (res.status !== 200) return null;
const id = asRecord(asRecord(await parseJson(res)).data).id;
if (typeof id === "string" && id.length > 0) return id;
if (typeof id === "number" && Number.isFinite(id)) return String(id);
return null;
} catch {
return null;
}
}
/**
* Runs the three-call sign-in: login -> auth/me -> feed-token exchange.
* Returns the person to show and the tokens to store server-side.
@@ -278,8 +295,12 @@ export async function loginToMinnect(
options,
);
// 4. The person's id on the feed, used to decide who may delete a post.
// Best-effort: without it no delete control is offered.
const feedUserId = await fetchFeedUserId(feedAccessToken, doFetch);
return {
person: { userId, name, email: personEmail, pictureUrl },
person: { userId, name, email: personEmail, pictureUrl, feedUserId },
tokens: {
profileAccessToken,
profileAccessTokenExpiresAt,
+131
View File
@@ -0,0 +1,131 @@
// Browser-side text-post publishing. The composer text is only cleared after
// the new post id shows up on the reloaded first page of the home feed.
import { fetchFeedPage, FeedLoadError, type FeedPage } from "./feed-client";
export class PostError extends Error {
constructor(public readonly signedOut: boolean) {
super(signedOut ? "sign_in" : "publish_failed");
}
}
export async function sendPost(
content: string,
file: File | null = null,
fetchImpl: typeof fetch = fetch,
): Promise<string> {
let res: Response;
// A file is sent as multipart form-data so the server can upload the bytes;
// text-only posts stay JSON. The content type is left to FormData for the
// multipart case so the boundary is set correctly.
const init: RequestInit = file
? (() => {
const form = new FormData();
form.append("content", content);
form.append("file", file, file.name);
return { method: "POST", credentials: "same-origin", body: form };
})()
: {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
};
try {
res = await fetchImpl("/api/posts", init);
} catch {
throw new PostError(false);
}
if (res.status === 401) throw new PostError(true);
if (!res.ok) throw new PostError(false);
try {
const b = (await res.json()) as { id?: unknown };
if ((typeof b.id === "string" && b.id) || typeof b.id === "number") return String(b.id);
} catch {
/* fall through */
}
throw new PostError(false);
}
function pageHasPost(page: FeedPage, id: string): boolean {
return page.items.some((item) => {
const post = (item as { post?: { id?: unknown } } | null)?.post;
return post != null && String(post.id) === id;
});
}
export interface ConfirmOpts {
/** How many times to reload the first page before giving up. */
attempts?: number;
/** Delay between reloads, in ms. */
delayMs?: number;
sleep?: (ms: number) => Promise<void>;
}
const defaultSleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/**
* Reload the first page, retrying a few times, because a freshly published post
* can take a moment to surface on the home feed. Returns the page as soon as it
* holds the post id, else null after all attempts. Throws PostError when the
* person is signed out or a reload fails.
*/
export async function confirmOnFirstPage(
id: string,
fetchPage: (cursor: string | null) => Promise<FeedPage> = fetchFeedPage,
opts: ConfirmOpts = {},
): Promise<FeedPage | null> {
const attempts = Math.max(1, opts.attempts ?? 6);
const delayMs = opts.delayMs ?? 800;
const sleep = opts.sleep ?? defaultSleep;
for (let i = 0; i < attempts; i++) {
let page: FeedPage;
try {
page = await fetchPage(null);
} catch (e) {
throw new PostError(e instanceof FeedLoadError && e.signedOut);
}
if (pageHasPost(page, id)) return page;
if (i < attempts - 1) await sleep(delayMs);
}
return null;
}
export interface PublishDeps {
send?: (content: string, file: File | null) => Promise<string>;
fetchPage?: (cursor: string | null) => Promise<FeedPage>;
confirm?: ConfirmOpts;
}
/**
* Publish a post (text, or text plus one image or video file) and confirm it on
* the reloaded first page. `unconfirmed` holds an id that was created but not yet
* confirmed, so if the reload itself fails a retry re-checks the feed instead of
* creating a duplicate.
*
* The server only returns an id once Minnect has accepted the publish (sendPost
* throws otherwise), so once we have an id the post IS published. If it has not
* surfaced on the feed after the retries, that is feed lag, not a failure: we
* clear the pending id and return the latest page — the post appears on a later
* poll — rather than showing the person a false error.
*/
export async function publishAndConfirm(
content: string,
file: File | null,
unconfirmed: { id: string | null },
deps: PublishDeps = {},
): Promise<FeedPage> {
const fetchPage = deps.fetchPage ?? fetchFeedPage;
const id = unconfirmed.id ?? (await (deps.send ?? sendPost)(content, file));
unconfirmed.id = id;
// confirmOnFirstPage throws only on a sign-out / reload failure; in that case
// unconfirmed.id stays set so a retry re-checks without creating again.
const page = await confirmOnFirstPage(id, fetchPage, deps.confirm);
unconfirmed.id = null;
if (page) return page;
try {
return await fetchPage(null);
} catch (e) {
throw new PostError(e instanceof FeedLoadError && e.signedOut);
}
}
+94
View File
@@ -0,0 +1,94 @@
import { FEED_BASE_URL } from "./env";
import { ensureFreshSession, refreshSession } from "./session-refresh";
// Text-post creation proxy. Two feed calls with the person's server-held feed
// token: create a public draft, then publish it with the same content. Neither
// the token nor the post text is logged.
export type CreatePostResult =
| { ok: true; id: string; status: string | null }
| { ok: false; status: 400 | 401 | 502; error: "sign_in" | "empty_post" | "post_failed" };
interface CreateOptions {
fetchImpl?: typeof fetch;
now?: number;
}
export const MAX_POST_LENGTH = 20_000;
function rec(v: unknown): Record<string, unknown> {
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
}
function readCreated(body: unknown): { id: string; status: string | null } | null {
const root = rec(body);
const data = rec(root.data);
const post = rec(data.post);
for (const v of [data.id, post.id, root.id]) {
if ((typeof v === "string" && v) || typeof v === "number") {
const status = [data.status, post.status, root.status].find((s) => typeof s === "string");
return { id: String(v), status: (status as string | undefined) ?? null };
}
}
return null;
}
function send(token: string, path: string, payload: unknown, doFetch: typeof fetch) {
return doFetch(`${FEED_BASE_URL}${path}`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
});
}
export async function createTextPost(
sessionId: string | undefined,
content: unknown,
options: CreateOptions = {},
): Promise<CreatePostResult> {
const signIn = { ok: false, status: 401, error: "sign_in" } as const;
const failed = { ok: false, status: 502, error: "post_failed" } as const;
const doFetch = options.fetchImpl ?? fetch;
let session = await ensureFreshSession(sessionId, options);
if (!session || !sessionId) return signIn;
if (typeof content !== "string" || !content.trim() || content.length > MAX_POST_LENGTH) {
return { ok: false, status: 400, error: "empty_post" };
}
// One call with a single refresh-and-retry on a 401 token rejection.
const call = async (path: string, payload: unknown): Promise<Response | "sign_in"> => {
let res = await send(session!.feedAccessToken, path, payload, doFetch);
if (res.status === 401) {
session = await refreshSession(sessionId, options);
if (!session) return "sign_in";
res = await send(session.feedAccessToken, path, payload, doFetch);
if (res.status === 401) return "sign_in";
}
return res;
};
try {
const created = await call("/api/v1/posts", {
content,
type: "text",
status: "draft",
visibility: "public",
});
if (created === "sign_in") return signIn;
if (created.status !== 200 && created.status !== 201) return failed;
const info = readCreated(await created.json().catch(() => null));
if (!info) return failed;
const published = await call(`/api/v1/posts/${encodeURIComponent(info.id)}/publish`, { content });
if (published === "sign_in") return signIn;
if (published.status < 200 || published.status > 299) return failed;
return { ok: true, id: info.id, status: info.status };
} catch {
return failed;
}
}
+52
View File
@@ -0,0 +1,52 @@
import { FEED_BASE_URL } from "./env";
import { ensureFreshSession, refreshSession } from "./session-refresh";
// Delete proxy: feed DELETE /api/v1/posts/{id} with the person's server-held
// feed token. The feed decides whether the caller may delete; this app also
// only offers the control on the person's own posts. One refresh-and-retry
// after a 401 token rejection.
export type DeleteResult =
| { ok: true }
| { ok: false; status: 400 | 401 | 502; error: "sign_in" | "bad_post" | "delete_failed" };
interface DeleteOptions {
fetchImpl?: typeof fetch;
now?: number;
}
const POST_ID = /^[A-Za-z0-9_-]{1,128}$/;
function call(token: string, postId: string, doFetch: typeof fetch) {
return doFetch(`${FEED_BASE_URL}/api/v1/posts/${encodeURIComponent(postId)}`, {
method: "DELETE",
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
});
}
export async function deletePost(
sessionId: string | undefined,
postId: string,
options: DeleteOptions = {},
): Promise<DeleteResult> {
const signIn = { ok: false, status: 401, error: "sign_in" } as const;
const failed = { ok: false, status: 502, error: "delete_failed" } as const;
const doFetch = options.fetchImpl ?? fetch;
let session = await ensureFreshSession(sessionId, options);
if (!session || !sessionId) return signIn;
if (!POST_ID.test(postId)) return { ok: false, status: 400, error: "bad_post" };
try {
let res = await call(session.feedAccessToken, postId, doFetch);
if (res.status === 401) {
session = await refreshSession(sessionId, options);
if (!session) return signIn;
res = await call(session.feedAccessToken, postId, doFetch);
if (res.status === 401) return signIn;
}
return res.status === 200 ? { ok: true } : failed;
} catch {
return failed;
}
}
+76
View File
@@ -0,0 +1,76 @@
import { FEED_BASE_URL } from "./env";
import { ensureFreshSession, refreshSession } from "./session-refresh";
// Like / unlike proxy. One call to the feed per request, with the person's
// server-held feed token. Like is idempotent: a like is never retried in a
// loop. The only repeat is one refresh-and-retry after a 401 token rejection.
export type LikeResult =
| { ok: true; isLiked: boolean; likesCount: number | null }
| { ok: false; status: 400 | 401 | 502; error: "sign_in" | "bad_post" | "like_failed" };
interface LikeOptions {
fetchImpl?: typeof fetch;
now?: number;
}
const POST_ID = /^[A-Za-z0-9_-]{1,128}$/;
function call(token: string, postId: string, liked: boolean, doFetch: typeof fetch) {
return doFetch(`${FEED_BASE_URL}/api/v1/posts/${encodeURIComponent(postId)}/like`, {
method: liked ? "POST" : "DELETE",
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
});
}
function rec(v: unknown): Record<string, unknown> {
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
}
/** A like count from the response body, only when it is a non-negative integer. */
function readCount(body: unknown): number | null {
const root = rec(body);
const data = rec(root.data);
for (const v of [data.likesCount, data.likeCount, root.likesCount, root.likeCount]) {
if (typeof v === "number" && Number.isInteger(v) && v >= 0) return v;
}
return null;
}
export async function setPostLike(
sessionId: string | undefined,
postId: string,
liked: boolean,
options: LikeOptions = {},
): Promise<LikeResult> {
const signIn = { ok: false, status: 401, error: "sign_in" } as const;
const failed = { ok: false, status: 502, error: "like_failed" } as const;
const doFetch = options.fetchImpl ?? fetch;
let session = await ensureFreshSession(sessionId, options);
if (!session || !sessionId) return signIn;
if (!POST_ID.test(postId)) return { ok: false, status: 400, error: "bad_post" };
let res: Response;
try {
res = await call(session.feedAccessToken, postId, liked, doFetch);
if (res.status === 401) {
session = await refreshSession(sessionId, options);
if (!session) return signIn;
res = await call(session.feedAccessToken, postId, liked, doFetch);
if (res.status === 401) return signIn;
}
} catch {
return failed;
}
if (res.status !== 200) return failed;
let body: unknown = null;
try {
body = await res.json();
} catch {
return failed;
}
if (rec(rec(body).data).success !== true) return failed;
return { ok: true, isLiked: liked, likesCount: readCount(body) };
}
+275
View File
@@ -0,0 +1,275 @@
import { FEED_BASE_URL } from "./env";
import { MAX_POST_LENGTH } from "./post-create";
import { ensureFreshSession, refreshSession, type RefreshOptions } from "./session-refresh";
import type { SessionData } from "./session-store";
// Media-post creation proxy. Unlike a text post this needs five feed steps:
// 1. create an image/video draft
// 2. ask the feed for a presigned upload URL
// 3. the SERVER PUTs the bytes to that URL (the browser never gets the token)
// 4. confirm the upload (image confirmation blocks until processing finishes)
// 5. publish with the same {content} publish call as a text post
// The pipeline is media-type agnostic; the allowed-type gates and entry points
// (createImagePost, createVideoPost) are per kind.
// Neither the feed token nor the file bytes are ever logged.
export type MediaKind = "image" | "video";
/** Image content types the feed accepts (spec: "Create a post with an image or video"). */
export const ALLOWED_IMAGE_TYPES: readonly string[] = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
"image/heic",
];
/** Video content types the feed accepts. */
export const ALLOWED_VIDEO_TYPES: readonly string[] = ["video/mp4", "video/quicktime", "video/webm"];
/** Upper bound on an uploaded video, enforced before any feed call. */
export const MAX_VIDEO_BYTES = 200 * 1024 * 1024;
/** Upper bound on an uploaded image, enforced before any feed call. */
export const MAX_IMAGE_BYTES = 15 * 1024 * 1024;
export type CreateMediaPostResult =
| { ok: true; id: string; status: string | null }
| {
ok: false;
status: 400 | 401 | 502;
error: "sign_in" | "too_long" | "bad_file" | "post_failed";
};
export interface MediaUpload {
/** The file to upload. */
file: { name: string; type: string; size: number; arrayBuffer: () => Promise<ArrayBuffer> };
/** Draft type and confirm mediaType. */
mediaType: MediaKind;
}
type Fetch = typeof fetch;
function rec(v: unknown): Record<string, unknown> {
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
}
function str(v: unknown): string | undefined {
return typeof v === "string" && v.length > 0 ? v : undefined;
}
function readCreated(body: unknown): { id: string; status: string | null } | null {
const root = rec(body);
const data = rec(root.data);
const post = rec(data.post);
for (const v of [data.id, post.id, root.id]) {
if ((typeof v === "string" && v) || typeof v === "number") {
const status = [data.status, post.status, root.status].find((s) => typeof s === "string");
return { id: String(v), status: (status as string | undefined) ?? null };
}
}
return null;
}
/** Tolerant reader for the presigned-urls response (field names are not pinned by the spec). */
function readPresigned(
body: unknown,
): { uploadUrl: string; attachmentId: string; s3Key: string } | null {
const root = rec(body);
const data = root.data;
// The entry may be the first element of an array, the first element of a
// named list, or the data object itself.
const candidates: unknown[] = [];
if (Array.isArray(data)) candidates.push(...data);
else if (Array.isArray(body)) candidates.push(...(body as unknown[]));
else {
const d = rec(data);
for (const key of ["files", "urls", "attachments", "presignedUrls"]) {
if (Array.isArray(d[key])) candidates.push(...(d[key] as unknown[]));
}
candidates.push(d);
}
for (const c of candidates) {
const e = rec(c);
const uploadUrl =
str(e.uploadUrl) ?? str(e.url) ?? str(e.presignedUrl) ?? str(e.signedUrl) ?? str(e.putUrl);
const attachmentId = str(e.attachmentId) ?? str(e.id);
const s3Key = str(e.s3Key) ?? str(e.key) ?? str(e.objectKey);
if (uploadUrl && attachmentId && s3Key) return { uploadUrl, attachmentId, s3Key };
}
return null;
}
function sendJson(token: string, path: string, payload: unknown, doFetch: Fetch) {
return doFetch(`${FEED_BASE_URL}${path}`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
});
}
/** True when the type is an accepted image content type (case-insensitive). */
export function isAllowedImageType(type: string): boolean {
return ALLOWED_IMAGE_TYPES.includes(type.trim().toLowerCase());
}
/** True when the type is an accepted video content type (case-insensitive). */
export function isAllowedVideoType(type: string): boolean {
return ALLOWED_VIDEO_TYPES.includes(type.trim().toLowerCase());
}
/**
* Create a public video post: same pipeline as an image with type "video".
* Video confirmation may return while transcoding continues; the post is
* published anyway and the card shows the thumbnail until hlsUrl appears.
*/
export async function createVideoPost(
sessionId: string | undefined,
content: unknown,
file: MediaUpload["file"] | null,
options: RefreshOptions = {},
): Promise<CreateMediaPostResult> {
if (!file || typeof file.size !== "number" || file.size <= 0 || file.size > MAX_VIDEO_BYTES) {
return { ok: false, status: 400, error: "bad_file" };
}
if (typeof file.type !== "string" || !isAllowedVideoType(file.type)) {
return { ok: false, status: 400, error: "bad_file" };
}
return runMediaPipeline(sessionId, content, { file, mediaType: "video" }, options);
}
/** Route entry point: picks image or video from the file type; anything else is a 400. */
export async function createMediaPost(
sessionId: string | undefined,
content: unknown,
file: MediaUpload["file"] | null,
options: RefreshOptions = {},
): Promise<CreateMediaPostResult> {
if (file && typeof file.type === "string" && isAllowedVideoType(file.type)) {
return createVideoPost(sessionId, content, file, options);
}
return createImagePost(sessionId, content, file, options);
}
/**
* Create a public image post from a caption and one file: draft -> presigned URL
* -> server-side PUT -> confirm -> publish. Returns the post id so the caller can
* confirm it on the reloaded first page. The video task reuses `runMediaPipeline`
* with `mediaType: "video"` and its own type gate.
*/
export async function createImagePost(
sessionId: string | undefined,
content: unknown,
file: MediaUpload["file"] | null,
options: RefreshOptions = {},
): Promise<CreateMediaPostResult> {
if (!file || typeof file.size !== "number" || file.size <= 0 || file.size > MAX_IMAGE_BYTES) {
return { ok: false, status: 400, error: "bad_file" };
}
if (typeof file.type !== "string" || !isAllowedImageType(file.type)) {
return { ok: false, status: 400, error: "bad_file" };
}
return runMediaPipeline(sessionId, content, { file, mediaType: "image" }, options);
}
/**
* Media-type agnostic upload-and-publish pipeline. Callers validate the file
* first (type, size) for their media kind. A caption may be empty for a media
* post; only an over-length caption is rejected.
*/
export async function runMediaPipeline(
sessionId: string | undefined,
content: unknown,
upload: MediaUpload,
options: RefreshOptions = {},
): Promise<CreateMediaPostResult> {
const signIn = { ok: false, status: 401, error: "sign_in" } as const;
const failed = { ok: false, status: 502, error: "post_failed" } as const;
const doFetch = options.fetchImpl ?? fetch;
const { file, mediaType } = upload;
let session: SessionData | null = await ensureFreshSession(sessionId, options);
if (!session || !sessionId) return signIn;
const caption = typeof content === "string" ? content : "";
if (caption.length > MAX_POST_LENGTH) {
return { ok: false, status: 400, error: "too_long" };
}
// One feed call with a single refresh-and-retry on a 401 token rejection.
const call = async (path: string, payload: unknown): Promise<Response | "sign_in"> => {
let res = await sendJson(session!.feedAccessToken, path, payload, doFetch);
if (res.status === 401) {
session = await refreshSession(sessionId, options);
if (!session) return "sign_in";
res = await sendJson(session.feedAccessToken, path, payload, doFetch);
if (res.status === 401) return "sign_in";
}
return res;
};
try {
// 1. Draft.
const created = await call("/api/v1/posts", {
content: caption,
type: mediaType,
status: "draft",
visibility: "public",
});
if (created === "sign_in") return signIn;
if (created.status !== 200 && created.status !== 201) return failed;
const info = readCreated(await created.json().catch(() => null));
if (!info) return failed;
// 2. Presigned URL for the one file.
const presignedRes = await call("/api/v1/attachments/presigned-urls", {
postId: info.id,
files: [{ name: file.name, type: file.type, size: file.size }],
});
if (presignedRes === "sign_in") return signIn;
if (presignedRes.status < 200 || presignedRes.status > 299) return failed;
const slot = readPresigned(await presignedRes.json().catch(() => null));
if (!slot) return failed;
// 3. The server PUTs the bytes. No Authorization header: the presigned URL
// is pre-authorized, and the feed token must never reach S3 or the browser.
const bytes = await file.arrayBuffer();
const put = await doFetch(slot.uploadUrl, {
method: "PUT",
headers: { "Content-Type": file.type },
body: bytes,
});
if (!put.ok) return failed;
// 4. Confirm (image confirmation blocks until processing finishes).
const confirmed = await call("/api/v1/attachments/confirm-uploads", {
postId: info.id,
uploads: [
{
attachmentId: slot.attachmentId,
s3Key: slot.s3Key,
mediaType,
filename: file.name,
},
],
});
if (confirmed === "sign_in") return signIn;
if (confirmed.status < 200 || confirmed.status > 299) return failed;
// 5. Publish, same call as a text post.
const published = await call(`/api/v1/posts/${encodeURIComponent(info.id)}/publish`, {
content: caption,
});
if (published === "sign_in") return signIn;
if (published.status < 200 || published.status > 299) return failed;
return { ok: true, id: info.id, status: info.status };
} catch {
return failed;
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ import { exchangeFeedToken, refreshProfileTokens, revokeToken } from "./minnect"
/** Refresh when a token expires within this window (spec: two minutes). */
export const REFRESH_WINDOW_MS = 2 * 60 * 1000;
interface RefreshOptions {
export interface RefreshOptions {
/** Injectable fetch, for testing. Defaults to the global fetch. */
fetchImpl?: typeof fetch;
/** Injectable clock for the expiry decision, for testing. Defaults to Date.now(). */
+2
View File
@@ -12,6 +12,8 @@ export interface SessionPerson {
name: string;
email: string;
pictureUrl: string | null;
/** The person's id on the feed (feed GET /api/v1/auth/me data.id); null when unknown. */
feedUserId?: string | null;
}
export interface SessionData {
+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.");
});
});
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { NextRequest } from "next/server";
import { DELETE } from "@/app/api/posts/[id]/route";
import { GET as getSession } from "@/app/api/session/route";
import { FeedView } from "@/components/FeedView";
import { toCardPost, toCardPosts, canDelete } from "@/lib/feed-model";
import { DeleteError, deleteAndRemove, sendDelete } from "@/lib/delete-client";
import { loginToMinnect } from "@/lib/minnect";
import { createSession } from "@/lib/session-store";
const ctx = (id: string) => ({ params: Promise.resolve({ id }) });
const item = (id: string, authorId: string) => ({
type: "post", publishedAt: "2026-10-19T10:00:00Z",
post: { id, content: `text ${id}`, author: { id: authorId, displayName: "X" } },
});
const posts = () => toCardPosts([item("mine", "F1"), item("theirs", "F2")]);
function view(feedUserId: string | null) {
return renderToStaticMarkup(createElement(FeedView, {
posts: posts(), status: "ready", loadingMore: false, loadMoreFailed: false, newCount: 0,
onRetry: () => {}, onShowNew: () => {}, feedUserId, onDelete: () => {},
}));
}
function session(feedUserId: string | null = "F1") {
return createSession({
profileAccessToken: "p", profileAccessTokenExpiresAt: null, profileRefreshToken: "r",
feedAccessToken: "T", feedAccessTokenExpiresAt: null,
person: { userId: "u", name: "A", email: "a@b.co", pictureUrl: null, feedUserId }, 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; }
}
describe("delete control visibility", () => {
it("own post has the menu; someone else's has none", () => {
const html = view("F1");
expect(html.match(/aria-label="More actions"/g)).toHaveLength(1);
const cards = html.split('data-testid="post-card"');
expect(cards[1]).toContain("More actions");
expect(cards[2]).not.toContain("More actions");
});
it("no feed user id means no delete control anywhere", () => {
expect(view(null)).not.toContain("More actions");
expect(view("F9")).not.toContain("More actions");
expect(canDelete(toCardPost(item("a", "F1"))!, undefined)).toBe(false);
});
});
describe("DELETE /api/posts/:id", () => {
it("401 sign_in without a session", async () => {
const res = await DELETE(new NextRequest("http://localhost/api/posts/p1", { method: "DELETE" }), ctx("p1"));
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "sign_in" });
});
it("calls the feed DELETE and returns ok on 200", async () => {
const f = vi.fn(async () => new Response("{}", { status: 200 }));
const res = await withFetch(f as unknown as typeof fetch, () =>
DELETE(new NextRequest("http://localhost/x", { method: "DELETE", headers: { cookie: `mfb_session=${session()}` } }), ctx("p1")));
expect(res.status).toBe(200);
const [url, init] = f.mock.calls[0] as unknown as [string, RequestInit];
expect(new URL(url).pathname).toBe("/api/v1/posts/p1");
expect(init.method).toBe("DELETE");
});
it("a feed failure is 502 delete_failed; a bad id never reaches the feed", async () => {
const h = { cookie: `mfb_session=${session()}` };
const bad = vi.fn(async () => new Response("{}", { status: 500 }));
const r = await withFetch(bad as unknown as typeof fetch, () =>
DELETE(new NextRequest("http://localhost/x", { method: "DELETE", headers: h }), ctx("p1")));
expect([r.status, await r.json()]).toEqual([502, { error: "delete_failed" }]);
const f = vi.fn();
const r2 = await withFetch(f as unknown as typeof fetch, () =>
DELETE(new NextRequest("http://localhost/x", { method: "DELETE", headers: h }), ctx("../admin/x")));
expect(r2.status).toBe(400);
expect(f).not.toHaveBeenCalled();
});
});
describe("removing the card", () => {
it("the card leaves the list after a 200", async () => {
const ok = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })) as unknown as typeof fetch;
const list = posts();
const next = await deleteAndRemove(list, list[0], (id) => sendDelete(id, ok));
expect(next.map((p) => p.id)).toEqual(["theirs"]);
});
it("a failed delete throws and the caller keeps the card", async () => {
const fail = vi.fn(async () => new Response("{}", { status: 502 })) as unknown as typeof fetch;
const list = posts();
await expect(deleteAndRemove(list, list[0], (id) => sendDelete(id, fail))).rejects.toBeInstanceOf(DeleteError);
expect(list).toHaveLength(2);
const out = vi.fn(async () => new Response("{}", { status: 401 })) as unknown as typeof fetch;
await expect(sendDelete("mine", out)).rejects.toMatchObject({ signedOut: true });
});
});
describe("feed user id", () => {
it("GET /api/session exposes feedUserId and no token", async () => {
const res = await getSession(new NextRequest("http://localhost/api/session", { headers: { cookie: `mfb_session=${session("F1")}` } }));
const body = await res.json();
expect(body).toEqual({ name: "A", email: "a@b.co", pictureUrl: null, feedUserId: "F1" });
});
it("sign-in captures data.id from the feed auth/me", async () => {
const f = vi.fn(async (url: string) => {
const u = String(url);
if (u.endsWith("/api/v4/auth/login")) return new Response(JSON.stringify({ data: { accessToken: "a", refreshToken: "r", expiresIn: 3600 } }), { status: 200 });
if (u.endsWith("/api/v4/auth/me")) return new Response(JSON.stringify({ data: { userId: "u1", email: "a@b.co", firstName: "A" } }), { status: 200 });
if (u.endsWith("/external-services/feed")) return new Response(JSON.stringify({ accessToken: "ft" }), { status: 200 });
if (u.endsWith("/api/v1/auth/me")) return new Response(JSON.stringify({ data: { id: "F1" } }), { status: 200 });
return new Response("{}", { status: 404 });
}) as unknown as typeof fetch;
const r = await loginToMinnect("a@b.co", "pw", { fetchImpl: f });
expect(r.person.feedUserId).toBe("F1");
const down = vi.fn(async (url: string) => String(url).endsWith("/api/v1/auth/me") ? new Response("{}", { status: 500 }) : (f as unknown as (u: string) => Promise<Response>)(url)) as unknown as typeof fetch;
expect((await loginToMinnect("a@b.co", "pw", { fetchImpl: down })).person.feedUserId).toBeNull();
});
});
+33 -1
View File
@@ -3,7 +3,7 @@ import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { FeedView, type FeedViewProps } from "@/components/FeedView";
import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client";
import { mergePosts, relativeTime, toCardPosts } from "@/lib/feed-model";
import { mergePosts, relativeTime, toCardPosts, upgradeVideos } from "@/lib/feed-model";
const NOW = Date.parse("2026-10-19T12:00:00Z");
const post = (id: string, publishedAt: string, extra: Record<string, unknown> = {}) => ({
@@ -137,3 +137,35 @@ describe("fetchFeedPage", () => {
await expect(fetchFeedPage(null, mk(500))).rejects.toMatchObject({ signedOut: false });
});
});
describe("video transcoding", () => {
const vid = (hls: unknown) =>
toCardPosts([post("7", "2026-10-19T10:00:00Z", { mediaItems: [{ mediaType: "video", thumbnailUrl: "https://i/t.jpg", hlsUrl: hls }] })]);
it("keeps a just-published video as a thumbnail with no hlsUrl", () => {
const [p] = vid(null);
expect(p.media).toEqual([{ kind: "video", src: "https://i/t.jpg", hlsUrl: null }]);
});
it("keeps a video with neither thumbnail nor hlsUrl as a pending card", () => {
const [p] = toCardPosts([post("8", "2026-10-19T10:00:00Z", { mediaItems: [{ mediaType: "video" }] })]);
expect(p.media).toHaveLength(1);
expect(p.media[0].src).toBeNull();
});
it("upgrades the shown thumbnail to the player when hlsUrl appears", () => {
const shown = vid(null);
const next = upgradeVideos(shown, vid("https://stream.mux.com/abc"));
expect(next[0].media[0].hlsUrl).toContain("stream.mux.com");
expect(upgradeVideos(next, vid("https://stream.mux.com/abc"))).toBe(next);
expect(upgradeVideos(shown, vid(null))).toBe(shown);
});
it("renders the thumbnail while transcoding and the player once hlsUrl is present", () => {
const html = render({ posts: vid(null) });
expect(html).toContain("https://i/t.jpg");
expect(html).not.toContain("<video");
const live = render({ posts: vid("https://stream.mux.com/abc") });
expect(live).toContain("<video");
});
});
+160
View File
@@ -0,0 +1,160 @@
import { describe, expect, it, vi } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { NextRequest } from "next/server";
import { DELETE, POST } from "@/app/api/posts/[id]/like/route";
import { PostCard } from "@/components/PostCard";
import { toCardPost, type CardPost } from "@/lib/feed-model";
import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client";
import { createSession } from "@/lib/session-store";
const ctx = (id: string) => ({ params: Promise.resolve({ id }) });
const card = (extra: Partial<CardPost> = {}): CardPost => ({
...toCardPost({ type: "post", publishedAt: "2026-10-19T10:00:00Z", likesCount: 3, post: { id: "p1", content: "hi", author: {}, isLiked: false } })!,
...extra,
});
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(),
});
}
/** A fake feed that keeps real like state: a second like never raises the count. */
function fakeFeed(withCount: boolean) {
const state = { liked: false, count: 3, calls: [] as string[] };
const fn = vi.fn(async (url: string, init?: RequestInit) => {
state.calls.push(`${init?.method} ${new URL(String(url), "http://x").pathname}`);
if (init?.method === "POST" && !state.liked) { state.liked = true; state.count += 1; }
if (init?.method === "DELETE" && state.liked) { state.liked = false; state.count -= 1; }
const data: Record<string, unknown> = { success: true };
if (withCount) data.likesCount = state.count;
return new Response(JSON.stringify({ data }), { status: 200 });
});
return { state, fn };
}
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; }
}
describe("like routes", () => {
it("401 sign_in without a session", async () => {
for (const h of [POST, DELETE]) {
const res = await h(new NextRequest("http://localhost/api/posts/p1/like", { method: "POST" }), ctx("p1"));
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "sign_in" });
}
});
it("like then unlike hit the matching feed routes and return server state", async () => {
const sid = session();
const feed = fakeFeed(true);
const h = { cookie: `mfb_session=${sid}` };
await withFetch(feed.fn as unknown as typeof fetch, async () => {
const a = await POST(new NextRequest("http://localhost/api/posts/p1/like", { method: "POST", headers: h }), ctx("p1"));
expect(await a.json()).toEqual({ isLiked: true, likesCount: 4 });
const b = await DELETE(new NextRequest("http://localhost/api/posts/p1/like", { method: "DELETE", headers: h }), ctx("p1"));
expect(await b.json()).toEqual({ isLiked: false, likesCount: 3 });
});
expect(feed.state.calls).toEqual(["POST /api/v1/posts/p1/like", "DELETE /api/v1/posts/p1/like"]);
});
it("a second like does not raise the count and is not retried", async () => {
const sid = session();
const feed = fakeFeed(true);
const h = { cookie: `mfb_session=${sid}` };
await withFetch(feed.fn as unknown as typeof fetch, async () => {
await POST(new NextRequest("http://localhost/x", { method: "POST", headers: h }), ctx("p1"));
const r = await POST(new NextRequest("http://localhost/x", { method: "POST", headers: h }), ctx("p1"));
expect(await r.json()).toEqual({ isLiked: true, likesCount: 4 });
});
expect(feed.fn).toHaveBeenCalledTimes(2);
});
it("returns likesCount null when the feed carries none; failure is 502 like_failed", async () => {
const sid = session();
const h = { cookie: `mfb_session=${sid}` };
await withFetch(fakeFeed(false).fn as unknown as typeof fetch, async () => {
const r = await POST(new NextRequest("http://localhost/x", { method: "POST", headers: h }), ctx("p1"));
expect(await r.json()).toEqual({ isLiked: true, likesCount: null });
});
const bad = vi.fn(async () => new Response("{}", { status: 500 })) as unknown as typeof fetch;
await withFetch(bad, async () => {
const r = await POST(new NextRequest("http://localhost/x", { method: "POST", headers: h }), ctx("p1"));
expect(r.status).toBe(502);
expect(await r.json()).toEqual({ error: "like_failed" });
});
const notOk = vi.fn(async () => new Response(JSON.stringify({ data: { success: false } }), { status: 200 })) as unknown as typeof fetch;
await withFetch(notOk, async () => {
const r = await DELETE(new NextRequest("http://localhost/x", { method: "DELETE", headers: h }), ctx("p1"));
expect(r.status).toBe(502);
});
});
it("rejects a post id that is not a plain id", async () => {
const sid = session();
const f = vi.fn();
await withFetch(f as unknown as typeof fetch, async () => {
const r = await POST(new NextRequest("http://localhost/x", { method: "POST", headers: { cookie: `mfb_session=${sid}` } }), ctx("../admin/x"));
expect(r.status).toBe(400);
});
expect(f).not.toHaveBeenCalled();
});
});
describe("card like state", () => {
it("reads isLiked from the post", () => {
const c = toCardPost({ type: "post", post: { id: "1", isLiked: true, author: {} } });
expect(c?.isLiked).toBe(true);
});
it("like then unlike through the client matches server state", async () => {
const feed = fakeFeed(true);
const proxy = vi.fn(async (url: string, init?: RequestInit) => {
const r = await feed.fn(url, init);
const b = (await r.json()) as { data: { likesCount: number } };
return new Response(JSON.stringify({ isLiked: init?.method === "POST", likesCount: b.data.likesCount }), { status: 200 });
}) as unknown as typeof fetch;
let c = card();
c = applyLikeOutcome(c, true, await sendLike("p1", true, proxy));
expect([c.isLiked, c.likesCount]).toEqual([true, 4]);
c = applyLikeOutcome(c, false, await sendLike("p1", false, proxy));
expect([c.isLiked, c.likesCount]).toEqual([false, 3]);
});
it("without a server count, flips and adjusts by one; a repeat does not double count", () => {
let c = card();
c = applyLikeOutcome(c, true, { isLiked: true, likesCount: null });
expect([c.isLiked, c.likesCount]).toEqual([true, 4]);
c = applyLikeOutcome(c, true, { isLiked: true, likesCount: null });
expect(c.likesCount).toBe(4);
c = applyLikeOutcome(c, false, { isLiked: false, likesCount: null });
expect([c.isLiked, c.likesCount]).toEqual([false, 3]);
});
it("a failed call throws and the previous card is untouched", async () => {
const before = card();
const f = vi.fn(async () => new Response("{}", { status: 502 })) as unknown as typeof fetch;
await expect(sendLike("p1", true, f)).rejects.toBeInstanceOf(LikeError);
expect(before.isLiked).toBe(false);
expect(before.likesCount).toBe(3);
const signedOut = vi.fn(async () => new Response("{}", { status: 401 })) as unknown as typeof fetch;
await expect(sendLike("p1", true, signedOut)).rejects.toMatchObject({ signedOut: true });
});
it("renders outlined vs filled heart, pressed state, and the action error", () => {
const off = renderToStaticMarkup(createElement(PostCard, { post: card(), onToggleLike: () => {} }));
expect(off).toContain('aria-pressed="false"');
expect(off).toContain('fill="none"');
expect(off).not.toContain("Could not update like.");
const on = renderToStaticMarkup(createElement(PostCard, { post: card({ isLiked: true }), onToggleLike: () => {}, likeError: true }));
expect(on).toContain('aria-pressed="true"');
expect(on).toContain('fill="currentColor"');
expect(on).toContain("Could not update like.");
});
});
+232
View File
@@ -0,0 +1,232 @@
import { describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { POST } from "@/app/api/posts/route";
import { createImagePost, createVideoPost } from "@/lib/post-media";
import { createSession } from "@/lib/session-store";
// Server-side image-post pipeline, with a fully mocked feed. The browser sends
// multipart content + one file to POST /api/posts; the server runs
// draft -> presigned -> PUT -> confirm -> publish and returns only { 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(),
});
}
function jsonRes(body: unknown, status: number) {
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
}
function multipart(content: string, file: File | null, sid?: string) {
const form = new FormData();
form.append("content", content);
if (file) form.append("file", file, file.name);
return new NextRequest("http://localhost/api/posts", {
method: "POST",
headers: sid ? { cookie: `mfb_session=${sid}` } : {},
body: form,
});
}
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 imageFile = (name = "photo.jpg", type = "image/jpeg") =>
new File([new Uint8Array([1, 2, 3])], name, { type });
describe("POST /api/posts (image)", () => {
it("runs draft -> presigned -> PUT -> confirm -> publish and returns only the id", async () => {
const sid = session();
const calls: { url: string; method: string; auth: string | null }[] = [];
const bodies: Record<string, unknown> = {};
const fn = vi.fn(async (url: string | URL, init?: RequestInit) => {
const u = String(url);
calls.push({ url: u, method: String(init?.method ?? "GET"), auth: new Headers(init?.headers).get("authorization") });
if (u.includes("/api/v1/") && typeof init?.body === "string") {
bodies[new URL(u).pathname] = JSON.parse(init.body);
}
if (u.endsWith("/api/v1/posts")) return jsonRes({ data: { id: "p9", status: "draft" } }, 201);
if (u.endsWith("/attachments/presigned-urls"))
return jsonRes({ data: [{ attachmentId: "a1", s3Key: "k/1.jpg", url: "https://s3.example/put?sig=1" }] }, 200);
if (u === "https://s3.example/put?sig=1") return new Response(null, { status: 200 });
if (u.endsWith("/attachments/confirm-uploads")) return jsonRes({ data: { success: true } }, 200);
if (u.includes("/publish")) return jsonRes({ data: { success: true } }, 200);
return new Response("{}", { status: 404 });
});
const res = await withFetch(fn as unknown as typeof fetch, () =>
POST(multipart("look at this", imageFile(), sid)),
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ id: "p9" });
const feedPaths = calls.filter((c) => c.url.includes("/api/v1/")).map((c) => new URL(c.url).pathname);
expect(feedPaths).toEqual([
"/api/v1/posts",
"/api/v1/attachments/presigned-urls",
"/api/v1/attachments/confirm-uploads",
"/api/v1/posts/p9/publish",
]);
expect(bodies["/api/v1/posts"]).toEqual({
content: "look at this",
type: "image",
status: "draft",
visibility: "public",
});
expect(bodies["/api/v1/attachments/presigned-urls"]).toEqual({
postId: "p9",
files: [{ name: "photo.jpg", type: "image/jpeg", size: 3 }],
});
expect(bodies["/api/v1/attachments/confirm-uploads"]).toEqual({
postId: "p9",
uploads: [{ attachmentId: "a1", s3Key: "k/1.jpg", mediaType: "image", filename: "photo.jpg" }],
});
expect(bodies["/api/v1/posts/p9/publish"]).toEqual({ content: "look at this" });
// The server PUTs the bytes with no token; every feed call carries Bearer T.
const put = calls.find((c) => c.method === "PUT");
expect(put?.url).toBe("https://s3.example/put?sig=1");
expect(put?.auth).toBeNull();
expect(calls.filter((c) => c.url.includes("/api/v1/")).every((c) => c.auth === "Bearer T")).toBe(true);
});
it("never returns the feed token to the browser", async () => {
const sid = session();
const fn = vi.fn(async (url: string | URL) => {
const u = String(url);
if (u.endsWith("/api/v1/posts")) return jsonRes({ data: { id: "p9" } }, 201);
if (u.endsWith("/attachments/presigned-urls"))
return jsonRes({ data: [{ attachmentId: "a1", s3Key: "k", url: "https://s3.example/put" }] }, 200);
if (u === "https://s3.example/put") return new Response(null, { status: 200 });
return jsonRes({ data: { success: true } }, 200);
});
const res = await withFetch(fn as unknown as typeof fetch, () => POST(multipart("hi", imageFile(), sid)));
const text = await res.text();
expect(text).toBe(JSON.stringify({ id: "p9" }));
expect(text).not.toContain("T");
});
it("rejects a disallowed type with 400 bad_file and makes no feed call", async () => {
const sid = session();
const fn = vi.fn();
const res = await withFetch(fn as unknown as typeof fetch, () =>
POST(multipart("x", imageFile("doc.pdf", "application/pdf"), sid)),
);
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "bad_file" });
expect(fn).not.toHaveBeenCalled();
});
it("rejects a missing file with 400 bad_file", async () => {
const sid = session();
const fn = vi.fn();
const res = await withFetch(fn as unknown as typeof fetch, () => POST(multipart("x", null, sid)));
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "bad_file" });
expect(fn).not.toHaveBeenCalled();
});
it("401 sign_in for a multipart post without a session", async () => {
const fn = vi.fn();
const res = await withFetch(fn as unknown as typeof fetch, () => POST(multipart("x", imageFile())));
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "sign_in" });
expect(fn).not.toHaveBeenCalled();
});
it("502 post_failed when the server upload PUT fails after the draft was created", async () => {
const sid = session();
const fn = vi.fn(async (url: string | URL) => {
const u = String(url);
if (u.endsWith("/api/v1/posts")) return jsonRes({ data: { id: "p9" } }, 201);
if (u.endsWith("/attachments/presigned-urls"))
return jsonRes({ data: [{ attachmentId: "a1", s3Key: "k", url: "https://s3.example/put" }] }, 200);
if (u === "https://s3.example/put") return new Response("denied", { status: 403 });
return jsonRes({ data: { success: true } }, 200);
});
const res = await withFetch(fn as unknown as typeof fetch, () => POST(multipart("x", imageFile(), sid)));
expect(res.status).toBe(502);
expect(await res.json()).toEqual({ error: "post_failed" });
});
it("accepts every allowed image type and rejects others (unit)", async () => {
const sid = session();
const ok = vi.fn(async (url: string | URL) => {
const u = String(url);
if (u.endsWith("/api/v1/posts")) return jsonRes({ data: { id: "p9" } }, 201);
if (u.endsWith("/attachments/presigned-urls"))
return jsonRes({ data: [{ attachmentId: "a1", s3Key: "k", url: "https://s3.example/put" }] }, 200);
if (u === "https://s3.example/put") return new Response(null, { status: 200 });
return jsonRes({ data: { success: true } }, 200);
}) as unknown as typeof fetch;
for (const type of ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp", "image/heic"]) {
const r = await createImagePost(sid, "c", imageFile(`f.${type.split("/")[1]}`, type), { fetchImpl: ok });
expect(r.ok).toBe(true);
}
const bad = await createImagePost(sid, "c", imageFile("a.svg", "image/svg+xml"), {
fetchImpl: (() => {
throw new Error("should not be called");
}) as unknown as typeof fetch,
});
expect(bad).toEqual({ ok: false, status: 400, error: "bad_file" });
});
});
describe("POST /api/posts (video)", () => {
const videoFile = (name = "clip.mp4", type = "video/mp4") =>
new File([new Uint8Array([1, 2, 3])], name, { type });
function videoFeed(bodies: Record<string, unknown>, calls: string[]) {
return vi.fn(async (url: string | URL, init?: RequestInit) => {
const u = String(url);
calls.push(u);
if (u.includes("/api/v1/") && typeof init?.body === "string") bodies[new URL(u).pathname] = JSON.parse(init.body);
if (u.endsWith("/api/v1/posts")) return jsonRes({ data: { id: "v1", status: "draft" } }, 201);
if (u.endsWith("/attachments/presigned-urls"))
return jsonRes({ data: [{ attachmentId: "a1", s3Key: "k/1.mp4", url: "https://s3.example/put" }] }, 200);
if (u === "https://s3.example/put") return new Response(null, { status: 200 });
// Confirmation returns while transcoding continues: no hlsUrl.
if (u.endsWith("/confirm-uploads")) return jsonRes({ data: { status: "processing" } }, 200);
return jsonRes({ data: { success: true } }, 200);
});
}
it("uses draft type video, confirms mediaType video, and publishes without hlsUrl", async () => {
const sid = session();
const bodies: Record<string, unknown> = {};
const calls: string[] = [];
const fn = videoFeed(bodies, calls);
const res = await withFetch(fn as unknown as typeof fetch, () => POST(multipart("hi", videoFile("c.mov", "video/quicktime"), sid)));
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ id: "v1" });
expect((bodies["/api/v1/posts"] as { type: string }).type).toBe("video");
const uploads = (bodies["/api/v1/attachments/confirm-uploads"] as { uploads: { mediaType: string }[] }).uploads;
expect(uploads[0].mediaType).toBe("video");
expect(calls.some((u) => u.endsWith("/api/v1/posts/v1/publish"))).toBe(true);
});
it("accepts mp4, quicktime and webm; rejects other video types before any feed call", async () => {
const sid = session();
const ok = videoFeed({}, []) as unknown as typeof fetch;
for (const t of ["video/mp4", "video/quicktime", "video/webm"]) {
expect((await createVideoPost(sid, "c", videoFile("f", t), { fetchImpl: ok })).ok).toBe(true);
}
const fn = vi.fn();
const res = await withFetch(fn as unknown as typeof fetch, () =>
POST(multipart("x", videoFile("a.avi", "video/x-msvideo"), sid)),
);
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "bad_file" });
expect(fn).not.toHaveBeenCalled();
});
});
+172
View File
@@ -0,0 +1,172 @@
import { describe, expect, it, vi } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { NextRequest } from "next/server";
import { POST } from "@/app/api/posts/route";
import { Composer } from "@/components/Composer";
import { FeedView } from "@/components/FeedView";
import { PostError, publishAndConfirm, sendPost } from "@/lib/post-client";
import { FeedLoadError } from "@/lib/feed-client";
import { createSession } from "@/lib/session-store";
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(),
});
}
const req = (body: unknown, sid?: string) =>
new NextRequest("http://localhost/api/posts", {
method: "POST",
headers: sid ? { cookie: `mfb_session=${sid}`, "content-type": "application/json" } : { "content-type": "application/json" },
body: JSON.stringify(body),
});
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 item = (id: string) => ({ type: "post", publishedAt: "2026-10-19T10:00:00Z", post: { id, content: "x", author: {} } });
describe("POST /api/posts", () => {
it("401 sign_in without a session", async () => {
const res = await POST(req({ content: "hi" }));
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "sign_in" });
});
it("creates a public text draft, then publishes it with the same content", async () => {
const sid = session();
const calls: { path: string; body: unknown; auth: string | null }[] = [];
const fn = vi.fn(async (url: string, init?: RequestInit) => {
calls.push({
path: new URL(String(url)).pathname,
body: JSON.parse(String(init?.body)),
auth: new Headers(init?.headers).get("authorization"),
});
return new Response(JSON.stringify({ data: { id: "p9", status: "draft" } }), { status: 201 });
});
const res = await withFetch(fn as unknown as typeof fetch, () => POST(req({ content: "hello" }, sid)));
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ id: "p9" });
expect(calls.map((c) => c.path)).toEqual(["/api/v1/posts", "/api/v1/posts/p9/publish"]);
expect(calls[0].body).toEqual({ content: "hello", type: "text", status: "draft", visibility: "public" });
expect(calls[1].body).toEqual({ content: "hello" });
expect(calls.every((c) => c.auth === "Bearer T")).toBe(true);
});
it("rejects whitespace-only content without calling the feed", async () => {
const sid = session();
const fn = vi.fn();
const res = await withFetch(fn as unknown as typeof fetch, () => POST(req({ content: " \n " }, sid)));
expect(res.status).toBe(400);
expect(fn).not.toHaveBeenCalled();
});
it("502 post_failed when publish fails", async () => {
const sid = session();
let n = 0;
const fn = vi.fn(async () =>
n++ === 0 ? new Response(JSON.stringify({ data: { id: "p9" } }), { status: 201 }) : new Response("{}", { status: 500 }),
);
const res = await withFetch(fn as unknown as typeof fetch, () => POST(req({ content: "hello" }, sid)));
expect(res.status).toBe(502);
expect(await res.json()).toEqual({ error: "post_failed" });
});
});
describe("publishAndConfirm", () => {
it("returns the reloaded page only when the id is on the first page", async () => {
const state = { id: null as string | null };
const page = { items: [item("p9"), item("p1")], nextCursor: null };
const got = await publishAndConfirm("hi", null, state, { send: async () => "p9", fetchPage: async () => page });
expect(got).toBe(page);
expect(state.id).toBeNull();
});
const nap = { delayMs: 0, sleep: async () => {} };
it("retries the reload and succeeds once a slow post surfaces", async () => {
const state = { id: null as string | null };
const send = vi.fn(async () => "p9");
let n = 0;
const fetchPage = vi.fn(async () =>
n++ < 2 ? { items: [item("p1")], nextCursor: null } : { items: [item("p9"), item("p1")], nextCursor: null },
);
const got = await publishAndConfirm("hi", null, state, { send, fetchPage, confirm: nap });
expect(got.items.map((i) => (i as { post: { id: string } }).post.id)).toContain("p9");
expect(state.id).toBeNull();
expect(send).toHaveBeenCalledTimes(1);
expect(n).toBe(3);
});
it("does not throw or duplicate when a published post never surfaces (feed lag)", async () => {
const state = { id: null as string | null };
const send = vi.fn(async () => "p9");
const miss = vi.fn(async () => ({ items: [item("p1")], nextCursor: null }));
const got = await publishAndConfirm("hi", null, state, { send, fetchPage: miss, confirm: { ...nap, attempts: 3 } });
expect(got).toBeTruthy();
expect(state.id).toBeNull();
expect(send).toHaveBeenCalledTimes(1);
});
it("keeps the id when the reload itself fails, so a retry re-checks without creating again", async () => {
const state = { id: null as string | null };
const send = vi.fn(async () => "p9");
const boom = async () => {
throw new FeedLoadError(false);
};
await expect(publishAndConfirm("hi", null, state, { send, fetchPage: boom, confirm: nap })).rejects.toBeInstanceOf(PostError);
expect(state.id).toBe("p9");
const hit = async () => ({ items: [item("p9")], nextCursor: null });
await publishAndConfirm("hi", null, state, { send, fetchPage: hit, confirm: nap });
expect(send).toHaveBeenCalledTimes(1);
});
it("sendPost maps 401 to signed out and other failures to publish_failed", async () => {
const f = (status: number) => (async () => new Response("{}", { status })) as unknown as typeof fetch;
await expect(sendPost("x", null, f(401))).rejects.toMatchObject({ signedOut: true });
await expect(sendPost("x", null, f(500))).rejects.toMatchObject({ signedOut: false });
});
});
describe("Composer", () => {
const base = { value: "", busy: false, error: false, onChange: () => {}, onSubmit: () => {} };
it("disables Post for empty or whitespace-only text", () => {
expect(renderToStaticMarkup(createElement(Composer, base))).toMatch(/<button[^>]*disabled/);
expect(renderToStaticMarkup(createElement(Composer, { ...base, value: " " }))).toMatch(/<button[^>]*disabled/);
expect(renderToStaticMarkup(createElement(Composer, { ...base, value: "hi" }))).not.toMatch(/<button[^>]*disabled/);
});
it("names the action on failure and keeps the text", () => {
const html = renderToStaticMarkup(createElement(Composer, { ...base, value: "my draft", error: true }));
expect(html).toContain("Publishing the post failed");
expect(html).toContain("my draft");
});
it("is shown in an empty feed", () => {
const html = renderToStaticMarkup(
createElement(FeedView, {
posts: [], status: "ready", loadingMore: false, loadMoreFailed: false, newCount: 0,
onRetry: () => {}, onShowNew: () => {}, composer: base,
}),
);
expect(html).toContain("No posts yet.");
expect(html).toContain("composer-text");
});
it("shows an image picker and enables Post with a file but no text", () => {
const f = new File(["x"], "photo.jpg", { type: "image/jpeg" });
const html = renderToStaticMarkup(createElement(Composer, { ...base, file: f, onFile: () => {} }));
expect(html).toContain("Add image");
expect(html).toContain("photo.jpg");
expect(html).not.toMatch(/<button[^>]*class="composer-post"[^>]*disabled/);
});
it("keeps the text and the image name on a publish failure", () => {
const f = new File(["x"], "sunset.png", { type: "image/png" });
const html = renderToStaticMarkup(
createElement(Composer, { ...base, value: "my draft", file: f, error: true, onFile: () => {} }),
);
expect(html).toContain("Publishing the post failed");
expect(html).toContain("my draft");
expect(html).toContain("sunset.png");
});
});