From 6914f844be7fad28cfad55dcbd6868852d6d5ff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 13:48:52 +0100 Subject: [PATCH] fix(push): send the browser's subscription again when the card opens The Web Push card reads its state from the browser only. When the server had lost the row, or the channel had been switched off, the card said enabled and no reminder came. Opening the card now posts the subscription the browser holds, which stores the row and turns the channel back on. A refused post (demo, push off instance-wide) changes nothing on screen. --- .../__tests__/web-push-card.browser.test.tsx | 64 +++++++++++++++++++ src/components/settings/web-push-card.tsx | 37 +++++++---- 2 files changed, 87 insertions(+), 14 deletions(-) create mode 100644 src/components/settings/__tests__/web-push-card.browser.test.tsx diff --git a/src/components/settings/__tests__/web-push-card.browser.test.tsx b/src/components/settings/__tests__/web-push-card.browser.test.tsx new file mode 100644 index 000000000..ca7ccb331 --- /dev/null +++ b/src/components/settings/__tests__/web-push-card.browser.test.tsx @@ -0,0 +1,64 @@ +/** + * The Web Push card with a subscription the browser already holds. + * + * The card reads its state from the browser alone. When the server row was + * lost, or the channel had been switched off by failed sends, the card said + * "enabled" and no reminder came; opening it now sends the subscription + * again, which stores the row and turns the channel back on. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { stubFetch } from "../../../../tests/browser/fetch-stub"; +import { renderWithApp } from "../../../../tests/browser/render"; + +import { WebPushCard } from "../web-push-card"; + +const SUBSCRIPTION = { + endpoint: "https://push.example.test/send/abc", + keys: { p256dh: "p256dh-key", auth: "auth-key" }, +}; + +function holdSubscription(): void { + const subscription = { toJSON: () => SUBSCRIPTION }; + // Headless Chromium answers "denied" without a prompt. + vi.spyOn(Notification, "permission", "get").mockReturnValue("granted"); + vi.spyOn(navigator.serviceWorker, "getRegistration").mockResolvedValue({ + pushManager: { getSubscription: async () => subscription }, + } as unknown as ServiceWorkerRegistration); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("WebPushCard", () => { + it("sends the browser's existing subscription to the server when it opens", async () => { + holdSubscription(); + const api = stubFetch().on("POST", "/api/notifications/web-push", { + data: { subscribed: true }, + }); + + const { screen } = await renderWithApp(); + + await expect + .poll(() => api.calls("POST", "/api/notifications/web-push").length) + .toBe(1); + expect(api.calls("POST", "/api/notifications/web-push")[0].body).toEqual( + SUBSCRIPTION, + ); + await expect.element(screen.getByText("Disable push")).toBeVisible(); + }); + + it("keeps showing push as on when that request fails", async () => { + holdSubscription(); + stubFetch().on("POST", "/api/notifications/web-push", { + status: 403, + error: "Demo mode: modifications are disabled", + }); + + const { screen } = await renderWithApp(); + + await expect.element(screen.getByText("Disable push")).toBeVisible(); + expect(document.querySelector('[role="alert"]')).toBeNull(); + }); +}); diff --git a/src/components/settings/web-push-card.tsx b/src/components/settings/web-push-card.tsx index f25ae2795..e7c19b0d7 100644 --- a/src/components/settings/web-push-card.tsx +++ b/src/components/settings/web-push-card.tsx @@ -38,6 +38,27 @@ export function needsHomeScreen(): boolean { return appleTouch && !window.matchMedia("(display-mode: standalone)").matches; } +/** + * Store this browser's subscription for the signed-in account. Also run for a + * subscription the browser already holds, so a row the server lost or gave + * to another account on this browser comes back, and a channel switched off + * by failed sends is turned on again. + */ +function saveSubscription(subscription: PushSubscription): Promise { + const subJson = subscription.toJSON(); + return apiFetchRaw("/api/notifications/web-push", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + endpoint: subJson.endpoint, + keys: { + p256dh: subJson.keys?.p256dh ?? "", + auth: subJson.keys?.auth ?? "", + }, + }), + }); +} + type Unsupported = "browser" | "install" | null; export function WebPushCard() { @@ -73,6 +94,7 @@ export function WebPushCard() { if (registration) { const subscription = await registration.pushManager.getSubscription(); setIsSubscribed(!!subscription); + if (subscription) void saveSubscription(subscription).catch(() => {}); } } catch { // Ignore errors during check @@ -111,20 +133,7 @@ export function WebPushCard() { applicationServerKey: urlBase64ToArrayBuffer(vapidPublicKey), }); - const subJson = subscription.toJSON(); - - const res = await apiFetchRaw("/api/notifications/web-push", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - endpoint: subJson.endpoint, - keys: { - p256dh: subJson.keys?.p256dh ?? "", - auth: subJson.keys?.auth ?? "", - }, - }), - }); - + const res = await saveSubscription(subscription); if (res.ok) { setIsSubscribed(true); setMsg(t("settings.webPushSubscribed"));