diff --git a/web/src/components/community/AddHeaderModal.tsx b/web/src/components/community/AddHeaderModal.tsx index efcc23645..e052e3199 100644 --- a/web/src/components/community/AddHeaderModal.tsx +++ b/web/src/components/community/AddHeaderModal.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"; import Link from "next/link"; import { captureEvent } from "@/lib/posthogEvents"; import { authClient } from "@/lib/community/auth-client"; -import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase"; +import { COMMUNITY_FETCH_INIT, communityApiUrl, readApiError } from "@/lib/community/apiBase"; import { useSubmitLatch } from "@/lib/community/submitLatch"; import { useDialog } from "@/lib/hooks/useDialog"; import { useBackdropClose } from "@/lib/hooks/useBackdropClose"; @@ -93,9 +93,8 @@ export default function AddHeaderModal({ headers: { "Content-Type": "application/json" }, body: JSON.stringify(call.body), }); - const payload = (await response.json()) as { error?: string; hasCpp?: boolean }; if (!response.ok) { - setError(payload.error ?? "Could not save the header."); + setError(await readApiError(response, "Could not save the header")); return; } captureEvent( diff --git a/web/src/components/community/AddPerformanceModal.tsx b/web/src/components/community/AddPerformanceModal.tsx index b486ae6bd..158532536 100644 --- a/web/src/components/community/AddPerformanceModal.tsx +++ b/web/src/components/community/AddPerformanceModal.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { captureEvent } from "@/lib/posthogEvents"; import { authClient } from "@/lib/community/auth-client"; -import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase"; +import { COMMUNITY_FETCH_INIT, communityApiUrl, readApiError } from "@/lib/community/apiBase"; import { useSubmitLatch } from "@/lib/community/submitLatch"; import { useDialog } from "@/lib/hooks/useDialog"; import { useBackdropClose } from "@/lib/hooks/useBackdropClose"; @@ -64,9 +64,8 @@ export default function AddPerformanceModal({ body: JSON.stringify({ performanceJson: json, note }), }, ); - const payload = (await response.json()) as { error?: string }; if (!response.ok) { - setError(payload.error ?? "Could not save the performance."); + setError(await readApiError(response, "Could not save the performance")); return; } captureEvent("community_publish_performance", { pattern_id: patternId }); diff --git a/web/src/components/community/CommentSection.test.tsx b/web/src/components/community/CommentSection.test.tsx new file mode 100644 index 000000000..5b08944ba --- /dev/null +++ b/web/src/components/community/CommentSection.test.tsx @@ -0,0 +1,99 @@ +// With the app down, nginx answers a comment with its own 502 page. The thread +// read that page as JSON, the read threw, and the catch said "Network error." +// exactly as it does when the wire is cut. The server had answered, so the +// thread says which status it answered with. A fetch that never reaches +// anything still says "Network error." + +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import CommentSection, { type CommentView } from "./CommentSection"; + +const refresh = vi.fn(); +vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh, push: vi.fn() }) })); +vi.mock("@/lib/community/auth-client", () => ({ + authClient: { + useSession: () => ({ data: { user: { id: "u1", username: "ada" } }, isPending: false }), + }, +})); + +const NGINX_502 = + "502 Bad Gateway

502 Bad Gateway


nginx
"; + +const MINE: CommentView = { + id: "c1", + userId: "u1", + body: "first light", + createdAt: "2026-10-26T09:00:00.000Z", + editedAt: null, + username: "ada", + displayUsername: "ada", +}; + +const htmlPage = (status: number) => + new Response(NGINX_502, { status, headers: { "Content-Type": "text/html" } }); + +let fetchMock: ReturnType; +beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); +}); +afterEach(() => { + vi.unstubAllGlobals(); + refresh.mockReset(); +}); + +function post(text: string) { + render(); + fireEvent.change(screen.getByRole("textbox", { name: "Comment" }), { target: { value: text } }); + fireEvent.click(screen.getByRole("button", { name: "Post" })); +} + +describe("comment errors", () => { + it("says the status when the server answers a post with an HTML page", async () => { + fetchMock.mockResolvedValue(htmlPage(502)); + post("hello"); + expect(await screen.findByText("Comment failed (HTTP 502).")).toBeTruthy(); + expect(screen.queryByText("Network error.")).toBeNull(); + // The draft stays for another try. + expect((screen.getByRole("textbox", { name: "Comment" }) as HTMLTextAreaElement).value).toBe( + "hello", + ); + }); + + it("shows the route's own words when it sent some", async () => { + fetchMock.mockResolvedValue( + Response.json({ error: "Too many comments — wait a minute and try again." }, { status: 429 }), + ); + post("hello"); + expect(await screen.findByText("Too many comments — wait a minute and try again.")).toBeTruthy(); + }); + + it("still says Network error when nothing answered", async () => { + fetchMock.mockRejectedValue(new TypeError("Failed to fetch")); + post("hello"); + expect(await screen.findByText("Network error.")).toBeTruthy(); + }); + + it("posts and clears the field on ok", async () => { + fetchMock.mockResolvedValue(Response.json({ ok: true }, { status: 201 })); + post("hello"); + await vi.waitFor(() => expect(refresh).toHaveBeenCalled()); + expect((screen.getByRole("textbox", { name: "Comment" }) as HTMLTextAreaElement).value).toBe(""); + }); + + it("says the status when saving an edit meets an HTML page", async () => { + fetchMock.mockResolvedValue(htmlPage(502)); + render(); + fireEvent.click(screen.getByRole("button", { name: "edit" })); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + expect(await screen.findByText("Could not save the edit (HTTP 502).")).toBeTruthy(); + }); + + it("says the status when a delete meets an empty answer", async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 413 })); + render(); + fireEvent.click(screen.getByRole("button", { name: "delete" })); + fireEvent.click(screen.getByRole("button", { name: "press again" })); + expect(await screen.findByText("Could not delete the comment (HTTP 413).")).toBeTruthy(); + }); +}); diff --git a/web/src/components/community/CommentSection.tsx b/web/src/components/community/CommentSection.tsx index 696bbd27d..ba453aedf 100644 --- a/web/src/components/community/CommentSection.tsx +++ b/web/src/components/community/CommentSection.tsx @@ -5,7 +5,7 @@ import { useState } from "react"; import BodyComposer from "./BodyComposer"; import { useRouter } from "next/navigation"; import { authClient } from "@/lib/community/auth-client"; -import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase"; +import { COMMUNITY_FETCH_INIT, communityApiUrl, readApiError } from "@/lib/community/apiBase"; import { useSubmitLatch } from "@/lib/community/submitLatch"; import { COMMENT_MAX } from "@/lib/community/validate"; import AuthModal from "./AuthModal"; @@ -73,8 +73,7 @@ export default function CommentSection({ }, ); if (!response.ok) { - const payload = (await response.json()) as { error?: string }; - setRowError(payload.error ?? "Could not save the edit."); + setRowError(await readApiError(response, "Could not save the edit")); return; } setEditingId(null); @@ -95,8 +94,7 @@ export default function CommentSection({ { method: "DELETE", ...COMMUNITY_FETCH_INIT }, ); if (!response.ok) { - const payload = (await response.json()) as { error?: string }; - setRowError(payload.error ?? "Could not delete the comment."); + setRowError(await readApiError(response, "Could not delete the comment")); return; } setConfirmDelete(null); @@ -124,9 +122,8 @@ export default function CommentSection({ body: JSON.stringify({ body: text }), }, ); - const payload = (await response.json()) as { error?: string }; if (!response.ok) { - setError(payload.error ?? "Comment failed."); + setError(await readApiError(response, "Comment failed")); return; } diff --git a/web/src/components/community/EditDetailsModal.tsx b/web/src/components/community/EditDetailsModal.tsx index 6c5cf78d9..50e9f90d7 100644 --- a/web/src/components/community/EditDetailsModal.tsx +++ b/web/src/components/community/EditDetailsModal.tsx @@ -13,7 +13,7 @@ import { LICENSE_OPTIONS, forkLicenseOptions, licenseBySpdx } from "@/lib/patter import { useDialog } from "@/lib/hooks/useDialog"; import { useBackdropClose } from "@/lib/hooks/useBackdropClose"; import { captureEvent } from "@/lib/posthogEvents"; -import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase"; +import { COMMUNITY_FETCH_INIT, communityApiUrl, readApiError } from "@/lib/community/apiBase"; import { VISIBILITY_HINTS, VISIBILITY_LABELS, @@ -99,9 +99,8 @@ export default function EditDetailsModal({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: trimmed, description, license, madeOn, madeHow, visibility }), }); - const payload = (await response.json()) as { error?: string }; if (!response.ok) { - setError(payload.error ?? "Could not save."); + setError(await readApiError(response, "Could not save")); return; } captureEvent("community_edit_details", { pattern_id: patternId, license }); diff --git a/web/src/components/community/ReportModal.tsx b/web/src/components/community/ReportModal.tsx index 37c9e98e2..f4c6c6737 100644 --- a/web/src/components/community/ReportModal.tsx +++ b/web/src/components/community/ReportModal.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { authClient } from "@/lib/community/auth-client"; -import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase"; +import { COMMUNITY_FETCH_INIT, communityApiUrl, readApiError } from "@/lib/community/apiBase"; import { useSubmitLatch } from "@/lib/community/submitLatch"; import { REPORT_DETAIL_MAX, @@ -55,9 +55,8 @@ export default function ReportModal({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ targetType, targetId, reason, detail }), }); - const payload = (await response.json()) as { ok?: boolean; error?: string }; if (!response.ok) { - setError(payload.error ?? "Could not send the report."); + setError(await readApiError(response, "Could not send the report")); return; } captureEvent("community_report", { target_type: targetType, reason }); diff --git a/web/src/components/community/ShareDeckModal.tsx b/web/src/components/community/ShareDeckModal.tsx index 7370dc4c2..60448a0d1 100644 --- a/web/src/components/community/ShareDeckModal.tsx +++ b/web/src/components/community/ShareDeckModal.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { authClient } from "@/lib/community/auth-client"; -import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase"; +import { COMMUNITY_FETCH_INIT, communityApiUrl, readApiError } from "@/lib/community/apiBase"; import { useSubmitLatch } from "@/lib/community/submitLatch"; import { PUBLIC_DECKS_MAX, type CollectedPattern } from "@/lib/community/deck"; import { DESCRIPTION_MAX, TITLE_MAX } from "@/lib/community/validate"; @@ -64,9 +64,13 @@ export default function ShareDeckModal({ patternIds: deck.map((item) => item.patternId), }), }); - const payload = (await response.json()) as { id?: string; error?: string }; - if (!response.ok || !payload.id) { - setError(payload.error ?? "Sharing failed."); + if (!response.ok) { + setError(await readApiError(response, "Sharing failed")); + return; + } + const payload = (await response.json()) as { id?: string }; + if (!payload.id) { + setError("Sharing failed."); return; } captureEvent("community_deck_shared", { diff --git a/web/src/components/community/formErrors.test.tsx b/web/src/components/community/formErrors.test.tsx new file mode 100644 index 000000000..0275e9ab8 --- /dev/null +++ b/web/src/components/community/formErrors.test.tsx @@ -0,0 +1,133 @@ +// The community's save and send windows read every answer as JSON. When the +// proxy answered instead, with its 502 page or a bare 413 for a body over its +// limit, the read threw and the window said "Network error." although the +// server had answered. Each window here meets both and has to name the status. + +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import AddHeaderModal from "./AddHeaderModal"; +import AddPerformanceModal from "./AddPerformanceModal"; +import EditDetailsModal from "./EditDetailsModal"; +import ReportModal from "./ReportModal"; +import ShareDeckModal from "./ShareDeckModal"; + +vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) })); +vi.mock("@/lib/community/auth-client", () => ({ + authClient: { + useSession: () => ({ data: { user: { id: "u1", username: "ada" } }, isPending: false }), + }, +})); +vi.mock("@/lib/posthogEvents", () => ({ captureEvent: vi.fn() })); + +const NGINX_PAGE = (status: number, text: string) => + `${status} ${text}

${status} ${text}


nginx
`; + +const ANSWERS = [ + { status: 502, body: NGINX_PAGE(502, "Bad Gateway") }, + { status: 413, body: NGINX_PAGE(413, "Request Entity Too Large") }, +]; + +type Form = { + name: string; + element: ReactElement; + /** Fill what the window needs and press its button. */ + submit: () => void; + fallback: string; +}; + +const FORMS: Form[] = [ + { + name: "Edit details", + element: ( + undefined} + /> + ), + submit: () => fireEvent.click(screen.getByRole("button", { name: "Save details" })), + fallback: "Could not save", + }, + { + name: "Report", + element: ( + undefined} /> + ), + submit: () => fireEvent.click(screen.getByRole("button", { name: "Send report" })), + fallback: "Could not send the report", + }, + { + name: "Add firmware header", + element: ( + undefined} /> + ), + submit: () => fireEvent.click(screen.getByRole("button", { name: "Update header" })), + fallback: "Could not save the header", + }, + { + name: "Publish a performance", + element: undefined} />, + submit: () => { + fireEvent.change(screen.getByRole("textbox", { name: "Performance JSON" }), { + target: { value: '{"version":1,"length":30,"timeline":[]}' }, + }); + fireEvent.click(screen.getByRole("button", { name: "Publish performance" })); + }, + fallback: "Could not save the performance", + }, + { + name: "Share deck", + element: ( + undefined} + /> + ), + submit: () => { + fireEvent.change(screen.getByRole("textbox", { name: "Title" }), { + target: { value: "Night set" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Publish deck" })); + }, + fallback: "Sharing failed", + }, +]; + +let fetchMock: ReturnType; +beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("community windows name the status of an answer that is not JSON", () => { + for (const form of FORMS) { + for (const answer of ANSWERS) { + it(`${form.name} · HTTP ${answer.status}`, async () => { + fetchMock.mockResolvedValue( + new Response(answer.body, { + status: answer.status, + headers: { "Content-Type": "text/html" }, + }), + ); + render(form.element); + form.submit(); + expect(await screen.findByText(`${form.fallback} (HTTP ${answer.status}).`)).toBeTruthy(); + expect(document.body.textContent).not.toMatch(/Network error/); + }); + } + + it(`${form.name} · keeps the route's own error`, async () => { + fetchMock.mockResolvedValue(Response.json({ error: "Sign in first." }, { status: 401 })); + render(form.element); + form.submit(); + expect(await screen.findByText("Sign in first.")).toBeTruthy(); + }); + } +}); diff --git a/web/src/lib/community/apiBase.test.ts b/web/src/lib/community/apiBase.test.ts new file mode 100644 index 000000000..05263e961 --- /dev/null +++ b/web/src/lib/community/apiBase.test.ts @@ -0,0 +1,52 @@ +// A community form said "Network error." whenever the answer was not JSON: +// the 502 page nginx writes when the app is down, a bare 413 for a body over +// the proxy's limit. The server had answered both times. readApiError keeps +// the route's own words when there are some and gives the status when not. + +import { describe, expect, it } from "vitest"; +import { readApiError } from "./apiBase"; + +const NGINX_502 = + "502 Bad Gateway

502 Bad Gateway


nginx
"; + +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("readApiError", () => { + it("keeps the route's own error", async () => { + const response = json(429, { error: "Too many comments — wait a minute and try again." }); + expect(await readApiError(response, "Comment failed")).toBe( + "Too many comments — wait a minute and try again.", + ); + }); + + it("gives the status for an HTML error page", async () => { + const response = new Response(NGINX_502, { + status: 502, + headers: { "Content-Type": "text/html" }, + }); + expect(await readApiError(response, "Comment failed")).toBe("Comment failed (HTTP 502)."); + }); + + it("gives the status for an empty body", async () => { + expect(await readApiError(new Response(null, { status: 413 }), "Could not save")).toBe( + "Could not save (HTTP 413).", + ); + }); + + it("gives the status when the JSON carries no usable error", async () => { + for (const body of [{}, { ok: false }, { error: "" }, { error: " " }, { error: { code: 1 } }, null, [], "nope"]) { + expect(await readApiError(json(400, body), "Sharing failed")).toBe("Sharing failed (HTTP 400)."); + } + }); + + it("does not throw on a body that was already read", async () => { + const response = json(500, { error: "late" }); + await response.text(); + expect(await readApiError(response, "Could not save")).toBe("Could not save (HTTP 500)."); + }); +}); diff --git a/web/src/lib/community/apiBase.ts b/web/src/lib/community/apiBase.ts index 6ea42cb6d..1778f8a89 100644 --- a/web/src/lib/community/apiBase.ts +++ b/web/src/lib/community/apiBase.ts @@ -96,3 +96,21 @@ export function communityApiUrl(path: string): string { export const COMMUNITY_FETCH_INIT: Pick = { credentials: "include", }; + +/** + * What to tell someone when a community call answered but not with ok. + * + * The route's own `{ error }` when it sent one. A proxy's 502 page or a bare + * 413 is not JSON, and reading it as JSON threw into the caller's catch, which + * said "Network error." about a server that had answered. Those come back as + * `fallback` with the status instead: "Comment failed (HTTP 502)." Pass + * `fallback` without its full stop. + */ +export async function readApiError(response: Response, fallback: string): Promise { + const payload: unknown = await response.json().catch(() => null); + if (typeof payload === "object" && payload !== null && "error" in payload) { + const { error } = payload; + if (typeof error === "string" && error.trim().length > 0) return error; + } + return `${fallback} (HTTP ${response.status}).`; +}