Keep them beside the existing SMS alertSearch tests and update imports so they still load src/.
532 lines
18 KiB
TypeScript
532 lines
18 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 {
|
|
ALERT_JWT_STATUS_CONSUMED,
|
|
ALERT_JWT_STATUS_UNUSED,
|
|
alertAuthorizationDb,
|
|
type AlertAuthorizationJwtInput,
|
|
} from "../../src/db/alertAuthorizationSqlite.js";
|
|
import { alertSearchCursorsDb } from "../../src/db/alertSearchCursorsSqlite.js";
|
|
import { closeDatabase } from "../../src/db/sqlite.js";
|
|
import type { FetchLike } from "../../src/alertSearch/client.js";
|
|
import { runDailyAlertSearch } from "../../src/alertSearch/daily.js";
|
|
import { ALERT_SEARCH_PAGE_SIZE } from "../../src/alertSearch/types.js";
|
|
|
|
const USER = "did:ethr:0xdailyuser";
|
|
const ENDORSER_BASE = "https://api.endorser.ch";
|
|
const PARTNER_BASE = "https://partner-api.endorser.ch";
|
|
|
|
/** 06:00Z: past midnight UTC, but still the previous day in the Americas. */
|
|
const NOW_SPLIT = new Date("2026-08-15T06:00:00.000Z");
|
|
const DAY_BEFORE = "2026-08-14";
|
|
const DAY_UTC = "2026-08-15";
|
|
const JWT_DAY_BEFORE = "delegated.jwt.day-before";
|
|
const JWT_DAY_UTC = "delegated.jwt.utc-day";
|
|
|
|
function ulid(n: number): string {
|
|
return `01H${String(n).padStart(23, "0")}`;
|
|
}
|
|
|
|
function jsonResponse(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
function emptyEndorserBody(overrides?: Record<string, unknown>) {
|
|
return {
|
|
data: {
|
|
claims: [],
|
|
personalPlanContributions: [],
|
|
trackedPlanUpdates: [],
|
|
trackedPlanClaims: [],
|
|
plansNearby: [],
|
|
...overrides,
|
|
},
|
|
};
|
|
}
|
|
|
|
function emptyPartnerBody(overrides?: Record<string, unknown>) {
|
|
return { data: { profilesNearby: [], ...overrides } };
|
|
}
|
|
|
|
function claimRows(from: number, count: number) {
|
|
return Array.from({ length: count }, (_, i) => ({
|
|
id: ulid(from + i),
|
|
issuedAt: "2026-01-01T00:00:00Z",
|
|
issuer: "did:ethr:0x1",
|
|
}));
|
|
}
|
|
|
|
function profileRows(
|
|
count: number,
|
|
updatedAt: (index: number) => string
|
|
) {
|
|
return Array.from({ length: count }, (_, i) => ({
|
|
issuerDid: `did:ethr:0x${i}`,
|
|
description: "p",
|
|
updatedAt: updatedAt(i),
|
|
rowId: i + 1,
|
|
}));
|
|
}
|
|
|
|
function captureFetch(
|
|
handler: (url: string, init?: RequestInit) => Response | Promise<Response>
|
|
): { fetch: FetchLike; urls: string[]; auths: string[] } {
|
|
const urls: string[] = [];
|
|
const auths: string[] = [];
|
|
const fetchImpl: FetchLike = async (url, init) => {
|
|
urls.push(url);
|
|
auths.push(new Headers(init?.headers).get("Authorization") ?? "");
|
|
return handler(url, init);
|
|
};
|
|
return { fetch: fetchImpl, urls, auths };
|
|
}
|
|
|
|
function jwtInput(
|
|
sequence: number,
|
|
day: string,
|
|
jwt: string
|
|
): AlertAuthorizationJwtInput {
|
|
return { sequence, day, jwt, nbf: 1, exp: 2 };
|
|
}
|
|
|
|
async function seedBatch(
|
|
jwts: AlertAuthorizationJwtInput[],
|
|
userId = USER,
|
|
batchId = "batch-1"
|
|
) {
|
|
await alertAuthorizationDb.replaceUnusedBatch({ userId, batchId, jwts });
|
|
}
|
|
|
|
function cycleOpts(fetch: FetchLike) {
|
|
return {
|
|
endorserBaseUrl: ENDORSER_BASE,
|
|
partnerBaseUrl: PARTNER_BASE,
|
|
config: { fetch },
|
|
};
|
|
}
|
|
|
|
function bothEmptyFetch() {
|
|
return captureFetch((url) => {
|
|
if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody());
|
|
return jsonResponse(emptyEndorserBody());
|
|
});
|
|
}
|
|
|
|
function bothSuccessFetch() {
|
|
return captureFetch((url) => {
|
|
if (url.includes("/api/partner/")) {
|
|
return jsonResponse(
|
|
emptyPartnerBody({
|
|
profilesNearby: [
|
|
{
|
|
issuerDid: "did:ethr:0x1",
|
|
description: "p",
|
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
|
rowId: 1,
|
|
},
|
|
],
|
|
})
|
|
);
|
|
}
|
|
return jsonResponse(
|
|
emptyEndorserBody({
|
|
claims: claimRows(10, 1),
|
|
})
|
|
);
|
|
});
|
|
}
|
|
|
|
describe("runDailyAlertSearch", () => {
|
|
let dir: string;
|
|
let previousDataDir: string | undefined;
|
|
|
|
beforeEach(async () => {
|
|
previousDataDir = process.env.NOTIFY_DATA_DIR;
|
|
dir = await mkdtemp(path.join(tmpdir(), "alert-search-daily-"));
|
|
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 });
|
|
});
|
|
|
|
it("selects the JWT for the current UTC day", async () => {
|
|
await seedBatch([
|
|
jwtInput(1, DAY_BEFORE, JWT_DAY_BEFORE),
|
|
jwtInput(2, DAY_UTC, JWT_DAY_UTC),
|
|
]);
|
|
const cap = bothEmptyFetch();
|
|
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
|
|
assert.equal(result.utcDay, DAY_UTC);
|
|
assert.equal(result.jwtSequence, 2);
|
|
assert.equal(result.completed, true);
|
|
assert.equal(result.consumed, true);
|
|
for (const auth of cap.auths) {
|
|
assert.equal(auth, `Bearer ${JWT_DAY_UTC}`);
|
|
}
|
|
});
|
|
|
|
it("leaves another day's JWT untouched", async () => {
|
|
await seedBatch([
|
|
jwtInput(1, DAY_BEFORE, JWT_DAY_BEFORE),
|
|
jwtInput(2, DAY_UTC, JWT_DAY_UTC),
|
|
]);
|
|
const cap = bothEmptyFetch();
|
|
await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
|
|
for (const auth of cap.auths) {
|
|
assert.equal(auth.includes(JWT_DAY_BEFORE), false);
|
|
}
|
|
const other = await alertAuthorizationDb.getUnusedForDay(USER, DAY_BEFORE);
|
|
assert.equal(other?.jwt, JWT_DAY_BEFORE);
|
|
assert.equal(other?.status, ALERT_JWT_STATUS_UNUSED);
|
|
});
|
|
|
|
it("rolls to the next day's JWT the instant UTC midnight passes", async () => {
|
|
await seedBatch([
|
|
jwtInput(1, DAY_BEFORE, JWT_DAY_BEFORE),
|
|
jwtInput(2, DAY_UTC, JWT_DAY_UTC),
|
|
]);
|
|
const before = await runDailyAlertSearch(
|
|
USER,
|
|
new Date("2026-08-14T23:59:59.000Z"),
|
|
cycleOpts(bothEmptyFetch().fetch)
|
|
);
|
|
assert.equal(before.utcDay, DAY_BEFORE);
|
|
assert.equal(before.jwtSequence, 1);
|
|
|
|
const after = await runDailyAlertSearch(
|
|
USER,
|
|
new Date("2026-08-15T00:00:00.000Z"),
|
|
cycleOpts(bothEmptyFetch().fetch)
|
|
);
|
|
assert.equal(after.utcDay, DAY_UTC);
|
|
assert.equal(after.jwtSequence, 2);
|
|
});
|
|
|
|
it("returns a structured no-JWT result when today has no unused JWT", async () => {
|
|
await seedBatch([jwtInput(2, DAY_BEFORE, JWT_DAY_BEFORE)]);
|
|
const result = await runDailyAlertSearch(
|
|
USER,
|
|
NOW_SPLIT,
|
|
cycleOpts(bothEmptyFetch().fetch)
|
|
);
|
|
assert.equal(result.userId, USER);
|
|
assert.equal(result.utcDay, DAY_UTC);
|
|
assert.equal(result.batchId, "batch-1");
|
|
assert.equal(result.jwtSequence, null);
|
|
assert.equal(result.endorserOutcome, null);
|
|
assert.equal(result.partnerOutcome, null);
|
|
assert.equal(result.completed, false);
|
|
assert.equal(result.consumed, false);
|
|
assert.equal(result.digest, null);
|
|
});
|
|
|
|
it("returns a structured no-JWT result when the user has no batch", async () => {
|
|
const result = await runDailyAlertSearch(USER, NOW_SPLIT);
|
|
assert.equal(result.utcDay, null);
|
|
assert.equal(result.batchId, null);
|
|
assert.equal(result.jwtSequence, null);
|
|
assert.equal(result.completed, false);
|
|
assert.equal(result.consumed, false);
|
|
assert.equal(result.digest, null);
|
|
});
|
|
|
|
it("passes today's JWT to runAlertSearchCycle", async () => {
|
|
await seedBatch([
|
|
jwtInput(1, DAY_UTC, JWT_DAY_UTC),
|
|
jwtInput(2, DAY_BEFORE, JWT_DAY_BEFORE),
|
|
]);
|
|
const cap = bothSuccessFetch();
|
|
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
|
|
assert.ok(cap.urls.some((u) => u.includes("/api/v2/report/alertSearch")));
|
|
assert.ok(cap.urls.some((u) => u.includes("/api/partner/alertSearch")));
|
|
assert.ok(cap.auths.length >= 2);
|
|
for (const auth of cap.auths) {
|
|
assert.equal(auth, `Bearer ${JWT_DAY_UTC}`);
|
|
}
|
|
assert.ok(result.digest);
|
|
assert.equal(result.digest.hasUpdates, true);
|
|
assert.equal(result.jwtSequence, 1);
|
|
});
|
|
|
|
it("consumes today's JWT when both sources succeed", async () => {
|
|
await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
|
|
const result = await runDailyAlertSearch(
|
|
USER,
|
|
NOW_SPLIT,
|
|
cycleOpts(bothSuccessFetch().fetch)
|
|
);
|
|
assert.equal(result.endorserOutcome, "success");
|
|
assert.equal(result.partnerOutcome, "success");
|
|
assert.equal(result.completed, true);
|
|
assert.equal(result.consumed, true);
|
|
assert.ok(result.digest);
|
|
assert.equal(result.digest.completed, true);
|
|
assert.equal(result.digest.hasUpdates, true);
|
|
assert.equal(result.digest.totalCount, 2);
|
|
assert.equal(result.digest.counts.claims, 1);
|
|
assert.equal(result.digest.counts.profilesNearby, 1);
|
|
assert.equal(result.digest.records.claims.length, 1);
|
|
assert.equal(result.digest.records.claims[0].id, ulid(10));
|
|
assert.equal(result.digest.records.profilesNearby[0].updatedAt, "2026-03-01T00:00:00.000Z");
|
|
const leftover = await alertAuthorizationDb.getUnusedForDay(USER, DAY_UTC);
|
|
assert.equal(leftover, undefined);
|
|
});
|
|
|
|
it("consumes today's JWT when both sources are empty", async () => {
|
|
await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
|
|
const result = await runDailyAlertSearch(
|
|
USER,
|
|
NOW_SPLIT,
|
|
cycleOpts(bothEmptyFetch().fetch)
|
|
);
|
|
assert.equal(result.endorserOutcome, "empty");
|
|
assert.equal(result.partnerOutcome, "empty");
|
|
assert.equal(result.completed, true);
|
|
assert.equal(result.consumed, true);
|
|
assert.ok(result.digest);
|
|
assert.equal(result.digest.completed, true);
|
|
assert.equal(result.digest.hasUpdates, false);
|
|
assert.equal(result.digest.totalCount, 0);
|
|
});
|
|
|
|
it("does not consume when Endorser succeeds and Partner fails", async () => {
|
|
await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
|
|
const cap = captureFetch((url) => {
|
|
if (url.includes("/api/partner/")) {
|
|
return jsonResponse({ error: "unauthorized" }, 401);
|
|
}
|
|
return jsonResponse(emptyEndorserBody({ claims: claimRows(10, 1) }));
|
|
});
|
|
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
|
|
assert.equal(result.endorserOutcome, "success");
|
|
assert.equal(result.partnerOutcome, "auth");
|
|
assert.equal(result.completed, false);
|
|
assert.equal(result.consumed, false);
|
|
assert.ok(result.digest);
|
|
assert.equal(result.digest.completed, false);
|
|
assert.equal(result.digest.hasUpdates, false);
|
|
assert.equal(result.digest.records.claims.length, 1);
|
|
assert.equal(result.digest.records.claims[0].id, ulid(10));
|
|
const unused = await alertAuthorizationDb.getUnusedForDay(USER, DAY_UTC);
|
|
assert.equal(unused?.jwt, JWT_DAY_UTC);
|
|
const stored = await alertSearchCursorsDb.get(USER);
|
|
assert.equal(stored?.endorserAfterId, ulid(10));
|
|
assert.equal(stored?.partnerAfterAt, null);
|
|
});
|
|
|
|
it("does not consume when Partner succeeds and Endorser fails", async () => {
|
|
await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
|
|
const cap = captureFetch((url) => {
|
|
if (url.includes("/api/partner/")) {
|
|
return jsonResponse(
|
|
emptyPartnerBody({
|
|
profilesNearby: [
|
|
{
|
|
issuerDid: "did:ethr:0x1",
|
|
description: "p",
|
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
|
rowId: 1,
|
|
},
|
|
],
|
|
})
|
|
);
|
|
}
|
|
return jsonResponse({ error: "unauthorized" }, 401);
|
|
});
|
|
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
|
|
assert.equal(result.endorserOutcome, "auth");
|
|
assert.equal(result.partnerOutcome, "success");
|
|
assert.equal(result.completed, false);
|
|
assert.equal(result.consumed, false);
|
|
assert.ok(result.digest);
|
|
assert.equal(result.digest.completed, false);
|
|
assert.equal(result.digest.hasUpdates, false);
|
|
assert.equal(result.digest.records.profilesNearby.length, 1);
|
|
const unused = await alertAuthorizationDb.getUnusedForDay(USER, DAY_UTC);
|
|
assert.equal(unused?.status, ALERT_JWT_STATUS_UNUSED);
|
|
const stored = await alertSearchCursorsDb.get(USER);
|
|
assert.equal(stored?.endorserAfterId, null);
|
|
assert.equal(stored?.partnerAfterAt, "2026-03-01T00:00:00.000Z");
|
|
});
|
|
|
|
it("does not consume on Endorser pagination", async () => {
|
|
await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
|
|
const cap = captureFetch((url) => {
|
|
if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody());
|
|
return jsonResponse({
|
|
...emptyEndorserBody(),
|
|
userMessage:
|
|
"Some data was not available in this search. Check the detail screens for the full set of data.",
|
|
});
|
|
});
|
|
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
|
|
assert.equal(result.endorserOutcome, "pagination");
|
|
assert.equal(result.partnerOutcome, "empty");
|
|
assert.equal(result.completed, false);
|
|
assert.equal(result.consumed, false);
|
|
assert.ok(result.digest);
|
|
assert.equal(result.digest.completed, false);
|
|
assert.equal(result.digest.hasUpdates, false);
|
|
assert.equal(result.digest.endorser.outcome, "pagination");
|
|
});
|
|
|
|
it("does not consume on Partner pagination", async () => {
|
|
await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
|
|
const cap = captureFetch((url) => {
|
|
if (url.includes("/api/partner/")) {
|
|
const tied = "2026-01-01T12:00:00.000Z";
|
|
return jsonResponse(
|
|
emptyPartnerBody({
|
|
profilesNearby: profileRows(ALERT_SEARCH_PAGE_SIZE, () => tied),
|
|
})
|
|
);
|
|
}
|
|
return jsonResponse(emptyEndorserBody());
|
|
});
|
|
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(cap.fetch));
|
|
assert.equal(result.endorserOutcome, "empty");
|
|
assert.equal(result.partnerOutcome, "pagination");
|
|
assert.equal(result.completed, false);
|
|
assert.equal(result.consumed, false);
|
|
assert.ok(result.digest);
|
|
assert.equal(result.digest.completed, false);
|
|
assert.equal(result.digest.partner.outcome, "pagination");
|
|
});
|
|
|
|
it("does not consume on network, auth, timeout, malformed, or http failure", async () => {
|
|
const cases: Array<{
|
|
name: string;
|
|
fetch: FetchLike;
|
|
expected: string;
|
|
}> = [
|
|
{
|
|
name: "network",
|
|
expected: "network",
|
|
fetch: async () => {
|
|
throw new TypeError("fetch failed");
|
|
},
|
|
},
|
|
{
|
|
name: "auth",
|
|
expected: "auth",
|
|
fetch: async () => jsonResponse({ error: "no jwt" }, 403),
|
|
},
|
|
{
|
|
name: "timeout",
|
|
expected: "timeout",
|
|
fetch: async () => {
|
|
const err = new Error("The operation was aborted");
|
|
err.name = "TimeoutError";
|
|
throw err;
|
|
},
|
|
},
|
|
{
|
|
name: "malformed",
|
|
expected: "malformed",
|
|
fetch: async () => jsonResponse({ data: { hitLimit: true } }),
|
|
},
|
|
{
|
|
name: "http",
|
|
expected: "http",
|
|
fetch: async () => jsonResponse({ error: "boom" }, 500),
|
|
},
|
|
];
|
|
|
|
for (const c of cases) {
|
|
closeDatabase();
|
|
await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
|
|
const result = await runDailyAlertSearch(USER, NOW_SPLIT, cycleOpts(c.fetch));
|
|
assert.equal(result.endorserOutcome, c.expected, c.name);
|
|
assert.equal(result.partnerOutcome, c.expected, c.name);
|
|
assert.equal(result.completed, false, c.name);
|
|
assert.equal(result.consumed, false, c.name);
|
|
assert.ok(result.digest, c.name);
|
|
assert.equal(result.digest.completed, false, c.name);
|
|
assert.equal(result.digest.hasUpdates, false, c.name);
|
|
const unused = await alertAuthorizationDb.getUnusedForDay(USER, DAY_UTC);
|
|
assert.equal(unused?.jwt, JWT_DAY_UTC, c.name);
|
|
}
|
|
});
|
|
|
|
it("consumes the exact selected JWT row, not another day's unused JWT", async () => {
|
|
await seedBatch([
|
|
jwtInput(1, DAY_UTC, JWT_DAY_UTC),
|
|
jwtInput(2, DAY_BEFORE, JWT_DAY_BEFORE),
|
|
]);
|
|
const today = await alertAuthorizationDb.getUnusedForDay(USER, DAY_UTC);
|
|
const other = await alertAuthorizationDb.getUnusedForDay(USER, DAY_BEFORE);
|
|
assert.ok(today);
|
|
assert.ok(other);
|
|
const result = await runDailyAlertSearch(
|
|
USER,
|
|
NOW_SPLIT,
|
|
cycleOpts(bothEmptyFetch().fetch)
|
|
);
|
|
assert.equal(result.consumed, true);
|
|
assert.equal(result.jwtSequence, 1);
|
|
const consumedRow = await alertAuthorizationDb.getJwtById(today.id);
|
|
assert.equal(consumedRow?.status, ALERT_JWT_STATUS_CONSUMED);
|
|
assert.ok(consumedRow?.consumedAt);
|
|
const stillUnused = await alertAuthorizationDb.getJwtById(other.id);
|
|
assert.equal(stillUnused?.status, ALERT_JWT_STATUS_UNUSED);
|
|
assert.equal(stillUnused?.consumedAt, undefined);
|
|
});
|
|
|
|
it("does not select or consume the same JWT after it has been consumed", async () => {
|
|
await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
|
|
const first = await runDailyAlertSearch(
|
|
USER,
|
|
NOW_SPLIT,
|
|
cycleOpts(bothEmptyFetch().fetch)
|
|
);
|
|
assert.equal(first.consumed, true);
|
|
const second = await runDailyAlertSearch(
|
|
USER,
|
|
NOW_SPLIT,
|
|
cycleOpts(bothEmptyFetch().fetch)
|
|
);
|
|
assert.equal(second.jwtSequence, null);
|
|
assert.equal(second.endorserOutcome, null);
|
|
assert.equal(second.completed, false);
|
|
assert.equal(second.consumed, false);
|
|
assert.equal(second.digest, null);
|
|
assert.equal(await alertAuthorizationDb.countUnused(USER), 0);
|
|
});
|
|
|
|
it("does not alter Phase 4B cursor rules: empty does not advance; success does", async () => {
|
|
await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
|
|
await runDailyAlertSearch(
|
|
USER,
|
|
NOW_SPLIT,
|
|
cycleOpts(bothEmptyFetch().fetch)
|
|
);
|
|
const afterEmpty = await alertSearchCursorsDb.get(USER);
|
|
assert.equal(afterEmpty, undefined);
|
|
|
|
closeDatabase();
|
|
await seedBatch([jwtInput(1, DAY_UTC, JWT_DAY_UTC)]);
|
|
await runDailyAlertSearch(
|
|
USER,
|
|
NOW_SPLIT,
|
|
cycleOpts(bothSuccessFetch().fetch)
|
|
);
|
|
const afterSuccess = await alertSearchCursorsDb.get(USER);
|
|
assert.equal(afterSuccess?.endorserAfterId, ulid(10));
|
|
assert.equal(afterSuccess?.partnerAfterAt, "2026-03-01T00:00:00.000Z");
|
|
});
|
|
});
|