Files
notification-wakeup-service/test/alertSearch/daily.test.ts
T
Jose Olarte III 226925278f Move remaining alertSearch tests under test/.
Keep them beside the existing SMS alertSearch tests and update imports so they still load src/.
2026-09-18 14:53:41 +08:00

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");
});
});