From a11e5991cfcdc471e2a50d64aff51bd0cc0282de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 13 Sep 2026 14:50:32 +0200 Subject: [PATCH] test(settings): read a real failure response through the test button The fetch and envelope reading move into runConnectionTest, which the button calls. A test stubs the fetch with the 502 envelopes the notification test routes send and asserts the rendered code, HTTP status, SMTP code and relay reply, so dropping the meta extraction fails it. Refs #947 --- .../test-connection-button-response.test.tsx | 122 ++++++++++++++++++ .../settings/test-connection-button.tsx | 82 ++++++------ 2 files changed, 166 insertions(+), 38 deletions(-) create mode 100644 src/components/settings/__tests__/test-connection-button-response.test.tsx diff --git a/src/components/settings/__tests__/test-connection-button-response.test.tsx b/src/components/settings/__tests__/test-connection-button-response.test.tsx new file mode 100644 index 000000000..97ab62800 --- /dev/null +++ b/src/components/settings/__tests__/test-connection-button-response.test.tsx @@ -0,0 +1,122 @@ +/** + * How the test-connection button reads a real failure response (#947). + * + * The component suite is SSR-only, so the button's probe is exercised as the + * function the button calls: `apiFetchRaw` is stubbed with the envelope a + * notification test route sends, and the result is rendered through the same + * callout the button renders. Removing the meta extraction turns this red. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiFetchRawMock = vi.fn(); +vi.mock("@/lib/api/api-fetch", () => ({ + apiFetchRaw: (...args: unknown[]) => apiFetchRawMock(...args), +})); + +import { + runConnectionTest, + TestConnectionFailure, +} from "../test-connection-button"; + +function envelope(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function renderFailure( + result: Awaited>, +): string { + if (result.kind !== "error") throw new Error("expected a failure"); + return renderToStaticMarkup( + , + ); +} + +beforeEach(() => { + apiFetchRawMock.mockReset(); +}); + +describe("runConnectionTest", () => { + it("reads the code, the HTTP status and what the relay said from a 502", async () => { + apiFetchRawMock.mockResolvedValue( + envelope(502, { + data: null, + error: "The webhook answered HTTP 400.", + meta: { + errorCode: "upstream_rejected", + upstreamStatus: 400, + upstreamBody: "priority must be an integer", + }, + }), + ); + + const result = await runConnectionTest("/api/settings/webhook/test"); + + expect(apiFetchRawMock).toHaveBeenCalledWith("/api/settings/webhook/test", { + method: "POST", + }); + const html = renderFailure(result); + expect(html).toContain("code:upstream_rejected (HTTP 400)"); + expect(html).toContain("priority must be an integer"); + }); + + it("reads the SMTP reply code from an email failure", async () => { + apiFetchRawMock.mockResolvedValue( + envelope(502, { + data: null, + error: "The mail server rejected the credentials (SMTP 535).", + meta: { errorCode: "credentials_rejected", smtpCode: 535 }, + }), + ); + + const html = renderFailure( + await runConnectionTest("/api/settings/email/test"), + ); + + expect(html).toContain("code:credentials_rejected (SMTP 535)"); + expect(html).not.toContain("test-connection-upstream-body"); + }); + + it("falls back to the generic code when a proxy replaced the body", async () => { + apiFetchRawMock.mockResolvedValue( + new Response("Bad Gateway", { status: 502 }), + ); + + expect(await runConnectionTest("/api/settings/ntfy/test")).toEqual({ + kind: "error", + errorCode: "generic", + upstreamStatus: undefined, + smtpCode: undefined, + upstreamBody: undefined, + }); + }); + + it("reports success with the latency", async () => { + apiFetchRawMock.mockResolvedValue( + envelope(200, { data: { ok: true, latencyMs: 42 }, error: null }), + ); + + expect(await runConnectionTest("/api/nightscout/test")).toEqual({ + kind: "ok", + latency: 42, + }); + }); + + it("is what the button calls", () => { + const src = readFileSync( + join(process.cwd(), "src/components/settings/test-connection-button.tsx"), + "utf8", + ); + expect(src).toMatch(/setResult\(await runConnectionTest\(endpoint\)\)/); + }); +}); diff --git a/src/components/settings/test-connection-button.tsx b/src/components/settings/test-connection-button.tsx index e741a6233..ed9f63b08 100644 --- a/src/components/settings/test-connection-button.tsx +++ b/src/components/settings/test-connection-button.tsx @@ -82,6 +82,48 @@ export function TestConnectionFailure({ ); } +export type ConnectionTestResult = + | { kind: "ok"; latency: number } + | { + kind: "error"; + errorCode: string; + upstreamStatus?: number; + smtpCode?: number; + upstreamBody?: string; + }; + +/** + * The button's probe: POST the endpoint and read the envelope into what the + * button shows. Exported so the reading of a real failure response can be + * tested without a DOM. + */ +export async function runConnectionTest( + endpoint: string, +): Promise { + try { + const res = await apiFetchRaw(endpoint, { method: "POST" }); + const json = (await res.json().catch(() => ({}))) as TestResponse; + + if (res.ok && json.data?.ok !== false) { + return { kind: "ok", latency: json.data?.latencyMs ?? 0 }; + } + const meta = json.meta; + return { + kind: "error", + errorCode: meta?.errorCode ?? "generic", + upstreamStatus: + typeof meta?.upstreamStatus === "number" + ? meta.upstreamStatus + : undefined, + smtpCode: typeof meta?.smtpCode === "number" ? meta.smtpCode : undefined, + upstreamBody: + typeof meta?.upstreamBody === "string" ? meta.upstreamBody : undefined, + }; + } catch { + return { kind: "error", errorCode: "connection_failed" }; + } +} + export function TestConnectionButton({ endpoint, disabled = false, @@ -89,49 +131,13 @@ export function TestConnectionButton({ }: TestConnectionButtonProps) { const { t } = useTranslations(); const [testing, setTesting] = useState(false); - const [result, setResult] = useState< - | { kind: "ok"; latency: number } - | { - kind: "error"; - errorCode: string; - upstreamStatus?: number; - smtpCode?: number; - upstreamBody?: string; - } - | null - >(null); + const [result, setResult] = useState(null); async function handleClick() { setTesting(true); setResult(null); try { - const res = await apiFetchRaw(endpoint, { method: "POST" }); - const json = (await res.json().catch(() => ({}))) as TestResponse; - - if (res.ok && json.data?.ok !== false) { - setResult({ - kind: "ok", - latency: json.data?.latencyMs ?? 0, - }); - } else { - const meta = json.meta; - setResult({ - kind: "error", - errorCode: meta?.errorCode ?? "generic", - upstreamStatus: - typeof meta?.upstreamStatus === "number" - ? meta.upstreamStatus - : undefined, - smtpCode: - typeof meta?.smtpCode === "number" ? meta.smtpCode : undefined, - upstreamBody: - typeof meta?.upstreamBody === "string" - ? meta.upstreamBody - : undefined, - }); - } - } catch { - setResult({ kind: "error", errorCode: "connection_failed" }); + setResult(await runConnectionTest(endpoint)); } finally { setTesting(false); }