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