Files
HiveGrid-Wireframes/hivegrid-cloud/apps/mobile/src/pushRegistration.test.ts
T

272 lines
8.8 KiB
TypeScript

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",
]);
});
});