Keep them beside the existing SMS alertSearch tests and update imports so they still load src/.
531 lines
18 KiB
TypeScript
531 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 { alertSearchCursorsDb } from "../../src/db/alertSearchCursorsSqlite.js";
|
|
import { closeDatabase } from "../../src/db/sqlite.js";
|
|
import {
|
|
partnerPageHasTiedBeforeDate,
|
|
type FetchLike,
|
|
} from "../../src/alertSearch/client.js";
|
|
import { runAlertSearchCycle } from "../../src/alertSearch/cycle.js";
|
|
import { ALERT_SEARCH_PAGE_SIZE } from "../../src/alertSearch/types.js";
|
|
|
|
const USER = "did:ethr:0xcycleuser";
|
|
const JWT = "delegated.jwt.token";
|
|
const ENDORSER_BASE = "https://api.endorser.ch";
|
|
const PARTNER_BASE = "https://partner-api.endorser.ch";
|
|
|
|
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 planRows(from: number, count: number) {
|
|
return Array.from({ length: count }, (_, i) => ({
|
|
handleId: `plan-${from + i}`,
|
|
jwtId: ulid(from + i),
|
|
}));
|
|
}
|
|
|
|
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) => Response | Promise<Response>
|
|
): { fetch: FetchLike; urls: string[] } {
|
|
const urls: string[] = [];
|
|
return {
|
|
urls,
|
|
fetch: async (url) => {
|
|
urls.push(url);
|
|
return handler(url);
|
|
},
|
|
};
|
|
}
|
|
|
|
const cycleOpts = {
|
|
jwt: JWT,
|
|
endorserBaseUrl: ENDORSER_BASE,
|
|
partnerBaseUrl: PARTNER_BASE,
|
|
};
|
|
|
|
describe("partnerPageHasTiedBeforeDate", () => {
|
|
it("is false when the page is under the server limit", () => {
|
|
assert.equal(
|
|
partnerPageHasTiedBeforeDate({
|
|
profilesNearby: profileRows(10, () => "2026-01-01T00:00:00.000Z"),
|
|
}),
|
|
false
|
|
);
|
|
});
|
|
|
|
it("is true when a full page shares the oldest updatedAt", () => {
|
|
const tied = "2026-01-01T12:00:00.000Z";
|
|
assert.equal(
|
|
partnerPageHasTiedBeforeDate({
|
|
profilesNearby: profileRows(ALERT_SEARCH_PAGE_SIZE, () => tied),
|
|
}),
|
|
true
|
|
);
|
|
});
|
|
|
|
it("is true when two or more rows share the page minimum", () => {
|
|
const rows = profileRows(ALERT_SEARCH_PAGE_SIZE, (i) =>
|
|
i < 2 ? "2026-01-01T00:00:00.000Z" : `2026-02-01T00:${String(i).padStart(2, "0")}:00.000Z`
|
|
);
|
|
assert.equal(partnerPageHasTiedBeforeDate({ profilesNearby: rows }), true);
|
|
});
|
|
|
|
it("is false when the full page has a unique oldest timestamp", () => {
|
|
const rows = profileRows(
|
|
ALERT_SEARCH_PAGE_SIZE,
|
|
(i) => `2026-01-01T00:${String(i).padStart(2, "0")}:00.000Z`
|
|
);
|
|
assert.equal(partnerPageHasTiedBeforeDate({ profilesNearby: rows }), false);
|
|
});
|
|
});
|
|
|
|
describe("alertSearch cycle integration", () => {
|
|
let dir: string;
|
|
let previousDataDir: string | undefined;
|
|
|
|
beforeEach(async () => {
|
|
previousDataDir = process.env.NOTIFY_DATA_DIR;
|
|
dir = await mkdtemp(path.join(tmpdir(), "alert-search-cycle-"));
|
|
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("first run omits cursors and persists after a complete non-empty retrieve", async () => {
|
|
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(
|
|
emptyEndorserBody({
|
|
claims: claimRows(10, 1),
|
|
trackedPlanUpdates: planRows(15, 1),
|
|
})
|
|
);
|
|
});
|
|
const cycle = await runAlertSearchCycle(USER, {
|
|
...cycleOpts,
|
|
config: { fetch: cap.fetch },
|
|
});
|
|
assert.equal(cycle.loaded.endorserAfterId, undefined);
|
|
assert.equal(cycle.loaded.partnerAfterDate, undefined);
|
|
for (const url of cap.urls) {
|
|
assert.equal(url.includes("afterId="), false);
|
|
assert.equal(url.includes("afterDate="), false);
|
|
}
|
|
assert.equal(cycle.retrieved.endorser.outcome, "success");
|
|
assert.equal(cycle.retrieved.partner.outcome, "success");
|
|
assert.equal(cycle.advanced.endorserAdvanced, true);
|
|
assert.equal(cycle.advanced.partnerAdvanced, true);
|
|
assert.equal(cycle.advanced.endorserAfterId, ulid(15));
|
|
assert.equal(cycle.advanced.partnerAfterAt, "2026-03-01T00:00:00.000Z");
|
|
});
|
|
|
|
it("loads Endorser afterId, paginates with MAX per-bucket minima, then advances to max ULID", async () => {
|
|
await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(1));
|
|
let endorserPages = 0;
|
|
const cap = captureFetch((url) => {
|
|
if (url.includes("/api/partner/")) {
|
|
return jsonResponse(emptyPartnerBody());
|
|
}
|
|
endorserPages += 1;
|
|
if (endorserPages === 1) {
|
|
assert.equal(url.includes(ulid(1)), true);
|
|
assert.equal(url.includes("beforeId="), false);
|
|
return jsonResponse(
|
|
emptyEndorserBody({
|
|
claims: claimRows(200, ALERT_SEARCH_PAGE_SIZE),
|
|
personalPlanContributions: claimRows(50, ALERT_SEARCH_PAGE_SIZE),
|
|
})
|
|
);
|
|
}
|
|
assert.equal(url.includes(`beforeId=${ulid(200)}`), true);
|
|
assert.equal(url.includes(`beforeId=${ulid(50)}`), false);
|
|
return jsonResponse(
|
|
emptyEndorserBody({
|
|
claims: claimRows(180, 3),
|
|
})
|
|
);
|
|
});
|
|
const cycle = await runAlertSearchCycle(USER, {
|
|
...cycleOpts,
|
|
config: { fetch: cap.fetch },
|
|
});
|
|
assert.equal(cycle.retrieved.endorser.outcome, "success");
|
|
assert.equal(cycle.retrieved.endorser.pageCount, 2);
|
|
assert.equal(cycle.advanced.endorserAdvanced, true);
|
|
assert.equal(cycle.advanced.endorserAfterId, ulid(249));
|
|
assert.equal(cycle.advanced.partnerAdvanced, false);
|
|
});
|
|
|
|
it("does not advance Endorser on empty retrieve", async () => {
|
|
await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(9));
|
|
const cap = captureFetch((url) => {
|
|
if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody());
|
|
return jsonResponse(emptyEndorserBody());
|
|
});
|
|
const cycle = await runAlertSearchCycle(USER, {
|
|
...cycleOpts,
|
|
config: { fetch: cap.fetch },
|
|
});
|
|
assert.equal(cycle.retrieved.endorser.outcome, "empty");
|
|
assert.equal(cycle.advanced.endorserAdvanced, false);
|
|
assert.equal(cycle.advanced.endorserAfterId, ulid(9));
|
|
});
|
|
|
|
it("does not advance Endorser when plansNearby hits 50", async () => {
|
|
await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(4));
|
|
let endorserPages = 0;
|
|
const cap = captureFetch((url) => {
|
|
if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody());
|
|
endorserPages += 1;
|
|
if (endorserPages === 1) {
|
|
return jsonResponse(
|
|
emptyEndorserBody({
|
|
plansNearby: planRows(300, ALERT_SEARCH_PAGE_SIZE),
|
|
})
|
|
);
|
|
}
|
|
return jsonResponse(
|
|
emptyEndorserBody({ plansNearby: planRows(250, 2) })
|
|
);
|
|
});
|
|
const cycle = await runAlertSearchCycle(USER, {
|
|
...cycleOpts,
|
|
config: { fetch: cap.fetch },
|
|
});
|
|
assert.equal(cycle.retrieved.endorser.outcome, "pagination");
|
|
assert.equal(cycle.retrieved.data.plansNearby.length, ALERT_SEARCH_PAGE_SIZE + 2);
|
|
assert.equal(cycle.advanced.endorserAdvanced, false);
|
|
assert.equal(cycle.advanced.endorserAfterId, ulid(4));
|
|
});
|
|
|
|
it("does not advance Endorser on HTTP auth failure", async () => {
|
|
await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(4));
|
|
const cap = captureFetch((url) => {
|
|
if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody());
|
|
return jsonResponse({ error: "Unauthorized" }, 401);
|
|
});
|
|
const cycle = await runAlertSearchCycle(USER, {
|
|
...cycleOpts,
|
|
config: { fetch: cap.fetch },
|
|
});
|
|
assert.equal(cycle.retrieved.endorser.outcome, "auth");
|
|
assert.equal(cycle.advanced.endorserAdvanced, false);
|
|
assert.equal(cycle.advanced.endorserAfterId, ulid(4));
|
|
});
|
|
|
|
it("loads Partner afterDate, paginates with beforeDate, advances max updatedAt", async () => {
|
|
await alertSearchCursorsDb.setPartnerAfterAt(
|
|
USER,
|
|
"2026-01-01T00:00:00.000Z"
|
|
);
|
|
const page1 = profileRows(
|
|
ALERT_SEARCH_PAGE_SIZE,
|
|
(i) => `2026-02-01T00:${String(i).padStart(2, "0")}:00.000Z`
|
|
);
|
|
const minTime = [...page1.map((p) => p.updatedAt)].sort()[0];
|
|
let partnerPages = 0;
|
|
const cap = captureFetch((url) => {
|
|
if (url.includes("/api/v2/report/")) {
|
|
return jsonResponse(emptyEndorserBody());
|
|
}
|
|
partnerPages += 1;
|
|
if (partnerPages === 1) {
|
|
assert.equal(
|
|
decodeURIComponent(url).includes("afterDate=2026-01-01T00:00:00.000Z"),
|
|
true
|
|
);
|
|
return jsonResponse(emptyPartnerBody({ profilesNearby: page1 }));
|
|
}
|
|
assert.equal(decodeURIComponent(url).includes(`beforeDate=${minTime}`), true);
|
|
return jsonResponse(
|
|
emptyPartnerBody({
|
|
profilesNearby: [
|
|
{
|
|
issuerDid: "did:ethr:0x99",
|
|
description: "p",
|
|
updatedAt: "2026-01-15T00:00:00.000Z",
|
|
rowId: 99,
|
|
},
|
|
],
|
|
})
|
|
);
|
|
});
|
|
const cycle = await runAlertSearchCycle(USER, {
|
|
...cycleOpts,
|
|
config: { fetch: cap.fetch },
|
|
});
|
|
assert.equal(cycle.retrieved.partner.outcome, "success");
|
|
assert.equal(cycle.retrieved.partner.pageCount, 2);
|
|
assert.equal(cycle.advanced.partnerAdvanced, true);
|
|
assert.equal(cycle.advanced.partnerAfterAt, page1.map((p) => p.updatedAt).sort()[page1.length - 1]);
|
|
});
|
|
|
|
it("does not advance Partner on empty retrieve", async () => {
|
|
await alertSearchCursorsDb.setPartnerAfterAt(USER, "2026-01-01T00:00:00.000Z");
|
|
const cap = captureFetch((url) => {
|
|
if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody());
|
|
return jsonResponse(emptyEndorserBody());
|
|
});
|
|
const cycle = await runAlertSearchCycle(USER, {
|
|
...cycleOpts,
|
|
config: { fetch: cap.fetch },
|
|
});
|
|
assert.equal(cycle.retrieved.partner.outcome, "empty");
|
|
assert.equal(cycle.advanced.partnerAdvanced, false);
|
|
assert.equal(cycle.advanced.partnerAfterAt, "2026-01-01T00:00:00.000Z");
|
|
});
|
|
|
|
it("does not advance Partner when a full page has tied oldest updatedAt", async () => {
|
|
await alertSearchCursorsDb.setPartnerAfterAt(USER, "2026-01-01T00:00:00.000Z");
|
|
const tied = "2026-04-01T00:00:00.000Z";
|
|
const cap = captureFetch((url) => {
|
|
if (url.includes("/api/v2/report/")) {
|
|
return jsonResponse(emptyEndorserBody());
|
|
}
|
|
return jsonResponse(
|
|
emptyPartnerBody({
|
|
profilesNearby: profileRows(ALERT_SEARCH_PAGE_SIZE, () => tied),
|
|
})
|
|
);
|
|
});
|
|
const cycle = await runAlertSearchCycle(USER, {
|
|
...cycleOpts,
|
|
config: { fetch: cap.fetch },
|
|
});
|
|
assert.equal(cycle.retrieved.partner.outcome, "pagination");
|
|
assert.match(cycle.retrieved.partner.message ?? "", /updatedAt/);
|
|
assert.equal(
|
|
cycle.retrieved.data.profilesNearby.length,
|
|
ALERT_SEARCH_PAGE_SIZE
|
|
);
|
|
assert.equal(cycle.advanced.partnerAdvanced, false);
|
|
assert.equal(cycle.advanced.partnerAfterAt, "2026-01-01T00:00:00.000Z");
|
|
});
|
|
|
|
it("A: both success → both advance", async () => {
|
|
const cap = captureFetch((url) => {
|
|
if (url.includes("/api/partner/")) {
|
|
return jsonResponse(
|
|
emptyPartnerBody({
|
|
profilesNearby: [
|
|
{
|
|
issuerDid: "did:ethr:0x1",
|
|
description: "p",
|
|
updatedAt: "2026-05-01T00:00:00.000Z",
|
|
},
|
|
],
|
|
})
|
|
);
|
|
}
|
|
return jsonResponse(emptyEndorserBody({ claims: claimRows(20, 1) }));
|
|
});
|
|
const cycle = await runAlertSearchCycle(USER, {
|
|
...cycleOpts,
|
|
config: { fetch: cap.fetch },
|
|
});
|
|
assert.equal(cycle.advanced.endorserAdvanced, true);
|
|
assert.equal(cycle.advanced.partnerAdvanced, true);
|
|
assert.equal(cycle.advanced.endorserAfterId, ulid(20));
|
|
assert.equal(cycle.advanced.partnerAfterAt, "2026-05-01T00:00:00.000Z");
|
|
});
|
|
|
|
it("B: Endorser success + Partner failure → Endorser only", async () => {
|
|
const cap = captureFetch((url) => {
|
|
if (url.includes("/api/partner/")) {
|
|
return jsonResponse({ error: "Unauthorized" }, 401);
|
|
}
|
|
return jsonResponse(emptyEndorserBody({ claims: claimRows(21, 1) }));
|
|
});
|
|
const cycle = await runAlertSearchCycle(USER, {
|
|
...cycleOpts,
|
|
config: { fetch: cap.fetch },
|
|
});
|
|
assert.equal(cycle.retrieved.endorser.outcome, "success");
|
|
assert.equal(cycle.retrieved.partner.outcome, "auth");
|
|
assert.equal(cycle.advanced.endorserAdvanced, true);
|
|
assert.equal(cycle.advanced.partnerAdvanced, false);
|
|
assert.equal(cycle.advanced.endorserAfterId, ulid(21));
|
|
assert.equal(cycle.advanced.partnerAfterAt, null);
|
|
});
|
|
|
|
it("C: Endorser failure + Partner success → Partner only", async () => {
|
|
const cap = captureFetch((url) => {
|
|
if (url.includes("/api/partner/")) {
|
|
return jsonResponse(
|
|
emptyPartnerBody({
|
|
profilesNearby: [
|
|
{
|
|
issuerDid: "did:ethr:0x1",
|
|
description: "p",
|
|
updatedAt: "2026-06-01T00:00:00.000Z",
|
|
},
|
|
],
|
|
})
|
|
);
|
|
}
|
|
return jsonResponse({ error: "fail" }, 500);
|
|
});
|
|
const cycle = await runAlertSearchCycle(USER, {
|
|
...cycleOpts,
|
|
config: { fetch: cap.fetch },
|
|
});
|
|
assert.equal(cycle.retrieved.endorser.outcome, "http");
|
|
assert.equal(cycle.retrieved.partner.outcome, "success");
|
|
assert.equal(cycle.advanced.endorserAdvanced, false);
|
|
assert.equal(cycle.advanced.partnerAdvanced, true);
|
|
assert.equal(cycle.advanced.endorserAfterId, null);
|
|
assert.equal(cycle.advanced.partnerAfterAt, "2026-06-01T00:00:00.000Z");
|
|
});
|
|
|
|
it("D: both fail → neither advances", async () => {
|
|
await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(3));
|
|
await alertSearchCursorsDb.setPartnerAfterAt(USER, "2026-01-01T00:00:00.000Z");
|
|
const cap = captureFetch(() => jsonResponse({ error: "down" }, 503));
|
|
const cycle = await runAlertSearchCycle(USER, {
|
|
...cycleOpts,
|
|
config: { fetch: cap.fetch },
|
|
});
|
|
assert.equal(cycle.advanced.endorserAdvanced, false);
|
|
assert.equal(cycle.advanced.partnerAdvanced, false);
|
|
assert.equal(cycle.advanced.endorserAfterId, ulid(3));
|
|
assert.equal(cycle.advanced.partnerAfterAt, "2026-01-01T00:00:00.000Z");
|
|
});
|
|
|
|
it("E: Partner incomplete + Endorser success → Endorser only", async () => {
|
|
const tied = "2026-07-01T00:00:00.000Z";
|
|
const cap = captureFetch((url) => {
|
|
if (url.includes("/api/partner/")) {
|
|
return jsonResponse(
|
|
emptyPartnerBody({
|
|
profilesNearby: profileRows(ALERT_SEARCH_PAGE_SIZE, () => tied),
|
|
})
|
|
);
|
|
}
|
|
return jsonResponse(emptyEndorserBody({ claims: claimRows(22, 1) }));
|
|
});
|
|
const cycle = await runAlertSearchCycle(USER, {
|
|
...cycleOpts,
|
|
config: { fetch: cap.fetch },
|
|
});
|
|
assert.equal(cycle.retrieved.endorser.outcome, "success");
|
|
assert.equal(cycle.retrieved.partner.outcome, "pagination");
|
|
assert.equal(cycle.advanced.endorserAdvanced, true);
|
|
assert.equal(cycle.advanced.partnerAdvanced, false);
|
|
assert.equal(cycle.advanced.endorserAfterId, ulid(22));
|
|
});
|
|
|
|
it("persists cursors across close/reopen and sends them on the next retrieve", async () => {
|
|
const first = captureFetch((url) => {
|
|
if (url.includes("/api/partner/")) {
|
|
return jsonResponse(
|
|
emptyPartnerBody({
|
|
profilesNearby: [
|
|
{
|
|
issuerDid: "did:ethr:0x1",
|
|
description: "p",
|
|
updatedAt: "2026-08-01T00:00:00.000Z",
|
|
},
|
|
],
|
|
})
|
|
);
|
|
}
|
|
return jsonResponse(emptyEndorserBody({ claims: claimRows(30, 1) }));
|
|
});
|
|
await runAlertSearchCycle(USER, {
|
|
...cycleOpts,
|
|
config: { fetch: first.fetch },
|
|
});
|
|
closeDatabase();
|
|
|
|
const second = captureFetch((url) => {
|
|
if (url.includes("/api/partner/")) return jsonResponse(emptyPartnerBody());
|
|
return jsonResponse(emptyEndorserBody());
|
|
});
|
|
const cycle = await runAlertSearchCycle(USER, {
|
|
...cycleOpts,
|
|
config: { fetch: second.fetch },
|
|
});
|
|
assert.equal(cycle.loaded.endorserAfterId, ulid(30));
|
|
assert.equal(cycle.loaded.partnerAfterDate, "2026-08-01T00:00:00.000Z");
|
|
const endorserUrl =
|
|
second.urls.find((u) => u.includes("/api/v2/report/")) ?? "";
|
|
const partnerUrl = second.urls.find((u) => u.includes("/api/partner/")) ?? "";
|
|
assert.equal(endorserUrl.includes(ulid(30)), true);
|
|
assert.equal(
|
|
decodeURIComponent(partnerUrl).includes("afterDate=2026-08-01T00:00:00.000Z"),
|
|
true
|
|
);
|
|
assert.equal(partnerUrl.includes("afterId=0"), false);
|
|
});
|
|
});
|