diff --git a/web/src/hooks/usePushDevice.test.tsx b/web/src/hooks/usePushDevice.test.tsx index c90b119f..b9f552dd 100644 --- a/web/src/hooks/usePushDevice.test.tsx +++ b/web/src/hooks/usePushDevice.test.tsx @@ -24,29 +24,44 @@ function wrap() { let subscribeMock: ReturnType; let getSubMock: ReturnType; +type MockNotification = { + permission: NotificationPermission; + requestPermission: () => Promise; +}; +const g = globalThis as unknown as { + PushManager?: unknown; + Notification?: MockNotification; +}; +const nav = navigator as unknown as { serviceWorker?: unknown }; + beforeEach(() => { apiMock.mockReset(); cacheVapidMock.mockReset(); subscribeMock = vi.fn(async () => ({ toJSON: () => ({ endpoint: "https://x", keys: {} }) })); getSubMock = vi.fn(async () => null); - (globalThis as any).PushManager = function () {}; - (navigator as any).serviceWorker = { + g.PushManager = function () {}; + nav.serviceWorker = { register: vi.fn(), - ready: Promise.resolve({ pushManager: { subscribe: subscribeMock, getSubscription: getSubMock } }), + ready: Promise.resolve({ + pushManager: { subscribe: subscribeMock, getSubscription: getSubMock }, + }), + }; + g.Notification = { + permission: "default", + requestPermission: vi.fn(async (): Promise => "granted"), }; - (globalThis as any).Notification = { permission: "default", requestPermission: vi.fn(async () => "granted") }; }); afterEach(() => vi.restoreAllMocks()); describe("usePushDevice", () => { it("reports unsupported when PushManager missing", () => { - delete (globalThis as any).PushManager; + delete g.PushManager; const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() }); expect(result.current.status).toBe("unsupported"); }); it("reports blocked when permission denied", () => { - (globalThis as any).Notification.permission = "denied"; + g.Notification!.permission = "denied"; const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() }); expect(result.current.status).toBe("blocked"); }); @@ -57,7 +72,9 @@ describe("usePushDevice", () => { return Promise.resolve(undefined); // PUT device }); const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() }); - await act(async () => { await result.current.enable(); }); + await act(async () => { + await result.current.enable(); + }); expect(subscribeMock).toHaveBeenCalledOnce(); expect(cacheVapidMock).toHaveBeenCalledWith("AQID"); const putCall = apiMock.mock.calls.find((c) => c[0] === "/notifications/push/device"); @@ -67,9 +84,13 @@ describe("usePushDevice", () => { }); it("enable(): permission denied short-circuits, no subscribe", async () => { - (globalThis as any).Notification.requestPermission = vi.fn(async () => "denied"); + g.Notification!.requestPermission = vi.fn( + async (): Promise => "denied", + ); const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() }); - await act(async () => { await result.current.enable(); }); + await act(async () => { + await result.current.enable(); + }); expect(subscribeMock).not.toHaveBeenCalled(); expect(result.current.status).toBe("blocked"); }); @@ -79,9 +100,13 @@ describe("usePushDevice", () => { getSubMock.mockResolvedValue({ unsubscribe: unsub }); apiMock.mockResolvedValue(undefined); const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() }); - await act(async () => { await result.current.disable(); }); + await act(async () => { + await result.current.disable(); + }); expect(unsub).toHaveBeenCalled(); - const del = apiMock.mock.calls.find((c) => c[0] === "/notifications/push/device" && c[1]?.method === "DELETE"); + const del = apiMock.mock.calls.find( + (c) => c[0] === "/notifications/push/device" && c[1]?.method === "DELETE", + ); expect(del).toBeTruthy(); await waitFor(() => expect(result.current.status).toBe("off")); }); @@ -103,16 +128,23 @@ describe("usePushDevice", () => { // Start enable() without awaiting — it stalls at the webpush-key call (gen=1). let innerEnableResolve!: () => void; - const innerEnableDone = new Promise((res) => { innerEnableResolve = res; }); + const innerEnableDone = new Promise((res) => { + innerEnableResolve = res; + }); act(() => { void result.current.enable().then(innerEnableResolve, innerEnableResolve); }); // Drain microtasks so enable() reaches the webpush-key await (past requestPermission + sw.ready). - await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); // Run disable() to full completion — increments gen to 2, sets status "off". - await act(async () => { await result.current.disable(); }); + await act(async () => { + await result.current.disable(); + }); // Reject the stale enable()'s webpush-key call — gen=1 is superseded so // the catch's set("off") is a no-op; disable's "off" (gen=2) must survive. diff --git a/web/src/pages/admin-settings/PushSettings.test.tsx b/web/src/pages/admin-settings/PushSettings.test.tsx index 20191c59..4e4a4a17 100644 --- a/web/src/pages/admin-settings/PushSettings.test.tsx +++ b/web/src/pages/admin-settings/PushSettings.test.tsx @@ -48,10 +48,7 @@ describe("PushSettings", () => { it("reflects configured status on non-password fields", () => { render(); // SettingField renders placeholder "configured" for a configured field. - expect(screen.getByLabelText("VAPID Public Key")).toHaveAttribute( - "placeholder", - "configured", - ); + expect(screen.getByLabelText("VAPID Public Key")).toHaveAttribute("placeholder", "configured"); expect(screen.getByLabelText("Key ID")).toHaveAttribute("placeholder", "configured"); // A field that is not in the configured list does not get the indicator. expect(screen.getByLabelText("Team ID")).not.toHaveAttribute("placeholder", "configured"); diff --git a/web/src/pages/admin-settings/PushSettings.tsx b/web/src/pages/admin-settings/PushSettings.tsx index 2b71c256..df529262 100644 --- a/web/src/pages/admin-settings/PushSettings.tsx +++ b/web/src/pages/admin-settings/PushSettings.tsx @@ -60,7 +60,7 @@ function CredentialField({ placeholder={sensitiveConfigured ? "configured" : hint} aria-describedby={hint ? hintId : undefined} rows={5} - className="border-input bg-background ring-offset-background focus-visible:ring-ring max-w-md w-full rounded-md border px-3 py-2 font-mono text-xs focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none" + className="border-input bg-background ring-offset-background focus-visible:ring-ring w-full max-w-md rounded-md border px-3 py-2 font-mono text-xs focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none" /> {hint && (

@@ -242,9 +242,7 @@ function FcmCard() { const [serviceAccountJson, setServiceAccountJson] = useState(""); - const jsonConfigured = new Set(sensitive?.configured ?? []).has( - "push.fcm.service_account_json", - ); + const jsonConfigured = new Set(sensitive?.configured ?? []).has("push.fcm.service_account_json"); return (

diff --git a/web/src/pages/settings/NotificationsSettings.test.tsx b/web/src/pages/settings/NotificationsSettings.test.tsx index 1b474b1a..ecd943fd 100644 --- a/web/src/pages/settings/NotificationsSettings.test.tsx +++ b/web/src/pages/settings/NotificationsSettings.test.tsx @@ -295,9 +295,7 @@ describe("NotificationsSettings", () => { await render(); - expect(container.textContent).toContain( - "This browser doesn’t support push notifications.", - ); + expect(container.textContent).toContain("This browser doesn’t support push notifications."); const labels = Array.from(container.querySelectorAll("label")); const deviceLabel = labels.find((l) => l.textContent?.trim() === "Push on this device"); diff --git a/web/src/pages/settings/NotificationsSettings.tsx b/web/src/pages/settings/NotificationsSettings.tsx index bb219491..c53e36ba 100644 --- a/web/src/pages/settings/NotificationsSettings.tsx +++ b/web/src/pages/settings/NotificationsSettings.tsx @@ -129,7 +129,8 @@ export default function NotificationsSettings() { {devicesLoading ? null : devices && devices.length > 0 ? ( devices.map((d) => { const registered = d.registered_at - ? (timeAgo(d.registered_at, "Registered") ?? `Registered ${new Date(d.registered_at).toLocaleDateString()}`) + ? (timeAgo(d.registered_at, "Registered") ?? + `Registered ${new Date(d.registered_at).toLocaleDateString()}`) : null; return (