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 { advanceAlertSearchCursors, loadAlertSearchCursors, maxEndorserAlertSearchUlid, } from "../../src/alertSearch/cursors.js"; import { retrieveAlertSearch } from "../../src/alertSearch/retrieve.js"; import type { AlertSearchSourceResult, RetrieveAlertSearchResult } from "../../src/alertSearch/retrieve.js"; import { ALERT_SEARCH_PAGE_SIZE } from "../../src/alertSearch/types.js"; import type { EndorserAlertSearchData, PartnerAlertSearchData } from "../../src/alertSearch/types.js"; const USER = "did:ethr:0xcursoruser"; const OTHER_USER = "did:ethr:0xother"; const JWT = "delegated.jwt.token"; function ulid(n: number): string { return `01H${String(n).padStart(23, "0")}`; } function emptyEndorser(): EndorserAlertSearchData { return { claims: [], personalPlanContributions: [], trackedPlanUpdates: [], trackedPlanClaims: [], plansNearby: [], }; } function emptyPartner(): PartnerAlertSearchData { return { profilesNearby: [] }; } function endorserResult( outcome: AlertSearchSourceResult["outcome"], data: EndorserAlertSearchData = emptyEndorser() ): AlertSearchSourceResult { return { outcome, pageCount: 1, data }; } function partnerResult( outcome: AlertSearchSourceResult["outcome"], data: PartnerAlertSearchData = emptyPartner() ): AlertSearchSourceResult { return { outcome, pageCount: 1, data }; } function combined( endorser: AlertSearchSourceResult, partner: AlertSearchSourceResult ): RetrieveAlertSearchResult { return { data: { ...endorser.data, ...partner.data }, empty: false, endorser, partner, }; } function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" }, }); } function emptyEndorserBody() { return { data: { claims: [], personalPlanContributions: [], trackedPlanUpdates: [], trackedPlanClaims: [], plansNearby: [], }, }; } function emptyPartnerBody(overrides?: Record) { return { data: { profilesNearby: [], ...overrides } }; } describe("alertSearch cursor persistence", () => { let dir: string; let previousDataDir: string | undefined; beforeEach(async () => { previousDataDir = process.env.NOTIFY_DATA_DIR; dir = await mkdtemp(path.join(tmpdir(), "alert-search-cursors-")); 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("no existing cursor → retrieval receives no afterId or afterDate", async () => { const loaded = await loadAlertSearchCursors(USER); assert.equal(loaded.endorserAfterId, undefined); assert.equal(loaded.partnerAfterDate, undefined); const urls: string[] = []; const result = await retrieveAlertSearch({ jwt: JWT, ...loaded, endorserBaseUrl: "https://api.endorser.ch", partnerBaseUrl: "https://partner-api.endorser.ch", config: { fetch: async (url) => { urls.push(url); if (url.includes("/api/partner/")) { return jsonResponse(emptyPartnerBody()); } return jsonResponse(emptyEndorserBody()); }, }, }); assert.equal(result.endorser.outcome, "empty"); for (const url of urls) { assert.equal(url.includes("afterId="), false); assert.equal(url.includes("afterDate="), false); } }); it("complete Endorser retrieval → cursor becomes maximum returned ULID", async () => { const data = emptyEndorser(); data.claims = [ { id: ulid(10), issuedAt: "t", issuer: "did:ethr:0x1" }, { id: ulid(30), issuedAt: "t", issuer: "did:ethr:0x1" }, ]; data.plansNearby = [{ handleId: "p", jwtId: ulid(20) }]; assert.equal(maxEndorserAlertSearchUlid(data), ulid(30)); const advanced = await advanceAlertSearchCursors( USER, combined(endorserResult("success", data), partnerResult("empty")) ); assert.equal(advanced.endorserAdvanced, true); assert.equal(advanced.endorserAfterId, ulid(30)); assert.equal(advanced.partnerAdvanced, false); }); it("Endorser empty retrieval → existing cursor unchanged", async () => { await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(99)); const advanced = await advanceAlertSearchCursors( USER, combined(endorserResult("empty"), partnerResult("empty")) ); assert.equal(advanced.endorserAdvanced, false); assert.equal(advanced.endorserAfterId, ulid(99)); }); it("Endorser pagination → cursor unchanged", async () => { await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(5)); const data = emptyEndorser(); data.claims = Array.from({ length: ALERT_SEARCH_PAGE_SIZE }, (_, i) => ({ id: ulid(100 + i), issuedAt: "t", issuer: "did:ethr:0x1", })); const advanced = await advanceAlertSearchCursors( USER, combined(endorserResult("pagination", data), partnerResult("empty")) ); assert.equal(advanced.endorserAdvanced, false); assert.equal(advanced.endorserAfterId, ulid(5)); }); it("Endorser error → cursor unchanged", async () => { await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(5)); for (const outcome of ["auth", "timeout", "network", "malformed", "http"] as const) { const advanced = await advanceAlertSearchCursors( USER, combined(endorserResult(outcome), partnerResult("empty")) ); assert.equal(advanced.endorserAdvanced, false, outcome); assert.equal(advanced.endorserAfterId, ulid(5), outcome); } }); it("complete Partner retrieval → cursor becomes maximum returned updatedAt", async () => { const data = emptyPartner(); data.profilesNearby = [ { issuerDid: "did:ethr:0x1", description: "a", updatedAt: "2026-01-01T00:00:00.000Z", }, { issuerDid: "did:ethr:0x2", description: "b", updatedAt: "2026-03-01T12:00:00.000Z", }, ]; const advanced = await advanceAlertSearchCursors( USER, combined(endorserResult("empty"), partnerResult("success", data)) ); assert.equal(advanced.partnerAdvanced, true); assert.equal(advanced.partnerAfterAt, "2026-03-01T12:00:00.000Z"); }); it("Partner empty retrieval → existing cursor unchanged", async () => { await alertSearchCursorsDb.setPartnerAfterAt(USER, "2026-02-01T00:00:00.000Z"); const advanced = await advanceAlertSearchCursors( USER, combined(endorserResult("empty"), partnerResult("empty")) ); assert.equal(advanced.partnerAdvanced, false); assert.equal(advanced.partnerAfterAt, "2026-02-01T00:00:00.000Z"); }); it("Partner pagination → cursor unchanged", async () => { await alertSearchCursorsDb.setPartnerAfterAt(USER, "2026-02-01T00:00:00.000Z"); const data = emptyPartner(); data.profilesNearby = Array.from({ length: ALERT_SEARCH_PAGE_SIZE }, (_, i) => ({ issuerDid: `did:ethr:0x${i}`, description: "p", updatedAt: "2026-04-01T00:00:00.000Z", })); const advanced = await advanceAlertSearchCursors( USER, combined(endorserResult("empty"), partnerResult("pagination", data)) ); assert.equal(advanced.partnerAdvanced, false); assert.equal(advanced.partnerAfterAt, "2026-02-01T00:00:00.000Z"); }); it("Partner error → cursor unchanged", async () => { await alertSearchCursorsDb.setPartnerAfterAt(USER, "2026-02-01T00:00:00.000Z"); const advanced = await advanceAlertSearchCursors( USER, combined(endorserResult("empty"), partnerResult("network")) ); assert.equal(advanced.partnerAdvanced, false); assert.equal(advanced.partnerAfterAt, "2026-02-01T00:00:00.000Z"); }); it("Endorser success + Partner failure → only Endorser advances", async () => { const endorserData = emptyEndorser(); endorserData.claims = [ { id: ulid(40), issuedAt: "t", issuer: "did:ethr:0x1" }, ]; const advanced = await advanceAlertSearchCursors( USER, combined(endorserResult("success", endorserData), partnerResult("auth")) ); assert.equal(advanced.endorserAdvanced, true); assert.equal(advanced.endorserAfterId, ulid(40)); assert.equal(advanced.partnerAdvanced, false); assert.equal(advanced.partnerAfterAt, null); }); it("Endorser failure + Partner success → only Partner advances", async () => { const partnerData = emptyPartner(); partnerData.profilesNearby = [ { issuerDid: "did:ethr:0x1", description: "p", updatedAt: "2026-05-01T00:00:00.000Z", }, ]; const advanced = await advanceAlertSearchCursors( USER, combined(endorserResult("timeout"), partnerResult("success", partnerData)) ); assert.equal(advanced.endorserAdvanced, false); assert.equal(advanced.endorserAfterId, null); assert.equal(advanced.partnerAdvanced, true); assert.equal(advanced.partnerAfterAt, "2026-05-01T00:00:00.000Z"); }); it("both successful → both advance", async () => { const endorserData = emptyEndorser(); endorserData.trackedPlanClaims = [ { id: ulid(7), issuedAt: "t", issuer: "did:ethr:0x1" }, ]; const partnerData = emptyPartner(); partnerData.profilesNearby = [ { issuerDid: "did:ethr:0x1", description: "p", updatedAt: "2026-06-01T00:00:00.000Z", }, ]; const advanced = await advanceAlertSearchCursors( USER, combined( endorserResult("success", endorserData), partnerResult("success", partnerData) ) ); assert.equal(advanced.endorserAdvanced, true); assert.equal(advanced.partnerAdvanced, true); assert.equal(advanced.endorserAfterId, ulid(7)); assert.equal(advanced.partnerAfterAt, "2026-06-01T00:00:00.000Z"); }); it("no usable returned cursor → existing cursor unchanged", async () => { await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(8)); await alertSearchCursorsDb.setPartnerAfterAt(USER, "2026-01-01T00:00:00.000Z"); const endorserData = emptyEndorser(); endorserData.claims = [ { id: "not-a-ulid", issuedAt: "t", issuer: "did:ethr:0x1" }, ]; const partnerData = emptyPartner(); partnerData.profilesNearby = [ { issuerDid: "did:ethr:0x1", description: "p" }, ]; const advanced = await advanceAlertSearchCursors( USER, combined( endorserResult("success", endorserData), partnerResult("success", partnerData) ) ); assert.equal(advanced.endorserAdvanced, false); assert.equal(advanced.partnerAdvanced, false); assert.equal(advanced.endorserAfterId, ulid(8)); assert.equal(advanced.partnerAfterAt, "2026-01-01T00:00:00.000Z"); }); it("survives close and reopen of the SQLite connection", async () => { await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(11)); await alertSearchCursorsDb.setPartnerAfterAt( USER, "2026-07-01T00:00:00.000Z" ); closeDatabase(); const loaded = await loadAlertSearchCursors(USER); assert.equal(loaded.endorserAfterId, ulid(11)); assert.equal(loaded.partnerAfterDate, "2026-07-01T00:00:00.000Z"); const other = await loadAlertSearchCursors(OTHER_USER); assert.equal(other.endorserAfterId, undefined); assert.equal(other.partnerAfterDate, undefined); }); it("loaded Partner cursor is sent as afterDate, not afterId 0", async () => { await alertSearchCursorsDb.setPartnerAfterAt( USER, "2026-08-01T00:00:00.000Z" ); await alertSearchCursorsDb.setEndorserAfterId(USER, ulid(12)); const loaded = await loadAlertSearchCursors(USER); const urls: string[] = []; await retrieveAlertSearch({ jwt: JWT, ...loaded, endorserBaseUrl: "https://api.endorser.ch", partnerBaseUrl: "https://partner-api.endorser.ch", config: { fetch: async (url) => { urls.push(url); if (url.includes("/api/partner/")) { return jsonResponse(emptyPartnerBody()); } return jsonResponse(emptyEndorserBody()); }, }, }); const endorserUrl = urls.find((u) => u.includes("/api/v2/report/")) ?? ""; const partnerUrl = urls.find((u) => u.includes("/api/partner/")) ?? ""; assert.equal(endorserUrl.includes(ulid(12)), true); assert.equal(partnerUrl.includes("afterId=0"), false); assert.equal( decodeURIComponent(partnerUrl).includes("afterDate=2026-08-01T00:00:00.000Z"), true ); }); });