import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { getPermission, isOptedIn, notificationSupport, reminderFiredToNotification, sendTestDoneToNotification, setOptedIn, showNotification, type NotificationPermission, } from "./notifications"; // --------------------------------------------------------------------------- // Fake browser environment // --------------------------------------------------------------------------- // vitest's default node env has no `window`. We install a minimal shim // before each test (and tear it down afterwards) so the helpers under // test can run as if they were in a browser tab. interface FakeStore { data: Map; } function installWindow(opts: { permission?: NotificationPermission; notificationsEnabled?: boolean; ctorThrows?: boolean; }) { const calls: Array<{ title: string; init: NotificationOptions | undefined }> = []; const store: FakeStore = { data: new Map() }; class FakeNotification { static permission: NotificationPermission = opts.permission ?? "default"; static async requestPermission(): Promise { // Default test policy: granting on request unless the test // overrides Notification.permission directly. FakeNotification.permission = "granted"; return "granted"; } onclick: (() => void) | null = null; constructor(public title: string, public init?: NotificationOptions) { if (opts.ctorThrows) throw new Error("ctor failure"); calls.push({ title, init }); } } const fakeWindow = { Notification: opts.notificationsEnabled === false ? undefined : FakeNotification, localStorage: { getItem: (k: string) => store.data.get(k) ?? null, setItem: (k: string, v: string) => { store.data.set(k, v); }, removeItem: (k: string) => { store.data.delete(k); }, }, location: { href: "/" }, focus: vi.fn(), }; // @ts-expect-error — augment globalThis at runtime globalThis.window = fakeWindow; return { calls, FakeNotification, store }; } function tearDown() { // @ts-expect-error — clean up the shim delete globalThis.window; } beforeEach(() => { // Make sure no shim leaks across tests. tearDown(); }); afterEach(tearDown); // --------------------------------------------------------------------------- // notificationSupport / getPermission // --------------------------------------------------------------------------- describe("notificationSupport / getPermission", () => { it("reports unsupported when window is missing (SSR)", () => { expect(notificationSupport()).toBe("unsupported"); expect(getPermission()).toBe("denied"); }); it("reports unsupported when the browser has no Notification API", () => { installWindow({ notificationsEnabled: false }); expect(notificationSupport()).toBe("unsupported"); expect(getPermission()).toBe("denied"); }); it("returns the live permission when supported", () => { installWindow({ permission: "granted" }); expect(notificationSupport()).toBe("supported"); expect(getPermission()).toBe("granted"); }); }); // --------------------------------------------------------------------------- // isOptedIn / setOptedIn — localStorage flag // --------------------------------------------------------------------------- describe("opt-in localStorage flag", () => { it("defaults to opted-out", () => { installWindow({ permission: "granted" }); expect(isOptedIn()).toBe(false); }); it("flips to opted-in after setOptedIn(true)", () => { installWindow({ permission: "granted" }); setOptedIn(true); expect(isOptedIn()).toBe(true); }); it("setOptedIn(false) removes the flag", () => { installWindow({ permission: "granted" }); setOptedIn(true); setOptedIn(false); expect(isOptedIn()).toBe(false); }); it("survives gracefully when window is missing", () => { expect(() => setOptedIn(true)).not.toThrow(); expect(isOptedIn()).toBe(false); }); }); // --------------------------------------------------------------------------- // showNotification — gating + dispatch // --------------------------------------------------------------------------- describe("showNotification gating", () => { it("returns 'unsupported' when there's no Notification API", () => { installWindow({ notificationsEnabled: false }); setOptedIn(true); const r = showNotification({ title: "x" }); expect(r.ok).toBe(false); if (!r.ok) expect(r.reason).toBe("unsupported"); }); it("returns 'opted-out' when the operator hasn't opted in", () => { installWindow({ permission: "granted" }); const r = showNotification({ title: "x" }); expect(r.ok).toBe(false); if (!r.ok) expect(r.reason).toBe("opted-out"); }); it("returns 'permission' when permission is default / not yet granted", () => { installWindow({ permission: "default" }); setOptedIn(true); const r = showNotification({ title: "x" }); expect(r.ok).toBe(false); if (!r.ok) expect(r.reason).toBe("permission"); }); it("returns 'permission' when blocked by the browser", () => { installWindow({ permission: "denied" }); setOptedIn(true); const r = showNotification({ title: "x" }); expect(r.ok).toBe(false); if (!r.ok) expect(r.reason).toBe("permission"); }); it("dispatches when supported + opted in + granted, and forwards body/tag/icon", () => { const { calls } = installWindow({ permission: "granted" }); setOptedIn(true); const r = showNotification({ title: "Reminder sent", body: "All groups received the message.", tag: "reminder:r-1", }); expect(r.ok).toBe(true); expect(calls).toHaveLength(1); expect(calls[0]!.title).toBe("Reminder sent"); expect(calls[0]!.init?.body).toBe("All groups received the message."); expect(calls[0]!.init?.tag).toBe("reminder:r-1"); // Defaults to /icon-192.png + /icon-192.png for badge. expect(calls[0]!.init?.icon).toBe("/icon-192.png"); expect(calls[0]!.init?.badge).toBe("/icon-192.png"); }); it("returns 'error' when the Notification ctor throws (rare)", () => { installWindow({ permission: "granted", ctorThrows: true }); setOptedIn(true); const r = showNotification({ title: "x" }); expect(r.ok).toBe(false); if (!r.ok) { expect(r.reason).toBe("error"); expect(r.error).toMatch(/ctor failure/); } }); }); // --------------------------------------------------------------------------- // Event mappers // --------------------------------------------------------------------------- describe("reminderFiredToNotification mapping", () => { it("renders 'success' as a positive 'sent' notification", () => { const args = reminderFiredToNotification({ type: "reminder.fired", reminderId: "r-1", runId: "run-1", status: "success", }); expect(args).not.toBeNull(); expect(args?.title).toBe("Reminder sent"); expect(args?.body).toMatch(/All groups received/); expect(args?.tag).toBe("reminder:r-1"); expect(args?.href).toBe("/reminders/r-1"); }); it("renders 'partial' with a hint to open Activity", () => { const args = reminderFiredToNotification({ type: "reminder.fired", reminderId: "r-2", runId: "run-2", status: "partial", }); expect(args?.title).toBe("Reminder partly sent"); expect(args?.body).toMatch(/Some groups received/); expect(args?.body).toMatch(/See activity/); }); it("renders 'failed' with a clear failure headline", () => { const args = reminderFiredToNotification({ type: "reminder.fired", reminderId: "r-3", runId: "run-3", status: "failed", }); expect(args?.title).toBe("Reminder failed"); }); it("returns null for 'skipped' (bookkeeping noise, not user-facing)", () => { const args = reminderFiredToNotification({ type: "reminder.fired", reminderId: "r-4", runId: "run-4", status: "skipped", }); expect(args).toBeNull(); }); it("uses the same tag for repeat fires of the same reminder so they coalesce", () => { const a = reminderFiredToNotification({ type: "reminder.fired", reminderId: "r-1", runId: "run-A", status: "success", }); const b = reminderFiredToNotification({ type: "reminder.fired", reminderId: "r-1", runId: "run-B", status: "success", }); expect(a?.tag).toBe(b?.tag); }); }); describe("sendTestDoneToNotification mapping", () => { it("renders ok=true as 'Test message sent' linked to the group", () => { const args = sendTestDoneToNotification({ type: "send_test.done", groupId: "g-1", ok: true, }); expect(args?.title).toBe("Test message sent"); expect(args?.tag).toBe("send-test:g-1"); expect(args?.href).toBe("/groups/g-1"); }); it("returns null on failure (toast already shows the error)", () => { const args = sendTestDoneToNotification({ type: "send_test.done", groupId: "g-1", ok: false, }); expect(args).toBeNull(); }); });