diff --git a/js/nostr.js b/js/nostr.js index dd97a339..79f12ef1 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -11,6 +11,7 @@ import { WATCH_HISTORY_FETCH_EVENT_LIMIT, WATCH_HISTORY_CACHE_TTL_MS, VIEW_COUNT_DEDUPE_WINDOW_SECONDS, + VIEW_COUNT_BACKFILL_MAX_DAYS, } from "./config.js"; import { ACCEPT_LEGACY_V1, @@ -3052,8 +3053,51 @@ class NostrClient { } async countVideoViewEvents(pointer, options = {}) { + const relayList = + Array.isArray(options?.relays) && options.relays.length + ? options.relays + : undefined; + + const fallbackListOptions = (() => { + const listOptions = {}; + + if (relayList) { + listOptions.relays = relayList; + } + + if (Number.isFinite(options?.since)) { + listOptions.since = Math.floor(options.since); + } else { + const horizonDaysRaw = Number(VIEW_COUNT_BACKFILL_MAX_DAYS); + const horizonDays = Number.isFinite(horizonDaysRaw) + ? Math.max(0, Math.floor(horizonDaysRaw)) + : 0; + if (horizonDays > 0) { + const secondsPerDay = 86_400; + const nowSeconds = Math.floor(Date.now() / 1000); + const fallbackSinceSeconds = Math.max( + 0, + nowSeconds - horizonDays * secondsPerDay + ); + listOptions.since = Math.floor(fallbackSinceSeconds); + } + } + + if (Number.isFinite(options?.until)) { + listOptions.until = Math.floor(options.until); + } + + if (Number.isFinite(options?.limit) && options.limit > 0) { + listOptions.limit = Math.floor(options.limit); + } + + return listOptions; + })(); + if (!this.pool) { - const events = await this.listVideoViewEvents(pointer, options); + const events = await this.listVideoViewEvents(pointer, { + ...fallbackListOptions, + }); return { total: Array.isArray(events) ? events.length : 0, perRelay: [], @@ -3067,9 +3111,6 @@ class NostrClient { if (!pointerFilter || typeof pointerFilter !== "object") { throw new Error("Invalid video pointer supplied for view lookup."); } - const relayList = Array.isArray(options?.relays) && options.relays.length - ? options.relays - : undefined; const signal = options?.signal; const normalizeAbortError = () => { @@ -3122,7 +3163,7 @@ class NostrClient { : null; const listPromise = this.listVideoViewEvents(pointer, { - relays: relayList, + ...fallbackListOptions, }); const events = abortPromise diff --git a/tests/nostr-count-fallback.test.mjs b/tests/nostr-count-fallback.test.mjs new file mode 100644 index 00000000..fc7a3487 --- /dev/null +++ b/tests/nostr-count-fallback.test.mjs @@ -0,0 +1,151 @@ +// Run with: node tests/nostr-count-fallback.test.mjs + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; + +if (typeof globalThis.window === "undefined") { + globalThis.window = {}; +} + +if (!globalThis.window.NostrTools) { + globalThis.window.NostrTools = {}; +} + +const { VIEW_COUNT_BACKFILL_MAX_DAYS } = await import("../js/config.js"); +const { nostrClient } = await import("../js/nostr.js"); + +const NostrClient = nostrClient.constructor; + +async function withFrozenTime(timestampSeconds, callback) { + const originalNow = Date.now; + Date.now = () => timestampSeconds * 1000; + try { + return await callback(); + } finally { + Date.now = originalNow; + } +} + +function getConfiguredHorizonDays() { + const horizonDaysRaw = Number(VIEW_COUNT_BACKFILL_MAX_DAYS); + return Number.isFinite(horizonDaysRaw) + ? Math.max(0, Math.floor(horizonDaysRaw)) + : 0; +} + +async function testFallbackAddsDefaultSinceWhenPoolMissing() { + const client = new NostrClient(); + client.pool = null; + + const pointer = { type: "e", value: "count-fallback-default" }; + const calls = []; + client.listVideoViewEvents = async (_, options = {}) => { + calls.push({ ...options }); + return []; + }; + + const horizonDays = getConfiguredHorizonDays(); + + const nowSeconds = 1_700_000_000; + await withFrozenTime(nowSeconds, async () => { + const result = await client.countVideoViewEvents(pointer, {}); + assert.equal(calls.length, 1, "fallback should issue a single list query"); + const [{ since }] = calls; + if (horizonDays > 0) { + const expectedSince = Math.max(0, nowSeconds - horizonDays * 86_400); + assert.equal( + since, + expectedSince, + "fallback list should clamp history to the configured horizon" + ); + } else { + assert.equal( + Object.prototype.hasOwnProperty.call(calls[0], "since"), + false, + "without a configured horizon the fallback should avoid forcing a since filter" + ); + } + assert.equal(result.fallback, true, "fallback branch should be flagged"); + }); +} + +async function testFallbackHonorsCallerOverrides() { + const client = new NostrClient(); + client.pool = null; + + const pointer = { type: "e", value: "count-fallback-overrides" }; + let receivedOptions = null; + client.listVideoViewEvents = async (_, options = {}) => { + receivedOptions = { ...options }; + return []; + }; + + const overrides = { + since: 1_234_567, + until: 1_235_000, + limit: 42, + }; + + await client.countVideoViewEvents(pointer, overrides); + + assert.ok(receivedOptions, "fallback should forward overrides to list"); + assert.equal( + receivedOptions.since, + Math.floor(overrides.since), + "caller supplied since should be preserved" + ); + assert.equal( + receivedOptions.until, + Math.floor(overrides.until), + "caller supplied until should be preserved" + ); + assert.equal( + receivedOptions.limit, + Math.floor(overrides.limit), + "caller supplied limit should be preserved" + ); +} + +async function testFallbackAfterCountFailureUsesDefaultWindow() { + const client = new NostrClient(); + client.pool = {}; + client.countEventsAcrossRelays = async () => { + throw new Error("count failed"); + }; + + const pointer = { type: "e", value: "count-fallback-error" }; + let receivedOptions = null; + client.listVideoViewEvents = async (_, options = {}) => { + receivedOptions = { ...options }; + return []; + }; + + const horizonDays = getConfiguredHorizonDays(); + + const nowSeconds = 1_800_000_000; + await withFrozenTime(nowSeconds, async () => { + await client.countVideoViewEvents(pointer, {}); + }); + + assert.ok(receivedOptions, "fallback should invoke the list helper"); + if (horizonDays > 0) { + const expectedSince = Math.max(0, nowSeconds - horizonDays * 86_400); + assert.equal( + receivedOptions.since, + expectedSince, + "COUNT fallback should restrict scans to the configured horizon" + ); + } else { + assert.equal( + Object.prototype.hasOwnProperty.call(receivedOptions, "since"), + false, + "COUNT fallback should avoid forcing a since filter when no horizon is set" + ); + } +} + +await testFallbackAddsDefaultSinceWhenPoolMissing(); +await testFallbackHonorsCallerOverrides(); +await testFallbackAfterCountFailureUsesDefaultWindow(); + +console.log("nostr COUNT fallback tests completed successfully.");