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