Files
frontend-helper/tests/post.test.ts
T

144 lines
6.8 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 { 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();
});
it("throws and keeps the id when the post is not on the page, then a retry does not create again", async () => {
const state = { id: null as string | null };
const send = vi.fn(async () => "p9");
const miss = async () => ({ items: [item("p1")], nextCursor: null });
await expect(publishAndConfirm("hi", null, state, { send, fetchPage: miss })).rejects.toBeInstanceOf(PostError);
expect(state.id).toBe("p9");
const hit = async () => ({ items: [item("p9")], nextCursor: null });
await publishAndConfirm("hi", null, state, { send, fetchPage: hit });
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");
});
});