mgply09ox: Publish an image post through a server upload pipeline
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { POST } from "@/app/api/posts/route";
|
||||
import { createImagePost } 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" });
|
||||
});
|
||||
});
|
||||
+21
-5
@@ -79,7 +79,7 @@ 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", state, { send: async () => "p9", fetchPage: async () => page });
|
||||
const got = await publishAndConfirm("hi", null, state, { send: async () => "p9", fetchPage: async () => page });
|
||||
expect(got).toBe(page);
|
||||
expect(state.id).toBeNull();
|
||||
});
|
||||
@@ -88,17 +88,17 @@ describe("publishAndConfirm", () => {
|
||||
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", state, { send, fetchPage: miss })).rejects.toBeInstanceOf(PostError);
|
||||
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", state, { send, fetchPage: hit });
|
||||
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", f(401))).rejects.toMatchObject({ signedOut: true });
|
||||
await expect(sendPost("x", f(500))).rejects.toMatchObject({ signedOut: false });
|
||||
await expect(sendPost("x", null, f(401))).rejects.toMatchObject({ signedOut: true });
|
||||
await expect(sendPost("x", null, f(500))).rejects.toMatchObject({ signedOut: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,4 +124,20 @@ describe("Composer", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user