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(fn: typeof fetch, run: () => Promise) { 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(/]*disabled/); expect(renderToStaticMarkup(createElement(Composer, { ...base, value: " " }))).toMatch(/]*disabled/); expect(renderToStaticMarkup(createElement(Composer, { ...base, value: "hi" }))).not.toMatch(/]*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).toMatch(/]*type="submit"[^>]*class="[^"]*composer-post/); expect(html).not.toMatch(/]*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"); }); });