365 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|