mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge channel tests that report the other end's answer, and the Gotify format
This commit is contained in:
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(
|
||||
"{"error":"Bad Request","errorCode":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("<img src=x onerror="alert(1)">");
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Reference in new issue
Block a user