272 lines
8.8 KiB
TypeScript
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",
|
|
]);
|
|
});
|
|
});
|