Files
notification-wakeup-service/test/alertSearch/notify.test.ts
T

235 lines
6.5 KiB
TypeScript

import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import { db as fcmDb } from "../../src/db/fcmTokensSqlite.js";
import { closeDatabase } from "../../src/db/sqlite.js";
import type { DailyAlertSearchResult } from "../../src/alertSearch/daily.js";
import type { AlertSearchDigest } from "../../src/alertSearch/digest.js";
import {
ALERT_SEARCH_FCM_TYPE,
ALERT_SEARCH_NOTIFICATION_TITLE,
alertSearchNotificationBody,
buildAlertSearchNotificationContent,
deliverAlertSearchNotification,
isAlertSearchNotificationEligible,
} from "../../src/alertSearch/notify.js";
const USER = "did:ethr:0xnotifyuser";
function emptyDigest(
overrides: Partial<AlertSearchDigest> = {}
): AlertSearchDigest {
return {
completed: true,
hasUpdates: false,
totalCount: 0,
counts: {
claims: 0,
personalPlanContributions: 0,
trackedPlanUpdates: 0,
trackedPlanClaims: 0,
plansNearby: 0,
profilesNearby: 0,
},
records: {
claims: [],
personalPlanContributions: [],
trackedPlanUpdates: [],
trackedPlanClaims: [],
plansNearby: [],
profilesNearby: [],
},
endorser: { outcome: "empty", completed: true },
partner: { outcome: "empty", completed: true },
...overrides,
};
}
function updatesDigest(totalCount: number): AlertSearchDigest {
return emptyDigest({
completed: true,
hasUpdates: true,
totalCount,
counts: {
claims: totalCount,
personalPlanContributions: 0,
trackedPlanUpdates: 0,
trackedPlanClaims: 0,
plansNearby: 0,
profilesNearby: 0,
},
records: {
claims: Array.from({ length: totalCount }, (_, i) => ({
id: `01H${String(i).padStart(23, "0")}`,
issuedAt: "2026-01-01T00:00:00Z",
issuer: USER,
})),
personalPlanContributions: [],
trackedPlanUpdates: [],
trackedPlanClaims: [],
plansNearby: [],
profilesNearby: [],
},
endorser: { outcome: "success", completed: true },
partner: { outcome: "empty", completed: true },
});
}
function daily(
overrides: Partial<DailyAlertSearchResult> = {}
): DailyAlertSearchResult {
return {
userId: USER,
utcDay: "2026-08-14",
batchId: "batch-1",
jwtSequence: 1,
endorserOutcome: "success",
partnerOutcome: "empty",
completed: true,
consumed: true,
digest: updatesDigest(7),
...overrides,
};
}
describe("alertSearch notification gate", () => {
it("does not notify when digest is null", () => {
assert.equal(
isAlertSearchNotificationEligible(daily({ digest: null, consumed: false })),
false
);
});
it("does not notify when the digest is incomplete", () => {
assert.equal(
isAlertSearchNotificationEligible(
daily({
completed: false,
consumed: false,
digest: emptyDigest({
completed: false,
hasUpdates: false,
totalCount: 3,
endorser: { outcome: "success", completed: true },
partner: { outcome: "auth", completed: false },
}),
})
),
false
);
});
it("does not notify on a completed empty digest", () => {
assert.equal(
isAlertSearchNotificationEligible(
daily({
digest: emptyDigest(),
consumed: true,
})
),
false
);
});
it("notifies when completed with updates and today's JWT was consumed", () => {
assert.equal(isAlertSearchNotificationEligible(daily()), true);
});
it("does not notify when completed with updates but JWT was not consumed", () => {
assert.equal(
isAlertSearchNotificationEligible(daily({ consumed: false })),
false
);
});
it("puts totalCount in the body without record contents", () => {
const content = buildAlertSearchNotificationContent(7);
assert.equal(content.title, ALERT_SEARCH_NOTIFICATION_TITLE);
assert.equal(content.body, "You have 7 new updates.");
assert.equal(content.type, ALERT_SEARCH_FCM_TYPE);
assert.equal(content.body.includes(USER), false);
assert.equal(content.body.includes("01H"), false);
assert.equal(alertSearchNotificationBody(1), "You have 1 new update.");
});
});
describe("deliverAlertSearchNotification", () => {
let dir: string;
let previousDataDir: string | undefined;
beforeEach(async () => {
previousDataDir = process.env.NOTIFY_DATA_DIR;
dir = await mkdtemp(path.join(tmpdir(), "alert-search-notify-"));
process.env.NOTIFY_DATA_DIR = dir;
closeDatabase();
});
afterEach(async () => {
closeDatabase();
if (previousDataDir === undefined) {
delete process.env.NOTIFY_DATA_DIR;
} else {
process.env.NOTIFY_DATA_DIR = previousDataDir;
}
await rm(dir, { recursive: true, force: true });
});
it("sends once per distinct token for an eligible digest", async () => {
await fcmDb.upsert({
userId: USER,
deviceId: "d1",
fcmToken: "token-a",
platform: "ios",
updatedAt: new Date(),
});
await fcmDb.upsert({
userId: USER,
deviceId: "d2",
fcmToken: "token-b",
platform: "ios",
updatedAt: new Date(),
});
const sent: string[] = [];
const bodies: string[] = [];
const result = await deliverAlertSearchNotification(daily(), {
send: async (token, content) => {
sent.push(token);
bodies.push(content.body);
assert.equal(content.title, "TimeSafari");
assert.equal(content.type, "alert_search");
return "sent";
},
});
assert.equal(result.eligible, true);
assert.equal(result.sent, 2);
assert.deepEqual(sent.sort(), ["token-a", "token-b"]);
assert.equal(bodies[0], "You have 7 new updates.");
});
it("does not send when ineligible", async () => {
let sends = 0;
const result = await deliverAlertSearchNotification(
daily({ digest: null, consumed: false }),
{
send: async () => {
sends += 1;
return "sent";
},
}
);
assert.equal(result.eligible, false);
assert.equal(sends, 0);
});
it("records per-token failure without throwing", async () => {
const result = await deliverAlertSearchNotification(daily(), {
listTokens: async () => ["t1", "t2"],
send: async (token) => (token === "t1" ? "failed" : "sent"),
});
assert.equal(result.sent, 1);
assert.equal(result.failed, 1);
});
});