Merge channel tests that report the other end's answer, and the Gotify format

This commit is contained in:
Marc-André Bombeck committed 2026-09-13 14:30:14 +02:00
commit 4306bf1aec
40 files changed
+2059 -119

No files matched your search

@@ -154,4 +154,27 @@ describe("notification channel cards", () => {
);
},
);
it("webhook card offers the payload format and sends it with the full save (#947)", async () => {
const html = render(<WebhookCard isAuthenticated />);
expect(html).toContain('id="webhook-format"');
expect(html).toMatch(/<option value="generic" selected="">/);
expect(html).toContain('<option value="gotify">');
// The full save is the first mutation of the last render, whose closure
// holds the values seeded from the server.
const save = mocks.mutationOptions[mocks.mutationOptions.length - 2]!;
await save.mutationFn(false);
const [, init] = mocks.apiFetchRaw.mock.calls[0] as [
string,
{ body: string },
];
expect(JSON.parse(init.body)).toMatchObject({
url: "https://saved.example/hook",
format: "generic",
enabled: false,
});
});
});
@@ -0,0 +1,61 @@
/**
* The test-connection failure callout (#947).
*
* SSR-only, like the rest of the component suite: the callout is hook-free so
* it renders here without a provider. What is pinned is what a person reads:
* the translated sentence, the status the other end answered, and the relay's
* own words as text, never as markup.
*/
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { TestConnectionFailure } from "../test-connection-button";
describe("TestConnectionFailure", () => {
it("shows the sentence with the HTTP status and what the relay said", () => {
const html = renderToStaticMarkup(
<TestConnectionFailure
message="The other end refused the message"
upstreamStatus={400}
upstreamBody='{"error":"Bad Request","errorCode":400}'
/>,
);
expect(html).toContain('role="alert"');
expect(html).toContain("The other end refused the message (HTTP 400)");
expect(html).toContain(
"{&quot;error&quot;:&quot;Bad Request&quot;,&quot;errorCode&quot;:400}",
);
});
it("renders a markup-shaped body as characters", () => {
const html = renderToStaticMarkup(
<TestConnectionFailure
message="Upstream returned an error"
upstreamStatus={502}
upstreamBody='<img src=x onerror="alert(1)">'
/>,
);
expect(html).not.toContain("<img");
expect(html).toContain("&lt;img src=x onerror=&quot;alert(1)&quot;&gt;");
});
it("shows the SMTP code when there is no HTTP status", () => {
const html = renderToStaticMarkup(
<TestConnectionFailure message="Credentials rejected" smtpCode={535} />,
);
expect(html).toContain("Credentials rejected (SMTP 535)");
});
it("shows the sentence alone when the server sent no detail", () => {
const html = renderToStaticMarkup(
<TestConnectionFailure message="Test failed" />,
);
expect(html).toContain("Test failed");
expect(html).not.toContain("HTTP");
expect(html).not.toContain("test-connection-upstream-body");
});
});
@@ -11,7 +11,10 @@ import { apiFetchRaw } from "@/lib/api/api-fetch";
* Shared "Test connection" UX for the settings integrations + notifications
* sections (A8-UI). POSTs to the given endpoint, expects a `{ data, error,
* meta }` envelope, and surfaces success (latency) or a translated
* `meta.errorCode` callout.
* `meta.errorCode` callout. When the route reports what the other end
* answered (`meta.upstreamStatus` / `meta.smtpCode` / `meta.upstreamBody`,
* the notification test routes since #947), the callout shows the status and
* the relay's own words beneath it, as plain text.
*
* Endpoints are user-scoped, so the button intentionally does NOT send an
* Idempotency-Key — each click probes the upstream live.
@@ -27,7 +30,56 @@ export interface TestConnectionButtonProps {
interface TestResponse {
data?: { latencyMs?: number; ok?: boolean; sent?: number };
error?: string;
meta?: { errorCode?: string };
meta?: {
errorCode?: string;
upstreamStatus?: number;
smtpCode?: number;
upstreamBody?: string;
};
}
export interface TestConnectionFailureProps {
/** The translated error sentence. */
message: string;
upstreamStatus?: number;
smtpCode?: number;
/** What the other end answered, already bounded and cleaned by the server. */
upstreamBody?: string;
}
/**
* The failure callout. Hook-free so it renders without a provider. The
* relay's body is a React text child, never markup: whatever it contains is
* shown as characters.
*/
export function TestConnectionFailure({
message,
upstreamStatus,
smtpCode,
upstreamBody,
}: TestConnectionFailureProps) {
const code =
typeof upstreamStatus === "number"
? ` (HTTP ${upstreamStatus})`
: typeof smtpCode === "number"
? ` (SMTP ${smtpCode})`
: "";
return (
<div role="alert" className="space-y-1">
<p className="text-destructive flex items-center gap-1.5 text-sm">
<XCircle className="size-3.5 shrink-0" />
{`${message}${code}`}
</p>
{upstreamBody ? (
<p
data-testid="test-connection-upstream-body"
className="text-foreground font-mono text-xs break-all"
>
{upstreamBody}
</p>
) : null}
</div>
);
}
export function TestConnectionButton({
@@ -39,7 +91,13 @@ export function TestConnectionButton({
const [testing, setTesting] = useState(false);
const [result, setResult] = useState<
| { kind: "ok"; latency: number }
| { kind: "error"; errorCode: string }
| {
kind: "error";
errorCode: string;
upstreamStatus?: number;
smtpCode?: number;
upstreamBody?: string;
}
| null
>(null);
@@ -56,9 +114,20 @@ export function TestConnectionButton({
latency: json.data?.latencyMs ?? 0,
});
} else {
const meta = json.meta;
setResult({
kind: "error",
errorCode: json.meta?.errorCode ?? "generic",
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 {
@@ -109,13 +178,12 @@ export function TestConnectionButton({
)}
{result?.kind === "error" && (
<p
role="alert"
className="text-destructive flex items-center gap-1.5 text-sm"
>
<XCircle className="h-3.5 w-3.5" />
{describeError(result.errorCode)}
</p>
<TestConnectionFailure
message={describeError(result.errorCode)}
upstreamStatus={result.upstreamStatus}
smtpCode={result.smtpCode}
upstreamBody={result.upstreamBody}
/>
)}
</div>
);
+31 -1
View File
@@ -10,6 +10,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { PasswordInput } from "@/components/ui/password-input";
import { NativeSelect } from "@/components/ui/native-select";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
@@ -21,12 +22,14 @@ import {
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import type { WebhookPayloadFormat } from "@/lib/notifications/types";
interface WebhookSettings {
enabled: boolean;
url: string;
headerName: string;
hasHeaderValue: boolean;
format?: WebhookPayloadFormat;
}
export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
@@ -35,6 +38,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
const [url, setUrl] = useState("");
const [headerName, setHeaderName] = useState("");
const [headerValue, setHeaderValue] = useState("");
const [format, setFormat] = useState<WebhookPayloadFormat>("generic");
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
null,
@@ -50,13 +54,14 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
// React-recommended sync-from-server pattern (no setState-in-effect).
const settingsKey = settings
? `${settings.url}|${settings.headerName}`
? `${settings.url}|${settings.headerName}|${settings.format ?? "generic"}`
: null;
const [seededKey, setSeededKey] = useState<string | null>(null);
if (settingsKey && settingsKey !== seededKey) {
setSeededKey(settingsKey);
setUrl(settings!.url);
setHeaderName(settings!.headerName);
setFormat(settings!.format === "gotify" ? "gotify" : "generic");
}
const save = useMutation({
@@ -68,6 +73,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
url,
headerName: headerName || undefined,
headerValue: headerValue || undefined,
format,
enabled,
}),
});
@@ -180,6 +186,30 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
{t("settings.webhookUrlHint")}
</p>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="webhook-format">
{t("settings.webhookFormat")}
</Label>
<NativeSelect
id="webhook-format"
value={format}
onChange={(e) =>
setFormat(e.target.value === "gotify" ? "gotify" : "generic")
}
>
<option value="generic">
{t("settings.webhookFormatGeneric")}
</option>
<option value="gotify">
{t("settings.webhookFormatGotify")}
</option>
</NativeSelect>
{format === "gotify" && (
<p className="text-muted-foreground text-xs">
{t("settings.webhookFormatGotifyHint")}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="webhook-header-name">
{t("settings.webhookHeaderName")}