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

481 lines
15 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 { smsPhoneLogDb } from "../../src/db/smsPhoneLogSqlite.js";
import { smsBlockedNumbersDb } from "../../src/db/smsBlockedNumbersSqlite.js";
import { smsRegistrationsDb } from "../../src/db/smsRegistrationsSqlite.js";
import { closeDatabase, getDatabase } from "../../src/db/sqlite.js";
import { hashPhoneNumber } from "../../src/util/smsVerificationCode.js";
import type { SmsSendResult } from "../../src/services/smsService.js";
import type { DailyAlertSearchResult } from "../../src/alertSearch/daily.js";
import type { AlertSearchDigest } from "../../src/alertSearch/digest.js";
import {
ALERT_SEARCH_SMS_LINK,
SMS_SINGLE_SEGMENT_LIMIT,
alertSearchSmsBody,
deliverAlertSearchSms,
isAlertSearchSmsEligible,
} from "../../src/alertSearch/smsNotify.js";
const USER = "did:ethr:0xsmsnotify";
const PHONE = "+15555550123";
const OTHER_PHONE = "+15555550124";
const SECRET = "sms-notify-secret";
let dir: string;
let savedDataDir: string | undefined;
let savedSecret: string | undefined;
let savedAllowlist: string | undefined;
let savedBlocked: string | undefined;
beforeEach(async () => {
savedDataDir = process.env.NOTIFY_DATA_DIR;
savedSecret = process.env.SMS_CODE_SECRET;
savedAllowlist = process.env.SMS_ALLOWED_RECIPIENT_DIDS;
delete process.env.SMS_ALLOWED_RECIPIENT_DIDS;
savedBlocked = process.env.SMS_BLOCKED_NUMBERS;
delete process.env.SMS_BLOCKED_NUMBERS;
dir = await mkdtemp(path.join(tmpdir(), "sms-notify-"));
process.env.NOTIFY_DATA_DIR = dir;
process.env.SMS_CODE_SECRET = SECRET;
closeDatabase();
});
afterEach(async () => {
closeDatabase();
if (savedDataDir === undefined) delete process.env.NOTIFY_DATA_DIR;
else process.env.NOTIFY_DATA_DIR = savedDataDir;
if (savedSecret === undefined) delete process.env.SMS_CODE_SECRET;
else process.env.SMS_CODE_SECRET = savedSecret;
if (savedAllowlist === undefined) delete process.env.SMS_ALLOWED_RECIPIENT_DIDS;
else process.env.SMS_ALLOWED_RECIPIENT_DIDS = savedAllowlist;
if (savedBlocked === undefined) delete process.env.SMS_BLOCKED_NUMBERS;
else process.env.SMS_BLOCKED_NUMBERS = savedBlocked;
await rm(dir, { recursive: true, force: true });
});
function digest(overrides: Partial<AlertSearchDigest> = {}): AlertSearchDigest {
const counts = {
claims: 0,
personalPlanContributions: 0,
trackedPlanUpdates: 0,
trackedPlanClaims: 0,
plansNearby: 0,
profilesNearby: 0,
};
return {
completed: true,
hasUpdates: true,
totalCount: 7,
counts,
records: {
claims: [],
personalPlanContributions: [],
trackedPlanUpdates: [],
trackedPlanClaims: [],
plansNearby: [],
profilesNearby: [],
},
endorser: { outcome: "success", completed: true },
partner: { outcome: "empty", completed: true },
...overrides,
};
}
function daily(
overrides: Partial<DailyAlertSearchResult> = {}
): DailyAlertSearchResult {
return {
userId: USER,
utcDay: "2026-09-05",
batchId: "sms-batch-1",
jwtSequence: 1,
endorserOutcome: "success",
partnerOutcome: "empty",
completed: true,
consumed: true,
digest: digest(),
...overrides,
};
}
async function verifyPhone(phone: string, user = USER): Promise<void> {
const now = new Date().toISOString();
await smsRegistrationsDb.upsertPendingCode({
userId: user,
phoneE164: phone,
codeHash: "hash",
codeExpiresAt: now,
sentAt: now,
});
await smsRegistrationsDb.markVerified(user, phone);
}
describe("alertSearchSmsBody", () => {
it("fits one GSM-7 segment and carries the link and the opt-out", () => {
const body = alertSearchSmsBody(7);
assert.equal(
body,
`Gift Economies: you have 7 new updates. ${ALERT_SEARCH_SMS_LINK} Reply STOP to end.`
);
assert.ok(body.length <= SMS_SINGLE_SEGMENT_LIMIT, `length ${body.length}`);
assert.ok(
alertSearchSmsBody(999999).length <= SMS_SINGLE_SEGMENT_LIMIT
);
assert.equal(
alertSearchSmsBody(1),
`Gift Economies: you have 1 new update. ${ALERT_SEARCH_SMS_LINK} Reply STOP to end.`
);
assert.equal(body.includes("giftopia.tech"), false);
});
});
describe("SMS notification gate", () => {
it("requires consumption, completion, and updates", () => {
assert.equal(isAlertSearchSmsEligible(daily()), true);
assert.equal(isAlertSearchSmsEligible(daily({ consumed: false })), false);
assert.equal(isAlertSearchSmsEligible(daily({ digest: null })), false);
assert.equal(
isAlertSearchSmsEligible(
daily({ digest: digest({ completed: false }) })
),
false
);
assert.equal(
isAlertSearchSmsEligible(
daily({ digest: digest({ hasUpdates: false, totalCount: 0 }) })
),
false
);
});
});
describe("deliverAlertSearchSms", () => {
it("texts each verified number once and logs the provider id", async () => {
await verifyPhone(PHONE);
await verifyPhone(OTHER_PHONE);
const sent: string[] = [];
const result = await deliverAlertSearchSms(daily(), {
send: async (to, body) => {
sent.push(to);
assert.match(body, /7 new updates/);
return { status: "sent", messageId: "SM-" + to };
},
});
assert.equal(result.eligible, true);
assert.equal(result.sent, 2);
assert.deepEqual(sent.sort(), [PHONE, OTHER_PHONE].sort());
const log = await smsPhoneLogDb.listByUserId(USER);
const alerts = log.filter((row) => row.action === "alert-sent");
assert.equal(alerts.length, 2);
assert.ok(alerts.every((row) => row.providerMessageId?.startsWith("SM-")));
});
it("skips unverified numbers and other DIDs' numbers", async () => {
await verifyPhone(PHONE);
const now = new Date().toISOString();
await smsRegistrationsDb.upsertPendingCode({
userId: USER,
phoneE164: OTHER_PHONE,
codeHash: "hash",
codeExpiresAt: now,
sentAt: now,
});
await verifyPhone("+15555550125", "did:ethr:0xsomeoneelse");
const sent: string[] = [];
await deliverAlertSearchSms(daily(), {
send: async (to) => {
sent.push(to);
return { status: "sent", messageId: "SM1" };
},
});
assert.deepEqual(sent, [PHONE]);
});
it("sends nothing when ineligible", async () => {
await verifyPhone(PHONE);
let sends = 0;
const result = await deliverAlertSearchSms(daily({ consumed: false }), {
send: async () => {
sends += 1;
return { status: "sent", messageId: "SM1" };
},
});
assert.equal(result.eligible, false);
assert.equal(sends, 0);
});
it("logs a failure without throwing and without a provider id", async () => {
await verifyPhone(PHONE);
const failure: SmsSendResult = { status: "failed", error: "carrier down" };
const result = await deliverAlertSearchSms(daily(), {
send: async () => failure,
});
assert.equal(result.sent, 0);
assert.equal(result.failed, 1);
const log = await smsPhoneLogDb.listByUserId(USER);
assert.equal(log[0].action, "alert-send-failed");
assert.equal(log[0].detail, "carrier down");
assert.equal(log[0].providerMessageId, undefined);
});
it("records a thrown send as a failure and keeps going", async () => {
await verifyPhone(PHONE);
await verifyPhone(OTHER_PHONE);
const result = await deliverAlertSearchSms(daily(), {
send: async (to) => {
if (to === PHONE) throw new Error("socket hang up");
return { status: "sent", messageId: "SM1" };
},
});
assert.equal(result.sent, 1);
assert.equal(result.failed, 1);
});
it("does not text the same number twice on the same UTC day", async () => {
await verifyPhone(PHONE);
const now = new Date("2026-09-08T18:00:00.000Z");
let sends = 0;
const send = async (): Promise<SmsSendResult> => {
sends += 1;
return { status: "sent", messageId: "SM" + sends };
};
await deliverAlertSearchSms(daily(), { send, now });
// A second eligible run the same UTC day: the JWT is what normally stops
// this, and the log-backed cap is the backstop underneath it.
const second = await deliverAlertSearchSms(daily(), { send, now });
assert.equal(sends, 1);
assert.equal(second.sent, 0);
});
it("does not suppress a send on the next UTC day after a late-evening send", async () => {
await verifyPhone(PHONE);
let sends = 0;
const send = async (): Promise<SmsSendResult> => {
sends += 1;
return { status: "sent", messageId: "SM" + sends };
};
await deliverAlertSearchSms(daily(), {
send,
now: new Date("2026-09-08T23:30:00.000Z"),
});
getDatabase()
.prepare(
`UPDATE sms_phone_log SET created_at = ? WHERE action = 'alert-sent'`
)
.run("2026-09-08T23:30:00.000Z");
const nextDay = await deliverAlertSearchSms(daily(), {
send,
now: new Date("2026-09-09T00:01:00.000Z"),
});
assert.equal(sends, 2);
assert.equal(nextDay.sent, 1);
});
it("does not let a failed send count toward the UTC-day cap", async () => {
await verifyPhone(PHONE);
const now = new Date("2026-09-08T18:00:00.000Z");
let sends = 0;
const result = await deliverAlertSearchSms(daily(), {
now,
send: async () => {
sends += 1;
if (sends === 1) {
return { status: "failed", error: "carrier down" };
}
return { status: "sent", messageId: "SM1" };
},
});
assert.equal(result.sent, 0);
assert.equal(result.failed, 1);
const retry = await deliverAlertSearchSms(daily(), {
now,
send: async () => {
sends += 1;
return { status: "sent", messageId: "SM1" };
},
});
assert.equal(sends, 2);
assert.equal(retry.sent, 1);
assert.equal(retry.failed, 0);
});
});
describe("SMS_ALLOWED_RECIPIENT_DIDS", () => {
it("sends normally to a DID on the list", async () => {
process.env.SMS_ALLOWED_RECIPIENT_DIDS = USER;
await verifyPhone(PHONE);
const result = await deliverAlertSearchSms(daily(), {
send: async () => ({ status: "sent", messageId: "SM1" }),
});
assert.equal(result.sent, 1);
assert.equal(result.blocked, 0);
});
it("withholds an eligible digest from a DID that is not listed", async () => {
process.env.SMS_ALLOWED_RECIPIENT_DIDS = "did:ethr:0xsomeoneelse";
await verifyPhone(PHONE);
await verifyPhone(OTHER_PHONE);
let sends = 0;
const result = await deliverAlertSearchSms(daily(), {
send: async () => {
sends += 1;
return { status: "sent", messageId: "SM1" };
},
});
assert.equal(sends, 0);
// The digest was worth sending; this instance was not allowed to send it.
assert.equal(result.eligible, true);
assert.equal(result.sent, 0);
assert.equal(result.blocked, 2);
});
it("records what it withheld, one row per number", async () => {
process.env.SMS_ALLOWED_RECIPIENT_DIDS = "did:ethr:0xsomeoneelse";
await verifyPhone(PHONE);
await deliverAlertSearchSms(daily(), {
send: async () => ({ status: "sent", messageId: "SM1" }),
});
const rows = await smsPhoneLogDb.listByUserId(USER);
assert.equal(rows.length, 1);
assert.equal(rows[0].action, "recipient-not-allowed");
assert.equal(rows[0].result, "rejected");
assert.equal(rows[0].phoneE164, PHONE);
assert.equal(rows[0].detail, "SMS_ALLOWED_RECIPIENT_DIDS");
});
it("blocks every DID when the list is set but empty", async () => {
process.env.SMS_ALLOWED_RECIPIENT_DIDS = "";
await verifyPhone(PHONE);
let sends = 0;
const result = await deliverAlertSearchSms(daily(), {
send: async () => {
sends += 1;
return { status: "sent", messageId: "SM1" };
},
});
assert.equal(sends, 0);
assert.equal(result.blocked, 1);
});
it("does not fire for an ineligible digest", async () => {
process.env.SMS_ALLOWED_RECIPIENT_DIDS = "did:ethr:0xsomeoneelse";
await verifyPhone(PHONE);
const result = await deliverAlertSearchSms(daily({ consumed: false }), {
send: async () => ({ status: "sent", messageId: "SM1" }),
});
assert.equal(result.eligible, false);
assert.equal(result.blocked, 0);
assert.deepEqual(await smsPhoneLogDb.listByUserId(USER), []);
});
});
describe("blocked numbers in the daily digest", () => {
it("skips a blocked number and still texts the others", async () => {
await verifyPhone(PHONE);
await verifyPhone(OTHER_PHONE);
await smsBlockedNumbersDb.block({
phoneHash: hashPhoneNumber(PHONE, SECRET),
reason: "opt-out",
});
const sent: string[] = [];
const result = await deliverAlertSearchSms(daily(), {
send: async (to) => {
sent.push(to);
return { status: "sent", messageId: "SM1" };
},
});
assert.deepEqual(sent, [OTHER_PHONE]);
assert.equal(result.sent, 1);
assert.equal(result.blocked, 1);
const rows = await smsPhoneLogDb.listByUserId(USER);
const skipped = rows.find((row) => row.action === "number-blocked");
assert.equal(skipped?.result, "rejected");
assert.equal(skipped?.phoneE164, undefined);
});
it("sends nothing when every number is blocked", async () => {
await verifyPhone(PHONE);
process.env.SMS_BLOCKED_NUMBERS = PHONE;
let sends = 0;
const result = await deliverAlertSearchSms(daily(), {
send: async () => {
sends += 1;
return { status: "sent", messageId: "SM1" };
},
});
assert.equal(sends, 0);
assert.equal(result.blocked, 1);
assert.equal(result.sent, 0);
});
});
describe("Twilio 21610 syncs the provider's opt-out list into ours", () => {
it("blocks the number after an unsubscribed refusal", async () => {
await verifyPhone(PHONE);
const hash = hashPhoneNumber(PHONE, SECRET);
const result = await deliverAlertSearchSms(daily(), {
send: async () => ({
status: "failed",
error: "The message From/To pair violates a blacklist rule.",
code: 21610,
}),
});
assert.equal(result.failed, 1);
const stored = await smsBlockedNumbersDb.get(hash);
assert.equal(stored?.reason, "provider-opt-out");
assert.equal(stored?.detail, "Twilio 21610");
const rows = await smsPhoneLogDb.listByUserId(USER);
assert.ok(rows.some((row) => row.action === "alert-send-failed"));
assert.ok(rows.some((row) => row.action === "number-blocked"));
});
it("does not retry that number on the next eligible run", async () => {
await verifyPhone(PHONE);
await deliverAlertSearchSms(daily(), {
send: async () => ({ status: "failed", error: "unsubscribed", code: 21610 }),
});
let sends = 0;
const second = await deliverAlertSearchSms(daily(), {
send: async () => {
sends += 1;
return { status: "sent", messageId: "SM1" };
},
});
assert.equal(sends, 0);
assert.equal(second.blocked, 1);
});
it("leaves an ordinary failure unblocked, so a blip is retried", async () => {
await verifyPhone(PHONE);
await deliverAlertSearchSms(daily(), {
send: async () => ({ status: "failed", error: "carrier down", code: 30001 }),
});
assert.equal(
await smsBlockedNumbersDb.isBlocked(hashPhoneNumber(PHONE, SECRET)),
false
);
});
});