174 lines
8.2 KiB
TypeScript
174 lines
8.2 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).toMatch(/<button[^>]*type="submit"[^>]*class="[^"]*composer-post/);
|
|
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");
|
|
});
|
|
});
|