Files
frontend-helper/tests/post.test.ts
T
calcazar 0dc9d6b7b3 3dom4wf7p: Poll the feed before reporting a publish failure
A freshly published post can take a moment to surface on the home feed, so
the single immediate confirm reload reported a false failure even though the
post went through. Retry the reload a few times; once the server has accepted
the publish, treat a not-yet-visible post as success instead of an error.
2026-10-19 15:01:17 -06:00

173 lines
8.1 KiB
TypeScript

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");
});
});