235 lines
6.5 KiB
TypeScript
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);
|
|
});
|
|
});
|