import { beforeEach, describe, expect, it, vi } from "vitest"; import { type MobileSession, type SessionStorage } from "./auth.js"; import { registerPushNotifications, type PushNotificationsBridge } from "./pushRegistration.js"; function createMemoryStorage(session: MobileSession | null): SessionStorage { return { async read() { return session; }, async write(nextSession) { session = nextSession; }, async clear() { session = null; }, }; } function createFailingStorage(): SessionStorage { return { async read() { throw new Error("keychain locked"); }, async write() { /* noop */ }, async clear() { /* noop */ }, }; } function createPushPlugin( options: { currentPermission?: "prompt" | "prompt-with-rationale" | "granted" | "denied"; requestedPermission?: "prompt" | "prompt-with-rationale" | "granted" | "denied"; token?: string; failRegistration?: boolean; } = {} ): PushNotificationsBridge & { calls: string[] } { const calls: string[] = []; let registrationListener: ((token: { value: string }) => void) | null = null; let errorListener: ((error: unknown) => void) | null = null; return { calls, async checkPermissions() { calls.push("checkPermissions"); return { receive: options.currentPermission ?? "granted" }; }, async requestPermissions() { calls.push("requestPermissions"); return { receive: options.requestedPermission ?? "granted" }; }, async register() { calls.push("register"); queueMicrotask(() => { if (options.failRegistration) { errorListener?.(new Error("native registration failed")); return; } registrationListener?.({ value: options.token ?? "native-token-1" }); }); }, async addListener(eventName, listenerFunc) { calls.push(`addListener:${eventName}`); if (eventName === "registration") { registrationListener = listenerFunc as (token: { value: string }) => void; } else { errorListener = listenerFunc as (error: unknown) => void; } return { async remove() { calls.push(`remove:${eventName}`); }, }; }, }; } describe("registerPushNotifications", () => { beforeEach(() => { vi.restoreAllMocks(); }); it("keeps apiBaseUrl as a required caller contract", async () => { await expect(registerPushNotifications({ apiBaseUrl: "" })).rejects.toThrow("apiBaseUrl is required"); }); it("skips defensively when running on web", async () => { const pushPlugin = createPushPlugin(); const result = await registerPushNotifications({ apiBaseUrl: "https://api.example.test", platform: "web", pushPlugin, storage: createMemoryStorage({ token: "bearer-1", userId: "u-1", tenantId: "t-1" }), }); expect(result).toEqual({ status: "skipped", reason: "web-platform" }); expect(pushPlugin.calls).toEqual([]); }); it("skips when no mobile session is stored", async () => { const pushPlugin = createPushPlugin(); const result = await registerPushNotifications({ apiBaseUrl: "https://api.example.test", platform: "ios", pushPlugin, storage: createMemoryStorage(null), }); expect(result).toEqual({ status: "skipped", reason: "no-session" }); expect(pushPlugin.calls).toEqual([]); }); it("skips when the stored session cannot be read", async () => { const pushPlugin = createPushPlugin(); const result = await registerPushNotifications({ apiBaseUrl: "https://api.example.test", platform: "ios", pushPlugin, storage: createFailingStorage(), }); expect(result).toEqual({ status: "skipped", reason: "no-session" }); expect(pushPlugin.calls).toEqual([]); }); it("skips unsupported native platforms before touching the push bridge", async () => { const pushPlugin = createPushPlugin(); const result = await registerPushNotifications({ apiBaseUrl: "https://api.example.test", platform: "electron" as "ios", pushPlugin, storage: createMemoryStorage({ token: "bearer-1", userId: "u-1", tenantId: null }), }); expect(result).toEqual({ status: "skipped", reason: "unsupported-platform" }); expect(pushPlugin.calls).toEqual([]); }); it("requests permission and skips when the user denies push access", async () => { const pushPlugin = createPushPlugin({ currentPermission: "prompt", requestedPermission: "denied" }); const result = await registerPushNotifications({ apiBaseUrl: "https://api.example.test", platform: "android", pushPlugin, storage: createMemoryStorage({ token: "bearer-1", userId: "u-1", tenantId: null }), }); expect(result).toEqual({ status: "skipped", reason: "permission-denied" }); expect(pushPlugin.calls).toEqual(["checkPermissions", "requestPermissions"]); }); it("registers an iOS APNs token through the mobile API client", async () => { const requests: Array<{ url: string; authorization: string | null; body: unknown }> = []; const fakeFetch: typeof fetch = async (input, init) => { requests.push({ url: String(input), authorization: new Headers(init?.headers).get("authorization"), body: init?.body ? JSON.parse(String(init.body)) : null, }); return new Response(JSON.stringify({ success: true, data: { id: "dt-1" } }), { status: 200, headers: { "content-type": "application/json" }, }); }; const pushPlugin = createPushPlugin({ currentPermission: "prompt", requestedPermission: "granted", token: " apns-token-1 " }); const result = await registerPushNotifications({ apiBaseUrl: "https://api.example.test/", appVersion: "1.2.3", platform: "ios", pushPlugin, storage: createMemoryStorage({ token: "bearer-1", userId: "u-1", tenantId: "t-1" }), fetchImpl: fakeFetch, }); expect(result).toEqual({ status: "registered", platform: "ios", pushProvider: "apns" }); expect(pushPlugin.calls).toEqual([ "checkPermissions", "requestPermissions", "addListener:registration", "addListener:registrationError", "register", "remove:registration", "remove:registrationError", ]); expect(requests).toEqual([ { url: "https://api.example.test/v1/devices/register", authorization: "Bearer bearer-1", body: { token: "apns-token-1", userId: "u-1", tenantId: "t-1", platform: "ios", provider: "apns", pushProvider: "apns", appVersion: "1.2.3", }, }, ]); }); it("registers Android with the FCM provider", async () => { const bodies: unknown[] = []; const fakeFetch: typeof fetch = async (_input, init) => { bodies.push(init?.body ? JSON.parse(String(init.body)) : null); return new Response(JSON.stringify({ success: true }), { status: 200, headers: { "content-type": "application/json" }, }); }; const pushPlugin = createPushPlugin({ token: "fcm-token-1" }); const result = await registerPushNotifications({ apiBaseUrl: "https://api.example.test", platform: "android", pushPlugin, storage: createMemoryStorage({ token: "bearer-1", userId: "u-1", tenantId: null }), fetchImpl: fakeFetch, }); expect(result).toEqual({ status: "registered", platform: "android", pushProvider: "fcm" }); expect(pushPlugin.calls).not.toContain("requestPermissions"); expect(bodies).toEqual([ { token: "fcm-token-1", userId: "u-1", tenantId: null, platform: "android", provider: "fcm", pushProvider: "fcm", }, ]); }); it("skips when the native registration callback fails", async () => { const result = await registerPushNotifications({ apiBaseUrl: "https://api.example.test", platform: "ios", pushPlugin: createPushPlugin({ failRegistration: true }), storage: createMemoryStorage({ token: "bearer-1", userId: "u-1", tenantId: "t-1" }), timeoutMs: 10, }); expect(result).toEqual({ status: "skipped", reason: "registration-failed" }); }); it("skips when native registration returns an empty token", async () => { const pushPlugin = createPushPlugin({ token: " " }); const result = await registerPushNotifications({ apiBaseUrl: "https://api.example.test", platform: "ios", pushPlugin, storage: createMemoryStorage({ token: "bearer-1", userId: "u-1", tenantId: "t-1" }), timeoutMs: 10, }); expect(result).toEqual({ status: "skipped", reason: "registration-failed" }); expect(pushPlugin.calls).toEqual([ "checkPermissions", "addListener:registration", "addListener:registrationError", "register", "remove:registration", "remove:registrationError", ]); }); });