213 lines
7.3 KiB
TypeScript
213 lines
7.3 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import {
|
|
dispatchPushNotification,
|
|
redactToken,
|
|
REVOCATION_ERROR_CODES,
|
|
type DeviceTokenRow,
|
|
type PushFanOutInput,
|
|
type PushPayload,
|
|
type PushProviderSeam,
|
|
} from "./push-notification.js";
|
|
|
|
function makeRow(overrides: Partial<DeviceTokenRow>): DeviceTokenRow {
|
|
return {
|
|
id: overrides.id ?? "dt-1",
|
|
token: overrides.token ?? "tokAAAAAAAAAAAAAA",
|
|
userId: overrides.userId ?? "u-1",
|
|
tenantId: overrides.tenantId ?? null,
|
|
platform: overrides.platform ?? "ios",
|
|
pushProvider: overrides.pushProvider ?? "apns",
|
|
revokedAt: overrides.revokedAt ?? null,
|
|
};
|
|
}
|
|
|
|
function makePayload(overrides: Partial<PushPayload> = {}): PushPayload {
|
|
return {
|
|
userId: overrides.userId ?? "u-1",
|
|
tenantId: overrides.tenantId ?? null,
|
|
title: overrides.title ?? "Hello",
|
|
body: overrides.body ?? "World",
|
|
data: overrides.data,
|
|
};
|
|
}
|
|
|
|
function makeProviders(overrides: Partial<PushProviderSeam> = {}): PushProviderSeam {
|
|
return {
|
|
sendApns: overrides.sendApns ?? vi.fn().mockResolvedValue({ success: true, messageId: "apns-1" }),
|
|
sendFcm: overrides.sendFcm ?? vi.fn().mockResolvedValue({ success: true, messageId: "fcm-1" }),
|
|
sendWebPush: overrides.sendWebPush ?? vi.fn().mockResolvedValue({ success: true, messageId: "web-1" }),
|
|
};
|
|
}
|
|
|
|
function deferred() {
|
|
let resolve!: () => void;
|
|
const promise = new Promise<void>((res) => {
|
|
resolve = res;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
describe("dispatchPushNotification", () => {
|
|
it("returns zero attempts when no active tokens exist", async () => {
|
|
const input: PushFanOutInput = {
|
|
payload: makePayload(),
|
|
tokenLister: async () => [],
|
|
providers: makeProviders(),
|
|
tokenRevoker: vi.fn(),
|
|
};
|
|
const result = await dispatchPushNotification(input);
|
|
expect(result).toEqual({ attempted: 0, succeeded: 0, revoked: 0, failed: 0 });
|
|
});
|
|
|
|
it("filters out tokens with revokedAt set", async () => {
|
|
const providers = makeProviders();
|
|
const input: PushFanOutInput = {
|
|
payload: makePayload(),
|
|
tokenLister: async () => [
|
|
makeRow({ token: "t-active" }),
|
|
makeRow({ id: "dt-2", token: "t-revoked", revokedAt: new Date() }),
|
|
],
|
|
providers,
|
|
tokenRevoker: vi.fn(),
|
|
};
|
|
const result = await dispatchPushNotification(input);
|
|
expect(result.attempted).toBe(1);
|
|
expect(result.succeeded).toBe(1);
|
|
expect(providers.sendApns).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("routes ios tokens to APNs, android to FCM, web to WebPush", async () => {
|
|
const providers = makeProviders();
|
|
const input: PushFanOutInput = {
|
|
payload: makePayload(),
|
|
tokenLister: async () => [
|
|
makeRow({ token: "tok-ios", platform: "ios", pushProvider: "apns" }),
|
|
makeRow({ id: "dt-2", token: "tok-and", platform: "android", pushProvider: "fcm" }),
|
|
makeRow({ id: "dt-3", token: "tok-web", platform: "web", pushProvider: "webpush" }),
|
|
],
|
|
providers,
|
|
tokenRevoker: vi.fn(),
|
|
};
|
|
const result = await dispatchPushNotification(input);
|
|
expect(result.succeeded).toBe(3);
|
|
expect(providers.sendApns).toHaveBeenCalledWith("tok-ios", expect.any(Object));
|
|
expect(providers.sendFcm).toHaveBeenCalledWith("tok-and", expect.any(Object));
|
|
expect(providers.sendWebPush).toHaveBeenCalledWith("tok-web", expect.any(Object));
|
|
});
|
|
|
|
it("starts multiple provider sends before the first delayed send resolves", async () => {
|
|
const releases = Array.from({ length: 3 }, () => deferred());
|
|
const started: string[] = [];
|
|
const completed: string[] = [];
|
|
let sendIndex = 0;
|
|
const providers = makeProviders({
|
|
sendApns: vi.fn(async (token) => {
|
|
const release = releases[sendIndex++]!;
|
|
started.push(token);
|
|
await release.promise;
|
|
completed.push(token);
|
|
return { success: true, messageId: `apns-${token}` };
|
|
}),
|
|
});
|
|
const input: PushFanOutInput = {
|
|
payload: makePayload(),
|
|
tokenLister: async () => [
|
|
makeRow({ id: "dt-1", token: "tok-one" }),
|
|
makeRow({ id: "dt-2", token: "tok-two" }),
|
|
makeRow({ id: "dt-3", token: "tok-three" }),
|
|
],
|
|
providers,
|
|
tokenRevoker: vi.fn(),
|
|
};
|
|
|
|
const resultPromise = dispatchPushNotification(input);
|
|
try {
|
|
await vi.waitFor(
|
|
() => {
|
|
expect(started.length).toBeGreaterThanOrEqual(2);
|
|
expect(completed).toEqual([]);
|
|
},
|
|
{ timeout: 100 }
|
|
);
|
|
} finally {
|
|
for (const release of releases) {
|
|
release.resolve();
|
|
}
|
|
}
|
|
|
|
await expect(resultPromise).resolves.toEqual({ attempted: 3, succeeded: 3, revoked: 0, failed: 0 });
|
|
});
|
|
|
|
it("revokes tokens when the provider reports an irrecoverable error code", async () => {
|
|
const revoker = vi.fn();
|
|
const providers = makeProviders({
|
|
sendApns: vi.fn().mockResolvedValue({ success: false, errorCode: "Unregistered" }),
|
|
});
|
|
const input: PushFanOutInput = {
|
|
payload: makePayload(),
|
|
tokenLister: async () => [makeRow({ token: "tok-dead" })],
|
|
providers,
|
|
tokenRevoker: revoker,
|
|
};
|
|
const result = await dispatchPushNotification(input);
|
|
expect(result.revoked).toBe(1);
|
|
expect(result.failed).toBe(0);
|
|
expect(revoker).toHaveBeenCalledWith("tok-dead");
|
|
});
|
|
|
|
it("counts other errors as failed without revoking", async () => {
|
|
const revoker = vi.fn();
|
|
const providers = makeProviders({
|
|
sendApns: vi.fn().mockResolvedValue({ success: false, errorCode: "InternalServerError" }),
|
|
});
|
|
const input: PushFanOutInput = {
|
|
payload: makePayload(),
|
|
tokenLister: async () => [makeRow({ token: "tok-flaky" })],
|
|
providers,
|
|
tokenRevoker: revoker,
|
|
};
|
|
const result = await dispatchPushNotification(input);
|
|
expect(result.failed).toBe(1);
|
|
expect(result.revoked).toBe(0);
|
|
expect(revoker).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("emits an info log on successful delivery and a warn log on revocation", async () => {
|
|
const info = vi.fn();
|
|
const warn = vi.fn();
|
|
const providers = makeProviders({
|
|
sendApns: vi.fn()
|
|
.mockResolvedValueOnce({ success: true, messageId: "id-1" })
|
|
.mockResolvedValueOnce({ success: false, errorCode: "BadDeviceToken" }),
|
|
});
|
|
const input: PushFanOutInput = {
|
|
payload: makePayload(),
|
|
tokenLister: async () => [
|
|
makeRow({ id: "a", token: "tok-good" }),
|
|
makeRow({ id: "b", token: "tok-bad" }),
|
|
],
|
|
providers,
|
|
tokenRevoker: vi.fn(),
|
|
logger: { info, warn },
|
|
};
|
|
await dispatchPushNotification(input);
|
|
expect(info).toHaveBeenCalledWith(expect.objectContaining({ messageId: "id-1" }), "push delivered");
|
|
expect(warn).toHaveBeenCalledWith(expect.objectContaining({ errorCode: "BadDeviceToken" }), "push provider reported token revoked");
|
|
});
|
|
});
|
|
|
|
describe("redactToken", () => {
|
|
it("emits an ellipsis-truncated prefix", () => {
|
|
expect(redactToken("abcdefghij")).toBe("abcdefgh…");
|
|
expect(redactToken("short")).toBe("sh…");
|
|
});
|
|
});
|
|
|
|
describe("REVOCATION_ERROR_CODES", () => {
|
|
it("includes both APNs and FCM canonical errors", () => {
|
|
expect(REVOCATION_ERROR_CODES.has("Unregistered")).toBe(true);
|
|
expect(REVOCATION_ERROR_CODES.has("BadDeviceToken")).toBe(true);
|
|
expect(REVOCATION_ERROR_CODES.has("messaging/registration-token-not-registered")).toBe(true);
|
|
});
|
|
});
|