fix(push): say why a test push failed, under its own title

The Web Push test answered a refused push with `ok: false` and the
sender's `reason`, but the Test button reads `meta.errorCode`, so it
only said "Test failed". The answer now carries `upstream_rejected`, or
`connection_failed` when no push service answered.

The test push used the administrator's test title, which put the brand
first in several languages; it has its own title now.
This commit is contained in:
Marc-André Bombeck committed 2026-11-12 14:07:19 +01:00
1 parent d5980bdaee
commit e59ef4533d
14 files changed
+67 -16

No files matched your search

@@ -134,7 +134,7 @@ describe("POST /api/notifications/web-push/test", () => {
}
expect(payloadsSent()).toEqual([
expect.objectContaining({
title: "HealthLog Test-Benachrichtigung",
title: "Test-Benachrichtigung von HealthLog",
body: "Wenn du das siehst, kommen Push-Benachrichtigungen auf diesem Gerät an.",
tag: "self-test",
}),
@@ -181,6 +181,21 @@ describe("POST /api/notifications/web-push/test", () => {
expect(res.status).toBe(200);
expect(JSON.parse(text).data.ok).toBe(false);
// What the Test button reads to say why.
expect(JSON.parse(text).meta.errorCode).toBe("upstream_rejected");
expect(text).not.toMatch(/phone-secret|laptop-secret/);
});
it("tells a push service that never answered apart from one that refused", async () => {
mocks.safeFetch.mockRejectedValue(new TypeError("fetch failed"));
const res = await post();
const body = (await res.json()) as {
data: { ok: boolean };
meta?: { errorCode?: string };
};
expect(body.data.ok).toBe(false);
expect(body.meta?.errorCode).toBe("connection_failed");
});
});
@@ -1,6 +1,6 @@
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { apiSuccess, apiSuccessWithMeta, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { coerceLocale } from "@/lib/i18n/config";
@@ -34,7 +34,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const result = await sendViaWebPush(user.id, {
eventType: "SYSTEM_ALERT",
userId: user.id,
title: t("notifications.admin.testNotificationTitle"),
title: t("notifications.user.webPushTestTitle"),
message: t("notifications.user.webPushTestBody"),
metadata: { webPushTag: "self-test" },
});
@@ -54,7 +54,15 @@ export const POST = apiHandler(async (request: NextRequest) => {
annotate({
meta: { web_push_test_ok: result.ok, web_push_test_reason: result.reason },
});
return apiSuccess(
result.ok ? { ok: true, latencyMs } : { ok: false, reason: result.reason },
if (result.ok) return apiSuccess({ ok: true, latencyMs });
// The Test button says what went wrong from `meta.errorCode`, like every
// other channel's test; `reason` is the sender's own code.
return apiSuccessWithMeta(
{ ok: false, reason: result.reason },
{
errorCode: result.reason?.endsWith("_network_error")
? "connection_failed"
: "upstream_rejected",
},
);
});
@@ -101,6 +101,24 @@ describe("runConnectionTest", () => {
});
});
it("reads why a push test that ran was refused", async () => {
// The Web Push test answers 200 with `ok: false`; the code rides `meta`.
apiFetchRawMock.mockResolvedValue(
envelope(200, {
data: { ok: false, reason: "web_push_403" },
error: null,
meta: { errorCode: "upstream_rejected" },
}),
);
const result = await runConnectionTest("/api/notifications/web-push/test");
expect(result).toMatchObject({
kind: "error",
errorCode: "upstream_rejected",
});
});
it("reports success with the latency", async () => {
apiFetchRawMock.mockResolvedValue(
envelope(200, { data: { ok: true, latencyMs: 42 }, error: null }),
@@ -191,7 +191,7 @@ export const notificationTransportPaths: NonNullable<
summary: "Send a test push to every Web Push subscription",
description:
"Fires one notification to each of the caller's subscriptions, through the same sender real reminders use, with the text in the account's language. The device the test was pressed on gets it whichever subscription is newest. Endpoints the push service reports gone (404/410) are removed, as on a real send. Takes no body. Rate-limited 5 per minute per user.\n\n" +
"Read `ok`, not the status. A push that every service rejects still answers 200, with `ok: false` and a `reason`. No endpoint is returned: the full endpoint is the routing secret for that subscription.",
"Read `ok`, not the status. A push that every service rejects still answers 200, with `ok: false`, a `reason`, and `meta.errorCode`: `upstream_rejected`, or `connection_failed` when no push service answered. No endpoint is returned: the full endpoint is the routing secret for that subscription.",
responses: {
"200": {
description: