632 lines
20 KiB
TypeScript
632 lines
20 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 { smsAlertAuthorizationDb } from "../../src/db/smsAlertAuthorizationSqlite.js";
|
|
import { alertAuthorizationDb } from "../../src/db/alertAuthorizationSqlite.js";
|
|
import { smsPhoneLogDb } from "../../src/db/smsPhoneLogSqlite.js";
|
|
import { smsRegistrationsDb } from "../../src/db/smsRegistrationsSqlite.js";
|
|
import { closeDatabase, getDatabase } from "../../src/db/sqlite.js";
|
|
|
|
const USER = "did:ethr:0xsmsuser";
|
|
const OTHER = "did:ethr:0xothersmsuser";
|
|
const PHONE = "+15555550123";
|
|
const HASH = "phone-hash-abc";
|
|
|
|
let dir: string;
|
|
let previousDataDir: string | undefined;
|
|
|
|
beforeEach(async () => {
|
|
previousDataDir = process.env.NOTIFY_DATA_DIR;
|
|
dir = await mkdtemp(path.join(tmpdir(), "sms-db-"));
|
|
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 });
|
|
});
|
|
|
|
describe("smsRegistrationsDb", () => {
|
|
it("round-trips a pending registration", async () => {
|
|
const expires = new Date(Date.now() + 600_000).toISOString();
|
|
await smsRegistrationsDb.upsertPendingCode({
|
|
userId: USER,
|
|
phoneE164: PHONE,
|
|
codeHash: "hash-1",
|
|
codeExpiresAt: expires,
|
|
sentAt: new Date().toISOString(),
|
|
});
|
|
const row = await smsRegistrationsDb.get(USER, PHONE);
|
|
assert.equal(row?.userId, USER);
|
|
assert.equal(row?.phoneE164, PHONE);
|
|
assert.equal(row?.verified, false);
|
|
assert.equal(row?.codeHash, "hash-1");
|
|
assert.equal(row?.codeExpiresAt, expires);
|
|
assert.equal(row?.codeAttempts, 0);
|
|
});
|
|
|
|
it("keeps one row per (user, phone) and resets attempts on a resend", async () => {
|
|
const now = new Date().toISOString();
|
|
await smsRegistrationsDb.upsertPendingCode({
|
|
userId: USER,
|
|
phoneE164: PHONE,
|
|
codeHash: "hash-1",
|
|
codeExpiresAt: now,
|
|
sentAt: now,
|
|
});
|
|
await smsRegistrationsDb.incrementCodeAttempts(USER, PHONE);
|
|
await smsRegistrationsDb.upsertPendingCode({
|
|
userId: USER,
|
|
phoneE164: PHONE,
|
|
codeHash: "hash-2",
|
|
codeExpiresAt: now,
|
|
sentAt: now,
|
|
});
|
|
|
|
const rows = await smsRegistrationsDb.listByUserId(USER);
|
|
assert.equal(rows.length, 1);
|
|
assert.equal(rows[0].codeHash, "hash-2");
|
|
assert.equal(rows[0].codeAttempts, 0);
|
|
});
|
|
|
|
it("rejects a duplicate (user, phone) insert at the unique index", () => {
|
|
const connection = getDatabase();
|
|
const insert = connection.prepare(
|
|
`
|
|
INSERT INTO sms_registrations (
|
|
id, user_id, phone_e164, verified, code_attempts, created_at, updated_at
|
|
) VALUES (?, ?, ?, 0, 0, ?, ?)
|
|
`
|
|
);
|
|
const now = new Date().toISOString();
|
|
insert.run("row-1", USER, PHONE, now, now);
|
|
assert.throws(
|
|
() => insert.run("row-2", USER, PHONE, now, now),
|
|
/UNIQUE/
|
|
);
|
|
});
|
|
|
|
it("scopes verification, deletion, and counts by DID", async () => {
|
|
const now = new Date().toISOString();
|
|
for (const user of [USER, OTHER]) {
|
|
await smsRegistrationsDb.upsertPendingCode({
|
|
userId: user,
|
|
phoneE164: PHONE,
|
|
codeHash: "hash",
|
|
codeExpiresAt: now,
|
|
sentAt: now,
|
|
});
|
|
}
|
|
|
|
await smsRegistrationsDb.markVerified(USER, PHONE);
|
|
assert.equal((await smsRegistrationsDb.get(USER, PHONE))?.verified, true);
|
|
assert.equal((await smsRegistrationsDb.get(OTHER, PHONE))?.verified, false);
|
|
|
|
assert.equal(await smsRegistrationsDb.countVerifiedForPhone(PHONE), 1);
|
|
assert.deepEqual(
|
|
await smsRegistrationsDb.listVerifiedDidsForPhone(PHONE),
|
|
[USER]
|
|
);
|
|
|
|
assert.equal(await smsRegistrationsDb.delete(USER, PHONE), true);
|
|
assert.equal(await smsRegistrationsDb.get(OTHER, PHONE) !== undefined, true);
|
|
assert.equal(await smsRegistrationsDb.delete(USER, PHONE), false);
|
|
});
|
|
|
|
it("clears the code and verified flag for every DID on an opt-out", async () => {
|
|
const now = new Date().toISOString();
|
|
for (const user of [USER, OTHER]) {
|
|
await smsRegistrationsDb.upsertPendingCode({
|
|
userId: user,
|
|
phoneE164: PHONE,
|
|
codeHash: "hash",
|
|
codeExpiresAt: now,
|
|
sentAt: now,
|
|
});
|
|
await smsRegistrationsDb.markVerified(user, PHONE);
|
|
}
|
|
assert.equal(await smsRegistrationsDb.unverifyAllForPhone(PHONE), 2);
|
|
assert.equal(await smsRegistrationsDb.countVerifiedForPhone(PHONE), 0);
|
|
});
|
|
|
|
it("excludes the named DID from the verified count", async () => {
|
|
const now = new Date().toISOString();
|
|
for (const user of [USER, OTHER]) {
|
|
await smsRegistrationsDb.upsertPendingCode({
|
|
userId: user,
|
|
phoneE164: PHONE,
|
|
codeHash: "hash",
|
|
codeExpiresAt: now,
|
|
sentAt: now,
|
|
});
|
|
await smsRegistrationsDb.markVerified(user, PHONE);
|
|
}
|
|
assert.equal(
|
|
await smsRegistrationsDb.countVerifiedForPhone(PHONE, USER),
|
|
1
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("smsPhoneLogDb", () => {
|
|
it("records an action and reads it back", async () => {
|
|
await smsPhoneLogDb.append({
|
|
userId: USER,
|
|
phoneE164: PHONE,
|
|
phoneHash: HASH,
|
|
action: "code-sent",
|
|
result: "ok",
|
|
providerMessageId: "SM123",
|
|
});
|
|
const rows = await smsPhoneLogDb.listByUserId(USER);
|
|
assert.equal(rows.length, 1);
|
|
assert.equal(rows[0].action, "code-sent");
|
|
assert.equal(rows[0].providerMessageId, "SM123");
|
|
assert.equal(rows[0].phoneE164, PHONE);
|
|
});
|
|
|
|
it("counts sends per phone across DIDs", async () => {
|
|
for (const user of [USER, OTHER]) {
|
|
await smsPhoneLogDb.append({
|
|
userId: user,
|
|
phoneE164: PHONE,
|
|
phoneHash: HASH,
|
|
action: "code-sent",
|
|
result: "ok",
|
|
});
|
|
}
|
|
const since = new Date(Date.now() - 3_600_000).toISOString();
|
|
assert.equal(
|
|
await smsPhoneLogDb.countByPhoneHashSince(HASH, ["code-sent"], since),
|
|
2
|
|
);
|
|
assert.equal(
|
|
await smsPhoneLogDb.countByUserSince(USER, ["code-sent"], since),
|
|
1
|
|
);
|
|
});
|
|
|
|
it("ignores rows older than the window", async () => {
|
|
await smsPhoneLogDb.append({
|
|
userId: USER,
|
|
phoneHash: HASH,
|
|
action: "code-sent",
|
|
result: "ok",
|
|
});
|
|
const since = new Date(Date.now() + 60_000).toISOString();
|
|
assert.equal(
|
|
await smsPhoneLogDb.countByPhoneHashSince(HASH, ["code-sent"], since),
|
|
0
|
|
);
|
|
});
|
|
|
|
it("scrubs the number for one DID and keeps the hash and history", async () => {
|
|
for (const user of [USER, OTHER]) {
|
|
await smsPhoneLogDb.append({
|
|
userId: user,
|
|
phoneE164: PHONE,
|
|
phoneHash: HASH,
|
|
action: "code-sent",
|
|
result: "ok",
|
|
});
|
|
}
|
|
assert.equal(await smsPhoneLogDb.scrubPhoneNumber(USER, PHONE), 1);
|
|
|
|
const mine = await smsPhoneLogDb.listByUserId(USER);
|
|
assert.equal(mine.length, 1);
|
|
assert.equal(mine[0].phoneE164, undefined);
|
|
assert.equal(mine[0].phoneHash, HASH);
|
|
assert.equal(mine[0].action, "code-sent");
|
|
|
|
const theirs = await smsPhoneLogDb.listByUserId(OTHER);
|
|
assert.equal(theirs[0].phoneE164, PHONE);
|
|
});
|
|
});
|
|
|
|
describe("smsAlertAuthorizationDb", () => {
|
|
function batchJwts(day: string) {
|
|
return [{ sequence: 1, day, jwt: "jwt-" + day, nbf: 1, exp: 2 }];
|
|
}
|
|
|
|
it("stores into the SMS tables without touching the FCM ones", async () => {
|
|
await smsAlertAuthorizationDb.replaceUnusedBatch({
|
|
userId: USER,
|
|
batchId: "sms-batch-1",
|
|
jwts: batchJwts("2026-09-05"),
|
|
});
|
|
|
|
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 1);
|
|
assert.equal(await alertAuthorizationDb.countUnused(USER), 0);
|
|
assert.deepEqual(await alertAuthorizationDb.listDistinctUserIds(), []);
|
|
assert.deepEqual(await smsAlertAuthorizationDb.listDistinctUserIds(), [
|
|
USER,
|
|
]);
|
|
});
|
|
|
|
it("replaces unused SMS JWTs and leaves consumed ones", async () => {
|
|
await smsAlertAuthorizationDb.replaceUnusedBatch({
|
|
userId: USER,
|
|
batchId: "sms-batch-1",
|
|
jwts: [
|
|
...batchJwts("2026-09-05"),
|
|
{ sequence: 2, day: "2026-09-06", jwt: "jwt-b", nbf: 1, exp: 2 },
|
|
],
|
|
});
|
|
const first = await smsAlertAuthorizationDb.getUnusedForDay(
|
|
USER,
|
|
"2026-09-05"
|
|
);
|
|
assert.ok(first);
|
|
assert.equal(
|
|
await smsAlertAuthorizationDb.consumeUnusedJwt({
|
|
id: first.id,
|
|
userId: USER,
|
|
}),
|
|
true
|
|
);
|
|
|
|
await smsAlertAuthorizationDb.replaceUnusedBatch({
|
|
userId: USER,
|
|
batchId: "sms-batch-2",
|
|
jwts: batchJwts("2026-09-07"),
|
|
});
|
|
|
|
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 1);
|
|
const consumed = await smsAlertAuthorizationDb.getJwtById(first.id);
|
|
assert.equal(consumed?.status, "consumed");
|
|
assert.equal(
|
|
await smsAlertAuthorizationDb.getUnusedForDay(USER, "2026-09-06"),
|
|
undefined
|
|
);
|
|
});
|
|
|
|
it("round-trips a notifyTime and reports none when absent", async () => {
|
|
await smsAlertAuthorizationDb.replaceUnusedBatch({
|
|
userId: USER,
|
|
batchId: "sms-batch-1",
|
|
notifyHourUtc: 18,
|
|
notifyMinuteUtc: 30,
|
|
jwts: batchJwts("2026-09-05"),
|
|
});
|
|
const _b = (await smsAlertAuthorizationDb.getLatestBatch(USER));
|
|
assert.equal(_b?.notifyHourUtc, 18);
|
|
assert.equal(_b?.notifyMinuteUtc, 30);
|
|
|
|
await smsAlertAuthorizationDb.replaceUnusedBatch({
|
|
userId: USER,
|
|
batchId: "sms-batch-2",
|
|
jwts: batchJwts("2026-09-06"),
|
|
});
|
|
const _u = (await smsAlertAuthorizationDb.getLatestBatch(USER));
|
|
assert.equal(_u?.notifyHourUtc, undefined);
|
|
assert.equal(_u?.notifyMinuteUtc, undefined);
|
|
});
|
|
|
|
it("round-trips a recorded timezone and reports none when absent", async () => {
|
|
await smsAlertAuthorizationDb.replaceUnusedBatch({
|
|
userId: USER,
|
|
batchId: "sms-batch-1",
|
|
notifyHourUtc: 0,
|
|
notifyMinuteUtc: 30,
|
|
timezone: "America/Denver",
|
|
jwts: batchJwts("2026-09-05"),
|
|
});
|
|
const withZone = await smsAlertAuthorizationDb.getLatestBatch(USER);
|
|
assert.equal(withZone?.timezone, "America/Denver");
|
|
assert.equal(withZone?.notifyHourUtc, 0);
|
|
assert.equal(withZone?.notifyMinuteUtc, 30);
|
|
|
|
await smsAlertAuthorizationDb.replaceUnusedBatch({
|
|
userId: USER,
|
|
batchId: "sms-batch-2",
|
|
jwts: batchJwts("2026-09-06"),
|
|
});
|
|
const without = await smsAlertAuthorizationDb.getLatestBatch(USER);
|
|
assert.equal(without?.timezone, undefined);
|
|
assert.equal(without?.notifyHourUtc, undefined);
|
|
assert.equal(without?.notifyMinuteUtc, undefined);
|
|
});
|
|
|
|
it("lists only users with an unused JWT for the day asked about", async () => {
|
|
await smsAlertAuthorizationDb.replaceUnusedBatch({
|
|
userId: USER,
|
|
batchId: "sms-batch-1",
|
|
jwts: batchJwts("2026-09-05"),
|
|
});
|
|
await smsAlertAuthorizationDb.replaceUnusedBatch({
|
|
userId: OTHER,
|
|
batchId: "other-batch",
|
|
jwts: batchJwts("2026-09-06"),
|
|
});
|
|
|
|
assert.deepEqual(
|
|
await smsAlertAuthorizationDb.listPendingForDay({
|
|
day: "2026-09-05",
|
|
hourMinute: "12:00",
|
|
}),
|
|
[{ userId: USER, due: true }]
|
|
);
|
|
assert.deepEqual(
|
|
await smsAlertAuthorizationDb.listPendingForDay({
|
|
day: "2026-09-07",
|
|
hourMinute: "12:00",
|
|
}),
|
|
[]
|
|
);
|
|
});
|
|
|
|
it("drops a user from the list once that day's JWT is consumed", async () => {
|
|
await smsAlertAuthorizationDb.replaceUnusedBatch({
|
|
userId: USER,
|
|
batchId: "sms-batch-1",
|
|
jwts: batchJwts("2026-09-05"),
|
|
});
|
|
const jwt = await smsAlertAuthorizationDb.getUnusedForDay(
|
|
USER,
|
|
"2026-09-05"
|
|
);
|
|
assert.ok(jwt);
|
|
await smsAlertAuthorizationDb.consumeUnusedJwt({ id: jwt.id, userId: USER });
|
|
|
|
assert.deepEqual(
|
|
await smsAlertAuthorizationDb.listPendingForDay({
|
|
day: "2026-09-05",
|
|
hourMinute: "23:59",
|
|
}),
|
|
[]
|
|
);
|
|
});
|
|
|
|
it("flags a user not due until their stored hour, by text comparison", async () => {
|
|
await smsAlertAuthorizationDb.replaceUnusedBatch({
|
|
userId: USER,
|
|
batchId: "sms-batch-1",
|
|
notifyHourUtc: 9,
|
|
notifyMinuteUtc: 30,
|
|
jwts: batchJwts("2026-09-05"),
|
|
});
|
|
|
|
const at = async (hourMinute: string) =>
|
|
(
|
|
await smsAlertAuthorizationDb.listPendingForDay({
|
|
day: "2026-09-05",
|
|
hourMinute,
|
|
})
|
|
)[0]?.due;
|
|
|
|
assert.equal(await at("00:00"), false);
|
|
assert.equal(await at("09:29"), false);
|
|
assert.equal(await at("09:30"), true);
|
|
// Zero-padded HH:MM sorts chronologically, so 10:00 must beat 09:30.
|
|
assert.equal(await at("10:00"), true);
|
|
assert.equal(await at("23:59"), true);
|
|
});
|
|
|
|
it("reads the hour from the newest batch, not an older surviving one", async () => {
|
|
await smsAlertAuthorizationDb.replaceUnusedBatch({
|
|
userId: USER,
|
|
batchId: "old",
|
|
notifyHourUtc: 23,
|
|
notifyMinuteUtc: 0,
|
|
jwts: batchJwts("2026-09-05"),
|
|
});
|
|
// Consume it so the old batch row survives the next replace.
|
|
const jwt = await smsAlertAuthorizationDb.getUnusedForDay(
|
|
USER,
|
|
"2026-09-05"
|
|
);
|
|
assert.ok(jwt);
|
|
await smsAlertAuthorizationDb.consumeUnusedJwt({ id: jwt.id, userId: USER });
|
|
|
|
await smsAlertAuthorizationDb.replaceUnusedBatch({
|
|
userId: USER,
|
|
batchId: "new",
|
|
notifyHourUtc: 6,
|
|
notifyMinuteUtc: 0,
|
|
jwts: batchJwts("2026-09-06"),
|
|
});
|
|
|
|
const pending = await smsAlertAuthorizationDb.listPendingForDay({
|
|
day: "2026-09-06",
|
|
hourMinute: "07:00",
|
|
});
|
|
// 07:00 is past the new batch's 06:00 but short of the old batch's 23:00.
|
|
assert.deepEqual(pending, [{ userId: USER, due: true }]);
|
|
});
|
|
|
|
it("breaks a created_at tie by insertion order, not by random id", async () => {
|
|
const connection = getDatabase();
|
|
const sameInstant = "2026-09-05T00:00:00.000Z";
|
|
const insert = connection.prepare(
|
|
`INSERT INTO sms_alert_authorization_batches
|
|
(id, user_id, batch_id, notify_hour_min_utc, timezone, created_at)
|
|
VALUES (?, ?, ?, ?, NULL, ?)`
|
|
);
|
|
// Ids chosen so lexical order disagrees with insertion order both ways.
|
|
insert.run("zzz-first", USER, "older", "23:00", sameInstant);
|
|
insert.run("aaa-second", USER, "newer", "06:00", sameInstant);
|
|
connection
|
|
.prepare(
|
|
`INSERT INTO sms_alert_authorization_jwts
|
|
(id, batch_pk, user_id, batch_id, sequence, day, jwt, nbf, exp,
|
|
status, consumed_at, created_at)
|
|
VALUES ('j1', 'aaa-second', ?, 'newer', 1, '2026-09-05', 'j', 1, 2,
|
|
'unused', NULL, ?)`
|
|
)
|
|
.run(USER, sameInstant);
|
|
|
|
assert.equal(
|
|
(await smsAlertAuthorizationDb.getLatestBatch(USER))?.batchId,
|
|
"newer"
|
|
);
|
|
// 07:00 is past the newer batch's 06:00 but short of the older one's 23:00.
|
|
assert.deepEqual(
|
|
await smsAlertAuthorizationDb.listPendingForDay({
|
|
day: "2026-09-05",
|
|
hourMinute: "07:00",
|
|
}),
|
|
[{ userId: USER, due: true }]
|
|
);
|
|
});
|
|
|
|
it("treats a batch with no stored hour as always due", async () => {
|
|
await smsAlertAuthorizationDb.replaceUnusedBatch({
|
|
userId: USER,
|
|
batchId: "sms-batch-1",
|
|
jwts: batchJwts("2026-09-05"),
|
|
});
|
|
assert.deepEqual(
|
|
await smsAlertAuthorizationDb.listPendingForDay({
|
|
day: "2026-09-05",
|
|
hourMinute: "00:00",
|
|
}),
|
|
[{ userId: USER, due: true }]
|
|
);
|
|
});
|
|
|
|
it("stays due when the stored hour is unreadable, rather than never", async () => {
|
|
await smsAlertAuthorizationDb.replaceUnusedBatch({
|
|
userId: USER,
|
|
batchId: "sms-batch-1",
|
|
jwts: batchJwts("2026-09-05"),
|
|
});
|
|
// Only a hand-edited database reaches this state, but text ordering would
|
|
// rank "midnight" above every HH:MM and defer the user permanently.
|
|
getDatabase()
|
|
.prepare(
|
|
`UPDATE sms_alert_authorization_batches SET notify_hour_min_utc = ?
|
|
WHERE user_id = ?`
|
|
)
|
|
.run("midnight", USER);
|
|
|
|
assert.deepEqual(
|
|
await smsAlertAuthorizationDb.listPendingForDay({
|
|
day: "2026-09-05",
|
|
hourMinute: "00:00",
|
|
}),
|
|
[{ userId: USER, due: true }]
|
|
);
|
|
});
|
|
|
|
it("reads the SMS tables only, never the FCM ones", async () => {
|
|
await alertAuthorizationDb.replaceUnusedBatch({
|
|
userId: OTHER,
|
|
batchId: "fcm-batch",
|
|
jwts: batchJwts("2026-09-05"),
|
|
});
|
|
assert.deepEqual(
|
|
await smsAlertAuthorizationDb.listPendingForDay({
|
|
day: "2026-09-05",
|
|
hourMinute: "12:00",
|
|
}),
|
|
[]
|
|
);
|
|
assert.deepEqual(
|
|
await alertAuthorizationDb.listPendingForDay({
|
|
day: "2026-09-05",
|
|
hourMinute: "12:00",
|
|
}),
|
|
[{ userId: OTHER, due: true }]
|
|
);
|
|
});
|
|
|
|
it("deletes every batch and JWT for one user, consumed included", async () => {
|
|
await smsAlertAuthorizationDb.replaceUnusedBatch({
|
|
userId: USER,
|
|
batchId: "sms-batch-1",
|
|
jwts: [
|
|
...batchJwts("2026-09-05"),
|
|
{ sequence: 2, day: "2026-09-06", jwt: "jwt-b", nbf: 1, exp: 2 },
|
|
],
|
|
});
|
|
const first = await smsAlertAuthorizationDb.getUnusedForDay(
|
|
USER,
|
|
"2026-09-05"
|
|
);
|
|
assert.ok(first);
|
|
await smsAlertAuthorizationDb.consumeUnusedJwt({
|
|
id: first.id,
|
|
userId: USER,
|
|
});
|
|
await smsAlertAuthorizationDb.replaceUnusedBatch({
|
|
userId: OTHER,
|
|
batchId: "sms-batch-other",
|
|
jwts: batchJwts("2026-09-05"),
|
|
});
|
|
|
|
const removed = await smsAlertAuthorizationDb.deleteAllForUser(USER);
|
|
assert.equal(removed.deletedJwts, 2);
|
|
assert.equal(removed.deletedBatches, 1);
|
|
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 0);
|
|
assert.equal(await smsAlertAuthorizationDb.getJwtById(first.id), undefined);
|
|
assert.equal(
|
|
await smsAlertAuthorizationDb.getLatestBatch(USER),
|
|
undefined
|
|
);
|
|
assert.deepEqual(await smsAlertAuthorizationDb.listDistinctUserIds(), [
|
|
OTHER,
|
|
]);
|
|
});
|
|
|
|
it("deletes nothing, and does not fail, for an unknown user", async () => {
|
|
assert.deepEqual(
|
|
await smsAlertAuthorizationDb.deleteAllForUser("did:ethr:0xnobody"),
|
|
{ deletedBatches: 0, deletedJwts: 0 }
|
|
);
|
|
});
|
|
|
|
it("leaves the FCM inventory alone when the SMS one is deleted", async () => {
|
|
await alertAuthorizationDb.replaceUnusedBatch({
|
|
userId: USER,
|
|
batchId: "fcm-batch-1",
|
|
jwts: batchJwts("2026-09-05"),
|
|
});
|
|
await smsAlertAuthorizationDb.replaceUnusedBatch({
|
|
userId: USER,
|
|
batchId: "sms-batch-1",
|
|
jwts: batchJwts("2026-09-05"),
|
|
});
|
|
|
|
await smsAlertAuthorizationDb.deleteAllForUser(USER);
|
|
assert.equal(await alertAuthorizationDb.countUnused(USER), 1);
|
|
assert.deepEqual(await alertAuthorizationDb.listDistinctUserIds(), [USER]);
|
|
});
|
|
|
|
it("allows only one unused SMS JWT per (user, day)", () => {
|
|
const connection = getDatabase();
|
|
const now = new Date().toISOString();
|
|
connection
|
|
.prepare(
|
|
`
|
|
INSERT INTO sms_alert_authorization_jwts (
|
|
id, batch_pk, user_id, batch_id, sequence, day, jwt,
|
|
nbf, exp, status, consumed_at, created_at
|
|
) VALUES (?, 'pk', ?, 'b', ?, '2026-09-05', 'jwt', 1, 2, 'unused', NULL, ?)
|
|
`
|
|
)
|
|
.run("j1", USER, 1, now);
|
|
assert.throws(
|
|
() =>
|
|
connection
|
|
.prepare(
|
|
`
|
|
INSERT INTO sms_alert_authorization_jwts (
|
|
id, batch_pk, user_id, batch_id, sequence, day, jwt,
|
|
nbf, exp, status, consumed_at, created_at
|
|
) VALUES (?, 'pk', ?, 'b', ?, '2026-09-05', 'jwt', 1, 2, 'unused', NULL, ?)
|
|
`
|
|
)
|
|
.run("j2", USER, 2, now),
|
|
/UNIQUE/
|
|
);
|
|
});
|
|
});
|