From 825d3c7ce4b40d56b484065a7ee3337fdbb8abd3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Sep 2025 22:51:40 -0400 Subject: [PATCH] Add view counter regression tests --- tests/view-counter.test.mjs | 362 ++++++++++++++++++++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 tests/view-counter.test.mjs diff --git a/tests/view-counter.test.mjs b/tests/view-counter.test.mjs new file mode 100644 index 00000000..479114e2 --- /dev/null +++ b/tests/view-counter.test.mjs @@ -0,0 +1,362 @@ +// Run with: node tests/view-counter.test.mjs + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; + +const { + VIEW_COUNT_DEDUPE_WINDOW_SECONDS, + VIEW_COUNT_BACKFILL_MAX_DAYS, +} = await import("../js/config.js"); + +if (typeof globalThis.window === "undefined") { + globalThis.window = {}; +} + +if (!globalThis.window.NostrTools) { + globalThis.window.NostrTools = {}; +} + +const { nostrClient } = await import("../js/nostr.js"); + +function createMockNostrHarness() { + const storedEvents = new Map(); + const customTotals = new Map(); + const subscribers = new Map(); + + const pointerKeyFromInput = (input) => { + if (!input) { + return ""; + } + if (typeof input === "string") { + const trimmed = input.trim(); + if (!trimmed) { + return ""; + } + if (trimmed.includes(":")) { + return trimmed; + } + return `e:${trimmed}`; + } + if (Array.isArray(input)) { + if (input.length < 2) { + return ""; + } + const type = input[0] === "a" ? "a" : "e"; + const value = typeof input[1] === "string" ? input[1].trim() : ""; + if (!value) { + return ""; + } + return `${type}:${value}`; + } + if (typeof input === "object") { + const type = input.type === "a" ? "a" : "e"; + const value = typeof input.value === "string" ? input.value.trim() : ""; + if (!value) { + return ""; + } + return `${type}:${value}`; + } + return ""; + }; + + const listVideoViewEvents = async (pointer, options = {}) => { + const key = pointerKeyFromInput(pointer); + const events = storedEvents.get(key) || []; + const since = Number.isFinite(options?.since) ? Number(options.since) : null; + if (since === null) { + return events; + } + return events.filter((event) => { + const createdAt = Number.isFinite(event?.created_at) ? Number(event.created_at) : 0; + return createdAt >= since; + }); + }; + + const countVideoViewEvents = async (pointer) => { + const key = pointerKeyFromInput(pointer); + if (customTotals.has(key)) { + return { total: customTotals.get(key) }; + } + const events = storedEvents.get(key) || []; + return { total: events.length }; + }; + + const subscribeVideoViewEvents = (pointer, options = {}) => { + const key = pointerKeyFromInput(pointer); + let handlers = subscribers.get(key); + if (!handlers) { + handlers = new Set(); + subscribers.set(key, handlers); + } + const handler = typeof options?.onEvent === "function" ? options.onEvent : null; + if (handler) { + handlers.add(handler); + } + return () => { + if (handler && handlers) { + handlers.delete(handler); + if (!handlers.size) { + subscribers.delete(key); + } + } + }; + }; + + const emit = (pointer, event) => { + const key = pointerKeyFromInput(pointer); + const handlers = subscribers.get(key); + if (!handlers) { + return; + } + for (const handler of Array.from(handlers)) { + handler(event); + } + }; + + const setEvents = (key, events) => { + storedEvents.set(key, Array.isArray(events) ? events : []); + }; + + const setCountTotal = (key, total) => { + if (Number.isFinite(total)) { + customTotals.set(key, Number(total)); + } else { + customTotals.delete(key); + } + }; + + const reset = () => { + storedEvents.clear(); + customTotals.clear(); + subscribers.clear(); + }; + + const subscriptionCount = (key) => { + const handlers = subscribers.get(key); + return handlers ? handlers.size : 0; + }; + + return { + listVideoViewEvents, + countVideoViewEvents, + subscribeVideoViewEvents, + emit, + setEvents, + setCountTotal, + reset, + pointerKeyFromInput, + subscriptionCount, + }; +} + +const harness = createMockNostrHarness(); + +nostrClient.listVideoViewEvents = harness.listVideoViewEvents; +nostrClient.countVideoViewEvents = harness.countVideoViewEvents; +nostrClient.subscribeVideoViewEvents = harness.subscribeVideoViewEvents; + +const { + initViewCounter, + subscribeToVideoViewCount, + unsubscribeFromVideoViewCount, + ingestLocalViewEvent, +} = await import("../js/viewCounter.js"); + +initViewCounter({ nostrClient }); + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + +async function testDedupesWithinWindow() { + localStorage.clear(); + harness.reset(); + + const pointer = { type: "e", value: "view-counter-dedupe" }; + const pointerKey = harness.pointerKeyFromInput(pointer); + const base = Math.floor(Date.now() / 1000); + const events = [ + { id: "evt-1", pubkey: "pub-dedupe", created_at: base }, + { + id: "evt-2", + pubkey: "pub-dedupe", + created_at: base + Math.floor(VIEW_COUNT_DEDUPE_WINDOW_SECONDS / 2), + }, + ]; + harness.setEvents(pointerKey, events); + harness.setCountTotal(pointerKey, 1); + + const updates = []; + const token = subscribeToVideoViewCount(pointer, (state) => { + updates.push({ ...state }); + }); + try { + await flushPromises(); + await flushPromises(); + const final = updates.at(-1); + assert.ok(final, "expected at least one state update"); + assert.equal( + final.total, + 1, + "multiple events from the same pubkey within the dedupe window should count once" + ); + } finally { + unsubscribeFromVideoViewCount(pointer, token); + } +} + +async function testHydrationSkipsStaleEventsAndRollsOff() { + localStorage.clear(); + harness.reset(); + + const pointer = { type: "a", value: "30078:pk:test-hydrate" }; + const pointerKey = harness.pointerKeyFromInput(pointer); + const nowSeconds = Math.floor(Date.now() / 1000); + const horizonSeconds = VIEW_COUNT_BACKFILL_MAX_DAYS * 86_400; + + const staleEvent = { + id: "evt-stale", + pubkey: "pub-hydrate", + created_at: nowSeconds - horizonSeconds - 60, + }; + const rolledOffEvent = { + id: "evt-rolled-off", + pubkey: "pub-hydrate", + created_at: nowSeconds - VIEW_COUNT_DEDUPE_WINDOW_SECONDS - 10, + }; + const recentEvent = { + id: "evt-recent", + pubkey: "pub-hydrate", + created_at: nowSeconds - 5, + }; + + harness.setEvents(pointerKey, [staleEvent, rolledOffEvent, recentEvent]); + harness.setCountTotal(pointerKey, 2); + + const updates = []; + let token = subscribeToVideoViewCount(pointer, (state) => { + updates.push({ ...state }); + }); + + try { + await flushPromises(); + await flushPromises(); + const final = updates.at(-1); + assert.ok(final, "expected hydration state update"); + assert.equal( + final.total, + 2, + "hydration should ignore events beyond the backfill window and keep unique buckets" + ); + } finally { + unsubscribeFromVideoViewCount(pointer, token); + } + + const futureEvent = { + id: "evt-future", + pubkey: "pub-hydrate", + created_at: nowSeconds + VIEW_COUNT_DEDUPE_WINDOW_SECONDS + 5, + }; + + harness.setEvents(pointerKey, [rolledOffEvent, recentEvent, futureEvent]); + harness.setCountTotal(pointerKey, 3); + + const secondUpdates = []; + token = subscribeToVideoViewCount(pointer, (state) => { + secondUpdates.push({ ...state }); + }); + try { + await flushPromises(); + await flushPromises(); + const final = secondUpdates.at(-1); + assert.ok(final, "expected updated hydration state"); + assert.equal( + final.total, + 3, + "events outside the dedupe window should contribute when hydrating fresh data" + ); + } finally { + unsubscribeFromVideoViewCount(pointer, token); + } +} + +async function testLocalIngestNotifiesImmediately() { + localStorage.clear(); + harness.reset(); + + const pointer = { type: "e", value: "view-counter-local" }; + const pointerKey = harness.pointerKeyFromInput(pointer); + harness.setEvents(pointerKey, []); + harness.setCountTotal(pointerKey, 0); + + const updates = []; + const token = subscribeToVideoViewCount(pointer, (state) => { + updates.push({ ...state }); + }); + + try { + await flushPromises(); + const initial = updates.at(-1); + assert.ok(initial, "expected initial state update"); + assert.equal(initial.total, 0, "initial total should start at zero"); + + const localEvent = { + id: "evt-local", + pubkey: "pub-local", + created_at: Math.floor(Date.now() / 1000), + }; + + ingestLocalViewEvent({ pointer, event: localEvent }); + + const latest = updates.at(-1); + assert.equal( + latest.total, + 1, + "local ingestion should immediately notify subscribers with incremented total" + ); + } finally { + unsubscribeFromVideoViewCount(pointer, token); + } +} + +async function testUnsubscribeStopsCallbacks() { + localStorage.clear(); + harness.reset(); + + const pointer = { type: "e", value: "view-counter-unsubscribe" }; + const pointerKey = harness.pointerKeyFromInput(pointer); + harness.setEvents(pointerKey, []); + harness.setCountTotal(pointerKey, 0); + + const updates = []; + const token = subscribeToVideoViewCount(pointer, (state) => { + updates.push({ ...state }); + }); + + await flushPromises(); + const updatesBeforeUnsubscribe = updates.length; + + unsubscribeFromVideoViewCount(pointer, token); + assert.equal( + harness.subscriptionCount(pointerKey), + 0, + "tearing down the final handler should close the live subscription" + ); + + harness.emit(pointerKey, { + id: "evt-ignored", + pubkey: "pub-unsubscribe", + created_at: Math.floor(Date.now() / 1000), + }); + + assert.equal( + updates.length, + updatesBeforeUnsubscribe, + "no handlers should fire once the final subscriber has been removed" + ); +} + +await testDedupesWithinWindow(); +await testHydrationSkipsStaleEventsAndRollsOff(); +await testLocalIngestNotifiesImmediately(); +await testUnsubscribeStopsCallbacks(); + +console.log("View counter tests completed successfully.");