Files
HiveGrid-Wireframes/hivegrid-cloud/apps/worker/src/jobs/push-notification.test.ts
T

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);
});
});