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); }