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

365 lines
11 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 { smsActionJwtUseDb } from "../../src/db/smsActionJwtUseSqlite.js";
import { smsAlertAuthorizationDb } from "../../src/db/smsAlertAuthorizationSqlite.js";
import { closeDatabase } from "../../src/db/sqlite.js";
import type { DailyAlertSearchResult } from "../../src/alertSearch/daily.js";
import {
SMS_ACTION_JWT_RETENTION_MULTIPLE,
isSmsAlertSearchSchedulerPassInFlight,
resetSmsAlertSearchSchedulerPassGuard,
runSmsAlertSearchSchedulerPass,
startSmsAlertSearchScheduler,
stopSmsAlertSearchScheduler,
} from "../../src/alertSearch/smsScheduler.js";
const USER = "did:ethr:0xschedone";
const OTHER = "did:ethr:0xschedtwo";
/** The day every seeded JWT belongs to, and an instant inside it. */
const DAY = "2026-09-05";
const NOW = new Date(`${DAY}T12:00:00.000Z`);
/** A pending list where every named user's hour has already come. */
function due(userIds: string[]) {
return userIds.map((userId) => ({ userId, due: true }));
}
let dir: string;
let savedDataDir: string | undefined;
beforeEach(async () => {
savedDataDir = process.env.NOTIFY_DATA_DIR;
dir = await mkdtemp(path.join(tmpdir(), "sms-scheduler-"));
process.env.NOTIFY_DATA_DIR = dir;
closeDatabase();
resetSmsAlertSearchSchedulerPassGuard();
});
afterEach(async () => {
stopSmsAlertSearchScheduler();
resetSmsAlertSearchSchedulerPassGuard();
closeDatabase();
if (savedDataDir === undefined) delete process.env.NOTIFY_DATA_DIR;
else process.env.NOTIFY_DATA_DIR = savedDataDir;
await rm(dir, { recursive: true, force: true });
});
function daily(userId: string): DailyAlertSearchResult {
return {
userId,
utcDay: "2026-09-05",
batchId: "b",
jwtSequence: 1,
endorserOutcome: "success",
partnerOutcome: "empty",
completed: true,
consumed: true,
digest: null,
};
}
describe("runSmsAlertSearchSchedulerPass", () => {
it("runs and notifies once per SMS-authorized user", async () => {
const ran: string[] = [];
const notified: string[] = [];
const result = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
listPending: async () => due([USER, OTHER]),
runDaily: async (userId) => {
ran.push(userId);
return daily(userId);
},
notify: async (input) => {
notified.push(input.userId);
},
prune: async () => undefined,
});
assert.deepEqual(ran, [USER, OTHER]);
assert.deepEqual(notified, [USER, OTHER]);
assert.equal(result.attempted, 2);
assert.equal(result.failed, 0);
assert.equal(result.skipped, false);
});
it("lists users from the SMS batches, not the FCM ones", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-1",
jwts: [{ sequence: 1, day: DAY, jwt: "j", nbf: 1, exp: 2 }],
});
const seen: string[] = [];
await runSmsAlertSearchSchedulerPass({
concurrency: 1,
now: NOW,
runDaily: async (userId) => {
seen.push(userId);
return daily(userId);
},
notify: async () => undefined,
prune: async () => undefined,
});
assert.deepEqual(seen, [USER]);
});
it("counts a failing user and keeps going", async () => {
const notified: string[] = [];
const result = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
listPending: async () => due([USER, OTHER]),
runDaily: async (userId) => {
if (userId === USER) throw new Error("Endorser down");
return daily(userId);
},
notify: async (input) => {
notified.push(input.userId);
},
prune: async () => undefined,
});
assert.equal(result.failed, 1);
assert.deepEqual(notified, [OTHER]);
});
it("does not let a failing notification fail the user", async () => {
const result = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
listPending: async () => due([USER]),
runDaily: async (userId) => daily(userId),
notify: async () => {
throw new Error("Twilio down");
},
prune: async () => undefined,
});
assert.equal(result.failed, 0);
});
it("skips a pass while another is in flight", async () => {
let release: (() => void) | undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const running = runSmsAlertSearchSchedulerPass({
concurrency: 1,
listPending: async () => due([USER]),
runDaily: async (userId) => {
await gate;
return daily(userId);
},
notify: async () => undefined,
prune: async () => undefined,
});
assert.equal(isSmsAlertSearchSchedulerPassInFlight(), true);
const skipped = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
listPending: async () => due([OTHER]),
prune: async () => undefined,
});
assert.equal(skipped.skipped, true);
assert.equal(skipped.attempted, 0);
release?.();
await running;
assert.equal(isSmsAlertSearchSchedulerPassInFlight(), false);
});
it("defers a user the pending list reports as not yet due", async () => {
const ran: string[] = [];
const result = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
listPending: async () => [
{ userId: USER, due: false },
{ userId: OTHER, due: true },
],
runDaily: async (userId) => {
ran.push(userId);
return daily(userId);
},
notify: async () => undefined,
prune: async () => undefined,
});
assert.deepEqual(ran, [OTHER]);
assert.equal(result.deferred, 1);
assert.equal(result.attempted, 1);
assert.equal(result.failed, 0);
});
it("holds the stored hour back until that instant, then runs", async () => {
// 18:00-06:00 is 00:00 UTC, so this batch asks for the very top of each UTC
// day; a batch stored at 18:00 UTC is the one that has to wait.
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-1",
notifyHourUtc: 18,
notifyMinuteUtc: 0,
jwts: [{ sequence: 1, day: DAY, jwt: "j", nbf: 1, exp: 2 }],
});
const ran: string[] = [];
const track = async (userId: string) => {
ran.push(userId);
return daily(userId);
};
const before = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
now: new Date(`${DAY}T17:55:00.000Z`),
runDaily: track,
notify: async () => undefined,
prune: async () => undefined,
});
assert.deepEqual(ran, []);
assert.equal(before.deferred, 1);
assert.equal(before.attempted, 0);
const after = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
now: new Date(`${DAY}T18:05:00.000Z`),
runDaily: track,
notify: async () => undefined,
prune: async () => undefined,
});
assert.deepEqual(ran, [USER]);
assert.equal(after.deferred, 0);
assert.equal(after.attempted, 1);
});
it("drops the user entirely once the UTC day rolls past their JWT", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-1",
notifyHourUtc: 18,
notifyMinuteUtc: 0,
jwts: [{ sequence: 1, day: DAY, jwt: "j", nbf: 1, exp: 2 }],
});
const result = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
now: new Date("2026-09-06T00:05:00.000Z"),
runDaily: async (userId) => daily(userId),
notify: async () => undefined,
prune: async () => undefined,
});
// No JWT for the new day, so there is nothing pending to defer.
assert.equal(result.attempted, 0);
assert.equal(result.deferred, 0);
});
it("runs a batch that carries no notifyTime", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-1",
jwts: [{ sequence: 1, day: DAY, jwt: "j", nbf: 1, exp: 2 }],
});
const ran: string[] = [];
const result = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
now: NOW,
runDaily: async (userId) => {
ran.push(userId);
return daily(userId);
},
notify: async () => undefined,
prune: async () => undefined,
});
assert.deepEqual(ran, [USER]);
assert.equal(result.deferred, 0);
});
it("fails the whole pass when the pending query itself fails", async () => {
await assert.rejects(
() =>
runSmsAlertSearchSchedulerPass({
concurrency: 1,
listPending: async () => {
throw new Error("database locked");
},
prune: async () => undefined,
}),
/database locked/
);
// The guard must not stay stuck after a thrown pass.
assert.equal(isSmsAlertSearchSchedulerPassInFlight(), false);
});
it("works on several users at once when concurrency allows", async () => {
const userIds = Array.from({ length: 12 }, (_, i) => `did:ethr:0x${i}`);
let inFlight = 0;
let peak = 0;
const result = await runSmsAlertSearchSchedulerPass({
concurrency: 4,
listPending: async () => due(userIds),
runDaily: async (userId) => {
inFlight += 1;
peak = Math.max(peak, inFlight);
await new Promise((resolve) => setTimeout(resolve, 5));
inFlight -= 1;
return daily(userId);
},
notify: async () => undefined,
prune: async () => undefined,
});
assert.equal(result.attempted, 12);
assert.equal(peak, 4, `expected 4 in flight, saw ${peak}`);
});
it("prunes action-JWT rows past the retention window", async () => {
process.env.SMS_ACTION_JWT_MAX_AGE_SEC = "60";
const retentionMs =
60 * SMS_ACTION_JWT_RETENTION_MULTIPLE * 1000;
await smsActionJwtUseDb.claim({
jwtHash: "fresh",
userId: USER,
action: "verify-phone",
});
// An old row, written straight to the table with a past used_at.
const { getDatabase } = await import("../../src/db/sqlite.js");
getDatabase()
.prepare(
`INSERT INTO sms_action_jwt_use (id, jwt_hash, user_id, action, used_at)
VALUES ('old', 'stale', ?, 'verify-phone', ?)`
)
.run(USER, new Date(Date.now() - retentionMs - 60_000).toISOString());
assert.equal(await smsActionJwtUseDb.count(), 2);
await runSmsAlertSearchSchedulerPass({
concurrency: 1,
listPending: async () => due([]),
notify: async () => undefined,
});
assert.equal(await smsActionJwtUseDb.count(), 1);
delete process.env.SMS_ACTION_JWT_MAX_AGE_SEC;
});
it("survives a prune failure", async () => {
const result = await runSmsAlertSearchSchedulerPass({
concurrency: 1,
listPending: async () => due([]),
prune: async () => {
throw new Error("locked");
},
});
assert.equal(result.skipped, false);
});
});
describe("startSmsAlertSearchScheduler", () => {
it("starts once and stops cleanly", () => {
assert.equal(startSmsAlertSearchScheduler(), true);
assert.equal(startSmsAlertSearchScheduler(), false);
stopSmsAlertSearchScheduler();
assert.equal(startSmsAlertSearchScheduler(), true);
});
it("does not run a pass at start time", async () => {
startSmsAlertSearchScheduler();
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(isSmsAlertSearchSchedulerPassInFlight(), false);
});
});