Keep them beside the existing SMS alertSearch tests and update imports so they still load src/.
426 lines
13 KiB
TypeScript
426 lines
13 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
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 { alertAuthorizationDb } from "../../src/db/alertAuthorizationSqlite.js";
|
|
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 {
|
|
isAlertSearchSchedulerPassInFlight,
|
|
resetAlertSearchSchedulerPassGuard,
|
|
runAlertSearchSchedulerPass,
|
|
startAlertSearchScheduler,
|
|
stopAlertSearchScheduler,
|
|
} from "../../src/alertSearch/scheduler.js";
|
|
import type { AlertSearchDigest } from "../../src/alertSearch/digest.js";
|
|
|
|
const USER_A = "did:ethr:0xusera";
|
|
const USER_B = "did:ethr:0xuserb";
|
|
|
|
function stubDailyResult(userId: string): DailyAlertSearchResult {
|
|
return {
|
|
userId,
|
|
utcDay: null,
|
|
batchId: null,
|
|
jwtSequence: null,
|
|
endorserOutcome: null,
|
|
partnerOutcome: null,
|
|
completed: false,
|
|
consumed: false,
|
|
digest: null,
|
|
};
|
|
}
|
|
|
|
function eligibleDaily(userId: string): DailyAlertSearchResult {
|
|
const digest: AlertSearchDigest = {
|
|
completed: true,
|
|
hasUpdates: true,
|
|
totalCount: 3,
|
|
counts: {
|
|
claims: 3,
|
|
personalPlanContributions: 0,
|
|
trackedPlanUpdates: 0,
|
|
trackedPlanClaims: 0,
|
|
plansNearby: 0,
|
|
profilesNearby: 0,
|
|
},
|
|
records: {
|
|
claims: [],
|
|
personalPlanContributions: [],
|
|
trackedPlanUpdates: [],
|
|
trackedPlanClaims: [],
|
|
plansNearby: [],
|
|
profilesNearby: [],
|
|
},
|
|
endorser: { outcome: "success", completed: true },
|
|
partner: { outcome: "empty", completed: true },
|
|
};
|
|
return {
|
|
userId,
|
|
utcDay: "2026-08-14",
|
|
batchId: "batch-1",
|
|
jwtSequence: 1,
|
|
endorserOutcome: "success",
|
|
partnerOutcome: "empty",
|
|
completed: true,
|
|
consumed: true,
|
|
digest,
|
|
};
|
|
}
|
|
|
|
/** The day every seeded JWT belongs to, and an instant inside it. */
|
|
const DAY = "2026-08-28";
|
|
const NOW = new Date(`${DAY}T12:00:00.000Z`);
|
|
|
|
async function seedBatch(
|
|
userId: string,
|
|
batchId: string,
|
|
notify?: { hour: number; minute: number }
|
|
) {
|
|
await alertAuthorizationDb.replaceUnusedBatch({
|
|
userId,
|
|
batchId,
|
|
notifyHourUtc: notify?.hour,
|
|
notifyMinuteUtc: notify?.minute,
|
|
jwts: [
|
|
{
|
|
sequence: 1,
|
|
day: DAY,
|
|
jwt: `jwt-${userId}`,
|
|
nbf: 1,
|
|
exp: 2,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
async function seedDevice(userId: string, deviceId: string, fcmToken: string) {
|
|
await fcmDb.upsert({
|
|
userId,
|
|
deviceId,
|
|
fcmToken,
|
|
platform: "ios",
|
|
updatedAt: new Date(),
|
|
});
|
|
}
|
|
|
|
describe("alertSearch scheduler pass", () => {
|
|
let dir: string;
|
|
let previousDataDir: string | undefined;
|
|
|
|
beforeEach(async () => {
|
|
previousDataDir = process.env.NOTIFY_DATA_DIR;
|
|
dir = await mkdtemp(path.join(tmpdir(), "alert-search-sched-"));
|
|
process.env.NOTIFY_DATA_DIR = dir;
|
|
closeDatabase();
|
|
stopAlertSearchScheduler();
|
|
resetAlertSearchSchedulerPassGuard();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
stopAlertSearchScheduler();
|
|
resetAlertSearchSchedulerPassGuard();
|
|
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("runs alertSearch once for a user with multiple FCM devices", async () => {
|
|
await seedBatch(USER_A, "batch-a");
|
|
await seedDevice(USER_A, "device-1", "token-1");
|
|
await seedDevice(USER_A, "device-2", "token-2");
|
|
const ran: string[] = [];
|
|
const result = await runAlertSearchSchedulerPass({
|
|
now: NOW,
|
|
concurrency: 1,
|
|
runDaily: async (userId) => {
|
|
ran.push(userId);
|
|
return stubDailyResult(userId);
|
|
},
|
|
});
|
|
assert.equal(result.skipped, false);
|
|
assert.deepEqual(ran, [USER_A]);
|
|
assert.equal(result.attempted, 1);
|
|
});
|
|
|
|
it("runs alertSearch once per eligible user", async () => {
|
|
await seedBatch(USER_A, "batch-a");
|
|
await seedBatch(USER_B, "batch-b");
|
|
const ran: string[] = [];
|
|
const result = await runAlertSearchSchedulerPass({
|
|
now: NOW,
|
|
concurrency: 1,
|
|
runDaily: async (userId) => {
|
|
ran.push(userId);
|
|
return stubDailyResult(userId);
|
|
},
|
|
});
|
|
assert.deepEqual(ran, [USER_A, USER_B]);
|
|
assert.equal(result.attempted, 2);
|
|
assert.equal(result.failed, 0);
|
|
});
|
|
|
|
it("defers a user whose notify hour has not arrived", async () => {
|
|
await seedBatch(USER_A, "batch-a", { hour: 18, minute: 0 });
|
|
await seedBatch(USER_B, "batch-b");
|
|
const ran: string[] = [];
|
|
const result = await runAlertSearchSchedulerPass({
|
|
now: NOW,
|
|
concurrency: 1,
|
|
runDaily: async (userId) => {
|
|
ran.push(userId);
|
|
return stubDailyResult(userId);
|
|
},
|
|
});
|
|
assert.deepEqual(ran, [USER_B]);
|
|
assert.equal(result.deferred, 1);
|
|
assert.equal(result.attempted, 1);
|
|
});
|
|
|
|
it("runs that user once the hour passes", async () => {
|
|
await seedBatch(USER_A, "batch-a", { hour: 18, minute: 0 });
|
|
const ran: string[] = [];
|
|
const result = await runAlertSearchSchedulerPass({
|
|
now: new Date(`${DAY}T18:05:00.000Z`),
|
|
concurrency: 1,
|
|
runDaily: async (userId) => {
|
|
ran.push(userId);
|
|
return stubDailyResult(userId);
|
|
},
|
|
});
|
|
assert.deepEqual(ran, [USER_A]);
|
|
assert.equal(result.deferred, 0);
|
|
});
|
|
|
|
it("does not list a user whose day is already spent", async () => {
|
|
await seedBatch(USER_A, "batch-a");
|
|
const jwt = await alertAuthorizationDb.getUnusedForDay(USER_A, DAY);
|
|
assert.ok(jwt);
|
|
await alertAuthorizationDb.consumeUnusedJwt({ id: jwt.id, userId: USER_A });
|
|
|
|
const ran: string[] = [];
|
|
const result = await runAlertSearchSchedulerPass({
|
|
now: NOW,
|
|
concurrency: 1,
|
|
runDaily: async (userId) => {
|
|
ran.push(userId);
|
|
return stubDailyResult(userId);
|
|
},
|
|
});
|
|
assert.deepEqual(ran, []);
|
|
assert.equal(result.attempted, 0);
|
|
assert.equal(result.deferred, 0);
|
|
});
|
|
|
|
it("skips a tick while an alertSearch pass is in flight", async () => {
|
|
await seedBatch(USER_A, "batch-a");
|
|
let release!: () => void;
|
|
const blocked = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const first = runAlertSearchSchedulerPass({
|
|
now: NOW,
|
|
concurrency: 1,
|
|
runDaily: async (userId) => {
|
|
await blocked;
|
|
return stubDailyResult(userId);
|
|
},
|
|
});
|
|
while (!isAlertSearchSchedulerPassInFlight()) {
|
|
await Promise.resolve();
|
|
}
|
|
const second = await runAlertSearchSchedulerPass({
|
|
now: NOW,
|
|
concurrency: 1,
|
|
runDaily: async () => {
|
|
throw new Error("second pass should not run daily");
|
|
},
|
|
});
|
|
assert.equal(second.skipped, true);
|
|
assert.equal(second.attempted, 0);
|
|
release();
|
|
const firstResult = await first;
|
|
assert.equal(firstResult.skipped, false);
|
|
assert.equal(firstResult.attempted, 1);
|
|
});
|
|
|
|
it("continues other users when one runDailyAlertSearch fails", async () => {
|
|
await seedBatch(USER_A, "batch-a");
|
|
await seedBatch(USER_B, "batch-b");
|
|
const ran: string[] = [];
|
|
const result = await runAlertSearchSchedulerPass({
|
|
now: NOW,
|
|
concurrency: 1,
|
|
runDaily: async (userId) => {
|
|
ran.push(userId);
|
|
if (userId === USER_A) throw new Error("boom");
|
|
return stubDailyResult(userId);
|
|
},
|
|
});
|
|
assert.deepEqual(ran, [USER_A, USER_B]);
|
|
assert.equal(result.failed, 1);
|
|
assert.equal(result.attempted, 2);
|
|
});
|
|
|
|
it("does not run a pass on start, and a second start is a no-op", async () => {
|
|
await seedBatch(USER_A, "batch-a");
|
|
assert.equal(startAlertSearchScheduler(), true);
|
|
assert.equal(startAlertSearchScheduler(), false);
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
assert.equal(isAlertSearchSchedulerPassInFlight(), false);
|
|
stopAlertSearchScheduler();
|
|
});
|
|
|
|
it("notifies once for an eligible digest and not for a non-eligible result", async () => {
|
|
await seedBatch(USER_A, "batch-a");
|
|
await seedBatch(USER_B, "batch-b");
|
|
const notified: string[] = [];
|
|
await runAlertSearchSchedulerPass({
|
|
now: NOW,
|
|
concurrency: 1,
|
|
runDaily: async (userId) =>
|
|
userId === USER_A ? eligibleDaily(userId) : stubDailyResult(userId),
|
|
notify: async (result) => {
|
|
if (result.digest?.hasUpdates) notified.push(result.userId);
|
|
},
|
|
});
|
|
assert.deepEqual(notified, [USER_A]);
|
|
});
|
|
|
|
it("does not run alertSearch twice for two FCM devices on the same user", async () => {
|
|
await seedBatch(USER_A, "batch-a");
|
|
await seedDevice(USER_A, "device-1", "token-1");
|
|
await seedDevice(USER_A, "device-2", "token-2");
|
|
const ran: string[] = [];
|
|
const notifyCalls: number[] = [];
|
|
await runAlertSearchSchedulerPass({
|
|
now: NOW,
|
|
concurrency: 1,
|
|
runDaily: async (userId) => {
|
|
ran.push(userId);
|
|
return eligibleDaily(userId);
|
|
},
|
|
notify: async () => {
|
|
notifyCalls.push(1);
|
|
},
|
|
});
|
|
assert.deepEqual(ran, [USER_A]);
|
|
assert.equal(notifyCalls.length, 1);
|
|
});
|
|
|
|
it("does not notify again after the day's JWT was consumed", async () => {
|
|
await seedBatch(USER_A, "batch-a");
|
|
const notifies: Array<DailyAlertSearchResult["digest"]> = [];
|
|
await runAlertSearchSchedulerPass({
|
|
now: NOW,
|
|
concurrency: 1,
|
|
runDaily: async (userId) => eligibleDaily(userId),
|
|
notify: async (result) => {
|
|
notifies.push(result.digest);
|
|
},
|
|
});
|
|
await runAlertSearchSchedulerPass({
|
|
now: NOW,
|
|
concurrency: 1,
|
|
runDaily: async (userId) => stubDailyResult(userId),
|
|
notify: async (result) => {
|
|
notifies.push(result.digest);
|
|
},
|
|
});
|
|
assert.equal(notifies.length, 2);
|
|
assert.equal(notifies[0]?.hasUpdates, true);
|
|
assert.equal(notifies[1], null);
|
|
});
|
|
|
|
it("does not suppress a later notification after an incomplete run", async () => {
|
|
await seedBatch(USER_A, "batch-a");
|
|
const eligible: boolean[] = [];
|
|
const first = eligibleDaily(USER_A);
|
|
const incompleteDigest = first.digest;
|
|
assert.ok(incompleteDigest);
|
|
await runAlertSearchSchedulerPass({
|
|
now: NOW,
|
|
concurrency: 1,
|
|
runDaily: async () => ({
|
|
...first,
|
|
completed: false,
|
|
consumed: false,
|
|
digest: {
|
|
...incompleteDigest,
|
|
completed: false,
|
|
hasUpdates: false,
|
|
},
|
|
}),
|
|
notify: async (result) => {
|
|
eligible.push(Boolean(result.consumed && result.digest?.hasUpdates));
|
|
},
|
|
});
|
|
await runAlertSearchSchedulerPass({
|
|
now: NOW,
|
|
concurrency: 1,
|
|
runDaily: async (userId) => eligibleDaily(userId),
|
|
notify: async (result) => {
|
|
eligible.push(Boolean(result.consumed && result.digest?.hasUpdates));
|
|
},
|
|
});
|
|
assert.deepEqual(eligible, [false, true]);
|
|
});
|
|
|
|
it("keeps the daily result when notification throws", async () => {
|
|
await seedBatch(USER_A, "batch-a");
|
|
let dailyConsumed = false;
|
|
const result = await runAlertSearchSchedulerPass({
|
|
now: NOW,
|
|
concurrency: 1,
|
|
runDaily: async (userId) => {
|
|
const daily = eligibleDaily(userId);
|
|
dailyConsumed = daily.consumed;
|
|
return daily;
|
|
},
|
|
notify: async () => {
|
|
throw new Error("fcm down");
|
|
},
|
|
});
|
|
assert.equal(result.failed, 0);
|
|
assert.equal(dailyConsumed, true);
|
|
});
|
|
});
|
|
|
|
describe("alertSearch scheduler isolation from FCM", () => {
|
|
it("does not call sendPushToDevice or WAKEUP_PING", () => {
|
|
const alertSched = readFileSync(
|
|
path.join(process.cwd(), "src/alertSearch/scheduler.ts"),
|
|
"utf8"
|
|
);
|
|
assert.equal(alertSched.includes("sendPushToDevice"), false);
|
|
assert.equal(alertSched.includes("WAKEUP_PING"), false);
|
|
});
|
|
|
|
it("leaves the FCM scheduler on sendPushToDevice only", () => {
|
|
const fcmSched = readFileSync(
|
|
path.join(process.cwd(), "src/scheduler.ts"),
|
|
"utf8"
|
|
);
|
|
assert.equal(fcmSched.includes("sendPushToDevice"), true);
|
|
assert.equal(fcmSched.includes("runDailyAlertSearch"), false);
|
|
assert.equal(fcmSched.includes("runAlertSearchSchedulerPass"), false);
|
|
assert.equal(fcmSched.includes("startAlertSearchScheduler"), false);
|
|
});
|
|
|
|
it("leaves WAKEUP_PING last_notified_at logic on sendPushToDevice", () => {
|
|
const push = readFileSync(
|
|
path.join(process.cwd(), "src/services/pushService.ts"),
|
|
"utf8"
|
|
);
|
|
assert.equal(push.includes('type: "WAKEUP_PING"'), true);
|
|
assert.equal(push.includes("sendAlertSearchPushToDevice"), true);
|
|
});
|
|
});
|