From b4fb345f9064b838df62ba5590d23db0e4af3c94 Mon Sep 17 00:00:00 2001 From: Cesar Alcazar Date: Mon, 19 Oct 2026 14:46:13 -0600 Subject: [PATCH] mgply09ox: Publish an image post through a server upload pipeline --- src/app/api/posts/route.ts | 34 +++++- src/app/globals.css | 8 ++ src/components/Composer.tsx | 68 ++++++++++- src/components/Feed.tsx | 17 ++- src/lib/post-client.ts | 43 +++++-- src/lib/post-media.ts | 231 ++++++++++++++++++++++++++++++++++++ src/lib/session-refresh.ts | 2 +- tests/post-media.test.ts | 183 ++++++++++++++++++++++++++++ tests/post.test.ts | 26 +++- 9 files changed, 581 insertions(+), 31 deletions(-) create mode 100644 src/lib/post-media.ts create mode 100644 tests/post-media.test.ts diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index 0164581..a87e1b8 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -1,17 +1,43 @@ import { NextResponse, type NextRequest } from "next/server"; import { SESSION_COOKIE } from "@/lib/cookie"; import { createTextPost } from "@/lib/post-create"; +import { createImagePost } from "@/lib/post-media"; -// POST /api/posts { content } -// Creates a public text draft on the feed, then publishes it. Returns { id }. -// The create call can report status "draft" for a post already on the home -// feed, so the browser reloads the first page and confirms the id there. +// POST /api/posts +// - JSON { content } -> public text post +// - multipart/form-data content+file -> public image post (one file) +// Both create a public draft on the feed, then publish it, and return { id }. +// For an image the server also uploads the bytes to a presigned URL and confirms +// the upload; the browser never receives the feed token. The create call can +// report status "draft" for a post already on the home feed, so the browser +// reloads the first page and confirms the id there. // 401 { error: "sign_in" } without a session. export const runtime = "nodejs"; export async function POST(req: NextRequest) { const sessionId = req.cookies.get(SESSION_COOKIE)?.value; + const contentType = req.headers.get("content-type") ?? ""; + + if (contentType.includes("multipart/form-data")) { + let content = ""; + let file: File | null = null; + try { + const form = await req.formData(); + const c = form.get("content"); + content = typeof c === "string" ? c : ""; + const f = form.get("file"); + file = f instanceof File ? f : null; + } catch { + return NextResponse.json({ error: "bad_file" }, { status: 400 }); + } + const result = await createImagePost(sessionId, content, file); + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + return NextResponse.json({ id: result.id }); + } + let content: unknown = null; try { content = ((await req.json()) as { content?: unknown }).content; diff --git a/src/app/globals.css b/src/app/globals.css index 791fcb0..bc563e0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -47,6 +47,14 @@ button:disabled { opacity: .5; cursor: not-allowed; } .composer textarea { font: inherit; width: 100%; padding: 12px; border: 1px solid #999; border-radius: 0; resize: vertical; background: #fff; color: var(--ink); } .composer textarea:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; } .composer .composer-post { align-self: flex-end; margin: 0; padding: 10px 24px; border-radius: 0; } +.composer-media { display: flex; flex-direction: column; gap: 8px; } +.composer-image { display: inline-flex; align-items: center; gap: 8px; align-self: flex-start; cursor: pointer; } +.composer-image span { padding: 6px 12px; border: 1px solid #999; background: #fafafa; } +.composer-image input[type="file"] { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); border: 0; } +.composer-preview { display: flex; align-items: center; gap: 10px; } +.composer-preview img { width: 64px; height: 64px; object-fit: cover; border: 1px solid var(--line); } +.composer-filename { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.composer-remove { margin: 0; padding: 6px 12px; border-radius: 0; } .comments { display: flex; flex-direction: column; gap: 10px; padding-top: 10px; border-top: 1px solid #eee; } .comment-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; } diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index 02b367b..aa2081a 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -1,19 +1,53 @@ -import type { FormEvent } from "react"; +"use client"; + +import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "react"; export interface ComposerProps { value: string; busy: boolean; error: boolean; + /** The selected image file, when the image picker is wired. */ + file?: File | null; onChange: (value: string) => void; + /** When present, the composer shows an image picker and reports the choice. */ + onFile?: (file: File | null) => void; onSubmit: () => void; } -export function Composer({ value, busy, error, onChange, onSubmit }: ComposerProps) { +// Content types the picker offers (spec: allowed image types). +const IMAGE_ACCEPT = "image/jpeg,image/jpg,image/png,image/gif,image/webp,image/heic"; + +export function Composer({ value, busy, error, file = null, onChange, onFile, onSubmit }: ComposerProps) { const empty = value.trim().length === 0; + // A post needs either text or an image. + const canSubmit = (!empty || !!file) && !busy; + const inputRef = useRef(null); + const [preview, setPreview] = useState(null); + + // Build a preview URL client-side only, and revoke it when the file changes. + useEffect(() => { + if (!file) { + setPreview(null); + return; + } + const url = URL.createObjectURL(file); + setPreview(url); + return () => URL.revokeObjectURL(url); + }, [file]); + function submit(e: FormEvent) { e.preventDefault(); - if (!empty && !busy) onSubmit(); + if (canSubmit) onSubmit(); } + function pick(e: ChangeEvent) { + const chosen = e.target.files && e.target.files[0] ? e.target.files[0] : null; + onFile?.(chosen); + } + function remove() { + onFile?.(null); + if (inputRef.current) inputRef.current.value = ""; + } + return (
@@ -25,10 +59,36 @@ export function Composer({ value, busy, error, onChange, onSubmit }: ComposerPro disabled={busy} onChange={(e) => onChange(e.target.value)} /> + {onFile ? ( +
+ + {file ? ( +
+ {preview ? ( + // eslint-disable-next-line @next/next/no-img-element + {`Selected + ) : null} + {file.name} + +
+ ) : null} +
+ ) : null} {error ? (

Publishing the post failed. Your text is still here.

) : null} -
diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index da0fe97..e6ff8bb 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -22,6 +22,7 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee const [deleteBusy, setDeleteBusy] = useState>(new Set()); const [deleteErrors, setDeleteErrors] = useState>(new Set()); const [draft, setDraft] = useState(""); + const [file, setFile] = useState(null); const [posting, setPosting] = useState(false); const [postFailed, setPostFailed] = useState(false); // Id of a post already created but not yet seen on the first page. A retry @@ -187,22 +188,24 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee const publish = useCallback(async () => { const text = draft.trim(); - if (!text || posting) return; + // A post needs either text or an image. + if ((!text && !file) || posting) return; setPosting(true); setPostFailed(false); try { - const page = await publishAndConfirm(text, unconfirmed.current); + const page = await publishAndConfirm(text, file, unconfirmed.current); setPosts(mergePosts(toCardPosts(page.items))); setNextCursor(page.nextCursor); setPending([]); setDraft(""); + setFile(null); } catch (e) { if (e instanceof PostError && e.signedOut) onSignedOut(); setPostFailed(true); } finally { setPosting(false); } - }, [draft, posting, onSignedOut]); + }, [draft, file, posting, onSignedOut]); function changeDraft(v: string) { // New text is a new post; forget a stale unconfirmed id. @@ -210,6 +213,12 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee setDraft(v); } + function changeFile(f: File | null) { + // A different attachment is a new post; forget a stale unconfirmed id. + unconfirmed.current = { id: null }; + setFile(f); + } + function showNew() { setPosts((cur) => mergePosts(cur, pending)); setPending([]); @@ -233,7 +242,7 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee deleteBusy={deleteBusy} deleteErrors={deleteErrors} onSignedOut={onSignedOut} - composer={{ value: draft, busy: posting, error: postFailed, onChange: changeDraft, onSubmit: () => void publish() }} + composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish() }} sentinelRef={sentinel} /> ); diff --git a/src/lib/post-client.ts b/src/lib/post-client.ts index c63abcf..4dcf6f3 100644 --- a/src/lib/post-client.ts +++ b/src/lib/post-client.ts @@ -9,15 +9,30 @@ export class PostError extends Error { } } -export async function sendPost(content: string, fetchImpl: typeof fetch = fetch): Promise { +export async function sendPost( + content: string, + file: File | null = null, + fetchImpl: typeof fetch = fetch, +): Promise { let res: Response; + // A file is sent as multipart form-data so the server can upload the bytes; + // text-only posts stay JSON. The content type is left to FormData for the + // multipart case so the boundary is set correctly. + const init: RequestInit = file + ? (() => { + const form = new FormData(); + form.append("content", content); + form.append("file", file, file.name); + return { method: "POST", credentials: "same-origin", body: form }; + })() + : { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }; try { - res = await fetchImpl("/api/posts", { - method: "POST", - credentials: "same-origin", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content }), - }); + res = await fetchImpl("/api/posts", init); } catch { throw new PostError(false); } @@ -56,22 +71,24 @@ export async function confirmOnFirstPage( } export interface PublishDeps { - send?: (content: string) => Promise; + send?: (content: string, file: File | null) => Promise; fetchPage?: (cursor: string | null) => Promise; } /** - * Publish text and confirm it on the reloaded first page. Returns that page - * only when the post id is on it. `unconfirmed` holds an id that was created - * earlier but not yet seen, so a retry re-checks the feed and does not create - * a duplicate. Throws PostError otherwise; the caller keeps the composer text. + * Publish a post (text, or text plus one image file) and confirm it on the + * reloaded first page. Returns that page only when the post id is on it. + * `unconfirmed` holds an id that was created earlier but not yet seen, so a + * retry re-checks the feed and does not create a duplicate. Throws PostError + * otherwise; the caller keeps the composer text and the selected file. */ export async function publishAndConfirm( content: string, + file: File | null, unconfirmed: { id: string | null }, deps: PublishDeps = {}, ): Promise { - const id = unconfirmed.id ?? (await (deps.send ?? sendPost)(content)); + const id = unconfirmed.id ?? (await (deps.send ?? sendPost)(content, file)); unconfirmed.id = id; const page = await confirmOnFirstPage(id, deps.fetchPage); if (!page) throw new PostError(false); diff --git a/src/lib/post-media.ts b/src/lib/post-media.ts new file mode 100644 index 0000000..a144e0b --- /dev/null +++ b/src/lib/post-media.ts @@ -0,0 +1,231 @@ +import { FEED_BASE_URL } from "./env"; +import { MAX_POST_LENGTH } from "./post-create"; +import { ensureFreshSession, refreshSession, type RefreshOptions } from "./session-refresh"; +import type { SessionData } from "./session-store"; + +// Media-post creation proxy. Unlike a text post this needs five feed steps: +// 1. create an image/video draft +// 2. ask the feed for a presigned upload URL +// 3. the SERVER PUTs the bytes to that URL (the browser never gets the token) +// 4. confirm the upload (image confirmation blocks until processing finishes) +// 5. publish with the same {content} publish call as a text post +// The pipeline is media-type agnostic so the video task can reuse it; only the +// allowed-type gate and the public entry point here are image-specific. +// Neither the feed token nor the file bytes are ever logged. + +export type MediaKind = "image" | "video"; + +/** Image content types the feed accepts (spec: "Create a post with an image or video"). */ +export const ALLOWED_IMAGE_TYPES: readonly string[] = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/webp", + "image/heic", +]; + +/** Upper bound on an uploaded image, enforced before any feed call. */ +export const MAX_IMAGE_BYTES = 15 * 1024 * 1024; + +export type CreateMediaPostResult = + | { ok: true; id: string; status: string | null } + | { + ok: false; + status: 400 | 401 | 502; + error: "sign_in" | "too_long" | "bad_file" | "post_failed"; + }; + +export interface MediaUpload { + /** The file to upload. */ + file: { name: string; type: string; size: number; arrayBuffer: () => Promise }; + /** Draft type and confirm mediaType. */ + mediaType: MediaKind; +} + +type Fetch = typeof fetch; + +function rec(v: unknown): Record { + return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : {}; +} + +function str(v: unknown): string | undefined { + return typeof v === "string" && v.length > 0 ? v : undefined; +} + +function readCreated(body: unknown): { id: string; status: string | null } | null { + const root = rec(body); + const data = rec(root.data); + const post = rec(data.post); + for (const v of [data.id, post.id, root.id]) { + if ((typeof v === "string" && v) || typeof v === "number") { + const status = [data.status, post.status, root.status].find((s) => typeof s === "string"); + return { id: String(v), status: (status as string | undefined) ?? null }; + } + } + return null; +} + +/** Tolerant reader for the presigned-urls response (field names are not pinned by the spec). */ +function readPresigned( + body: unknown, +): { uploadUrl: string; attachmentId: string; s3Key: string } | null { + const root = rec(body); + const data = root.data; + // The entry may be the first element of an array, the first element of a + // named list, or the data object itself. + const candidates: unknown[] = []; + if (Array.isArray(data)) candidates.push(...data); + else if (Array.isArray(body)) candidates.push(...(body as unknown[])); + else { + const d = rec(data); + for (const key of ["files", "urls", "attachments", "presignedUrls"]) { + if (Array.isArray(d[key])) candidates.push(...(d[key] as unknown[])); + } + candidates.push(d); + } + for (const c of candidates) { + const e = rec(c); + const uploadUrl = + str(e.uploadUrl) ?? str(e.url) ?? str(e.presignedUrl) ?? str(e.signedUrl) ?? str(e.putUrl); + const attachmentId = str(e.attachmentId) ?? str(e.id); + const s3Key = str(e.s3Key) ?? str(e.key) ?? str(e.objectKey); + if (uploadUrl && attachmentId && s3Key) return { uploadUrl, attachmentId, s3Key }; + } + return null; +} + +function sendJson(token: string, path: string, payload: unknown, doFetch: Fetch) { + return doFetch(`${FEED_BASE_URL}${path}`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(payload), + }); +} + +/** True when the type is an accepted image content type (case-insensitive). */ +export function isAllowedImageType(type: string): boolean { + return ALLOWED_IMAGE_TYPES.includes(type.trim().toLowerCase()); +} + +/** + * Create a public image post from a caption and one file: draft -> presigned URL + * -> server-side PUT -> confirm -> publish. Returns the post id so the caller can + * confirm it on the reloaded first page. The video task reuses `runMediaPipeline` + * with `mediaType: "video"` and its own type gate. + */ +export async function createImagePost( + sessionId: string | undefined, + content: unknown, + file: MediaUpload["file"] | null, + options: RefreshOptions = {}, +): Promise { + if (!file || typeof file.size !== "number" || file.size <= 0 || file.size > MAX_IMAGE_BYTES) { + return { ok: false, status: 400, error: "bad_file" }; + } + if (typeof file.type !== "string" || !isAllowedImageType(file.type)) { + return { ok: false, status: 400, error: "bad_file" }; + } + return runMediaPipeline(sessionId, content, { file, mediaType: "image" }, options); +} + +/** + * Media-type agnostic upload-and-publish pipeline. Callers validate the file + * first (type, size) for their media kind. A caption may be empty for a media + * post; only an over-length caption is rejected. + */ +export async function runMediaPipeline( + sessionId: string | undefined, + content: unknown, + upload: MediaUpload, + options: RefreshOptions = {}, +): Promise { + const signIn = { ok: false, status: 401, error: "sign_in" } as const; + const failed = { ok: false, status: 502, error: "post_failed" } as const; + const doFetch = options.fetchImpl ?? fetch; + const { file, mediaType } = upload; + + let session: SessionData | null = await ensureFreshSession(sessionId, options); + if (!session || !sessionId) return signIn; + + const caption = typeof content === "string" ? content : ""; + if (caption.length > MAX_POST_LENGTH) { + return { ok: false, status: 400, error: "too_long" }; + } + + // One feed call with a single refresh-and-retry on a 401 token rejection. + const call = async (path: string, payload: unknown): Promise => { + let res = await sendJson(session!.feedAccessToken, path, payload, doFetch); + if (res.status === 401) { + session = await refreshSession(sessionId, options); + if (!session) return "sign_in"; + res = await sendJson(session.feedAccessToken, path, payload, doFetch); + if (res.status === 401) return "sign_in"; + } + return res; + }; + + try { + // 1. Draft. + const created = await call("/api/v1/posts", { + content: caption, + type: mediaType, + status: "draft", + visibility: "public", + }); + if (created === "sign_in") return signIn; + if (created.status !== 200 && created.status !== 201) return failed; + const info = readCreated(await created.json().catch(() => null)); + if (!info) return failed; + + // 2. Presigned URL for the one file. + const presignedRes = await call("/api/v1/attachments/presigned-urls", { + postId: info.id, + files: [{ name: file.name, type: file.type, size: file.size }], + }); + if (presignedRes === "sign_in") return signIn; + if (presignedRes.status < 200 || presignedRes.status > 299) return failed; + const slot = readPresigned(await presignedRes.json().catch(() => null)); + if (!slot) return failed; + + // 3. The server PUTs the bytes. No Authorization header: the presigned URL + // is pre-authorized, and the feed token must never reach S3 or the browser. + const bytes = await file.arrayBuffer(); + const put = await doFetch(slot.uploadUrl, { + method: "PUT", + headers: { "Content-Type": file.type }, + body: bytes, + }); + if (!put.ok) return failed; + + // 4. Confirm (image confirmation blocks until processing finishes). + const confirmed = await call("/api/v1/attachments/confirm-uploads", { + postId: info.id, + uploads: [ + { + attachmentId: slot.attachmentId, + s3Key: slot.s3Key, + mediaType, + filename: file.name, + }, + ], + }); + if (confirmed === "sign_in") return signIn; + if (confirmed.status < 200 || confirmed.status > 299) return failed; + + // 5. Publish, same call as a text post. + const published = await call(`/api/v1/posts/${encodeURIComponent(info.id)}/publish`, { + content: caption, + }); + if (published === "sign_in") return signIn; + if (published.status < 200 || published.status > 299) return failed; + + return { ok: true, id: info.id, status: info.status }; + } catch { + return failed; + } +} diff --git a/src/lib/session-refresh.ts b/src/lib/session-refresh.ts index ba4a5d7..7b8d519 100644 --- a/src/lib/session-refresh.ts +++ b/src/lib/session-refresh.ts @@ -14,7 +14,7 @@ import { exchangeFeedToken, refreshProfileTokens, revokeToken } from "./minnect" /** Refresh when a token expires within this window (spec: two minutes). */ export const REFRESH_WINDOW_MS = 2 * 60 * 1000; -interface RefreshOptions { +export interface RefreshOptions { /** Injectable fetch, for testing. Defaults to the global fetch. */ fetchImpl?: typeof fetch; /** Injectable clock for the expiry decision, for testing. Defaults to Date.now(). */ diff --git a/tests/post-media.test.ts b/tests/post-media.test.ts new file mode 100644 index 0000000..94492e4 --- /dev/null +++ b/tests/post-media.test.ts @@ -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(fn: typeof fetch, run: () => Promise) { + 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 = {}; + 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" }); + }); +}); diff --git a/tests/post.test.ts b/tests/post.test.ts index fa47d71..7dffd86 100644 --- a/tests/post.test.ts +++ b/tests/post.test.ts @@ -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(/]*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"); + }); });