Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions web/src/components/community/AddHeaderModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 2 additions & 3 deletions web/src/components/community/AddPerformanceModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
Expand Down
99 changes: 99 additions & 0 deletions web/src/components/community/CommentSection.test.tsx
Original file line number Diff line number Diff line change
@@ -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 =
"<html><head><title>502 Bad Gateway</title></head><body><center><h1>502 Bad Gateway</h1></center><hr><center>nginx</center></body></html>";

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<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
refresh.mockReset();
});

function post(text: string) {
render(<CommentSection patternId="p1" comments={[MINE]} />);
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(<CommentSection patternId="p1" comments={[MINE]} />);
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(<CommentSection patternId="p1" comments={[MINE]} />);
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();
});
});
11 changes: 4 additions & 7 deletions web/src/components/community/CommentSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down
5 changes: 2 additions & 3 deletions web/src/components/community/EditDetailsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 });
Expand Down
5 changes: 2 additions & 3 deletions web/src/components/community/ReportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 });
Expand Down
12 changes: 8 additions & 4 deletions web/src/components/community/ShareDeckModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", {
Expand Down
133 changes: 133 additions & 0 deletions web/src/components/community/formErrors.test.tsx
Original file line number Diff line number Diff line change
@@ -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) =>
`<html><head><title>${status} ${text}</title></head><body><center><h1>${status} ${text}</h1></center><hr><center>nginx</center></body></html>`;

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: (
<EditDetailsModal
patternId="p1"
initialTitle="Tide"
initialDescription={null}
initialLicense="CC-BY-4.0"
initialMadeOn={null}
initialMadeHow={null}
initialVisibility="public"
onClose={() => undefined}
/>
),
submit: () => fireEvent.click(screen.getByRole("button", { name: "Save details" })),
fallback: "Could not save",
},
{
name: "Report",
element: (
<ReportModal targetType="pattern" targetId="p1" targetLabel="Tide" onClose={() => undefined} />
),
submit: () => fireEvent.click(screen.getByRole("button", { name: "Send report" })),
fallback: "Could not send the report",
},
{
name: "Add firmware header",
element: (
<AddHeaderModal patternId="p1" initialCpp="// old" mode="owner" onClose={() => undefined} />
),
submit: () => fireEvent.click(screen.getByRole("button", { name: "Update header" })),
fallback: "Could not save the header",
},
{
name: "Publish a performance",
element: <AddPerformanceModal patternId="p1" onClose={() => 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: (
<ShareDeckModal
deck={[{ patternId: "p1", title: "Tide", code: "// h" }]}
onClose={() => 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<typeof vi.fn>;
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();
});
}
});
Loading
Loading