// Run with: node tests/watch-history.test.mjs import "./test-helpers/setup-localstorage.mjs"; import assert from "node:assert/strict"; const { WATCH_HISTORY_PAYLOAD_MAX_BYTES, WATCH_HISTORY_MAX_ITEMS } = await import("../js/config.js"); const { getWatchHistoryV2Enabled, setWatchHistoryV2Enabled, } = await import("../js/constants.js"); const { nostrClient, chunkWatchHistoryPayloadItems, } = await import("../js/nostr.js"); const { watchHistoryService } = await import("../js/watchHistoryService.js"); const { getApplication, setApplication } = await import( "../js/applicationContext.js" ); if (typeof globalThis.window === "undefined") { globalThis.window = {}; } if (!window.crypto || !window.crypto.subtle) { const { webcrypto } = await import("node:crypto"); window.crypto = webcrypto; } const originalFlag = getWatchHistoryV2Enabled(); setWatchHistoryV2Enabled(true); const originalWindowNostr = window.nostr; const originalNostrTools = window.NostrTools || {}; const originalPool = nostrClient.pool; const originalRelays = Array.isArray(nostrClient.relays) ? [...nostrClient.relays] : nostrClient.relays; const originalReadRelays = Array.isArray(nostrClient.readRelays) ? [...nostrClient.readRelays] : nostrClient.readRelays; const originalWriteRelays = Array.isArray(nostrClient.writeRelays) ? [...nostrClient.writeRelays] : nostrClient.writeRelays; const originalEnsureSessionActor = nostrClient.ensureSessionActor; const originalSessionActor = nostrClient.sessionActor; const originalPubkey = nostrClient.pubkey; const originalWatchHistoryLastCreatedAt = nostrClient.watchHistoryLastCreatedAt; const originalRecordVideoView = nostrClient.recordVideoView; const originalWatchHistoryCache = nostrClient.watchHistoryCache; const originalWatchHistoryStorage = nostrClient.watchHistoryStorage; const originalScheduleWatchHistoryRepublish = nostrClient.scheduleWatchHistoryRepublish; const originalResolveWatchHistory = nostrClient.resolveWatchHistory; const originalGetWatchHistoryFingerprint = nostrClient.getWatchHistoryFingerprint; nostrClient.watchHistoryCache = new Map(); nostrClient.watchHistoryStorage = { version: 2, actors: {}, }; function cloneEvent(event) { return JSON.parse(JSON.stringify(event)); } function createFakeSimplePool() { let resolver = () => ({ ok: true }); const publishedEvents = []; const publishLog = []; let callIndex = 0; const pool = { publish(relays, event) { const normalizedRelays = Array.isArray(relays) ? [...relays] : []; const clonedEvent = cloneEvent(event); const entry = { index: callIndex += 1, relays: normalizedRelays, event: clonedEvent, handlers: {}, }; publishLog.push(entry); const handle = { on(type, handler) { if (typeof handler === "function") { entry.handlers[type] = handler; } return handle; }, }; setTimeout(() => { let outcome; try { outcome = resolver({ index: entry.index, relays: normalizedRelays, event: clonedEvent, }); } catch (error) { outcome = { ok: false, error }; } if (outcome && outcome.ok) { publishedEvents.push(cloneEvent(clonedEvent)); if (typeof entry.handlers.ok === "function") { entry.handlers.ok(); } } else if (typeof entry.handlers.failed === "function") { entry.handlers.failed( outcome?.error || new Error("publish failed"), ); } }, 0); return handle; }, list(relays, filters) { const matched = []; const listFilters = Array.isArray(filters) ? filters : []; for (const filter of listFilters) { const matches = publishedEvents.filter((event) => { if ( Array.isArray(filter?.kinds) && !filter.kinds.includes(event.kind) ) { return false; } if ( Array.isArray(filter?.authors) && !filter.authors.includes(event.pubkey) ) { return false; } if (Array.isArray(filter?.["#d"])) { const dValues = (event.tags || []) .filter( (tag) => Array.isArray(tag) && tag[0] === "d" && typeof tag[1] === "string", ) .map((tag) => tag[1]); if (!dValues.some((value) => filter["#d"].includes(value))) { return false; } } if (Array.isArray(filter?.["#snapshot"])) { const snapshotValues = (event.tags || []) .filter( (tag) => Array.isArray(tag) && tag[0] === "snapshot" && typeof tag[1] === "string", ) .map((tag) => tag[1]); if ( !snapshotValues.some((value) => filter["#snapshot"].includes(value)) ) { return false; } } return true; }); const limited = Number.isFinite(filter?.limit) ? matches.slice(0, Math.max(0, Math.floor(filter.limit))) : matches; matched.push(limited.map((event) => cloneEvent(event))); } return Promise.resolve(matched); }, reset() { publishedEvents.length = 0; publishLog.length = 0; callIndex = 0; }, setResolver(fn) { resolver = typeof fn === "function" ? fn : () => ({ ok: true }); }, getEvents() { return publishedEvents.map((event) => cloneEvent(event)); }, getPublishLog() { return publishLog.map((entry) => ({ index: entry.index, relays: [...entry.relays], event: cloneEvent(entry.event), })); }, }; return pool; } const poolHarness = createFakeSimplePool(); nostrClient.pool = poolHarness; nostrClient.relays = ["wss://relay.test"]; nostrClient.readRelays = ["wss://relay.test"]; nostrClient.writeRelays = ["wss://relay.test"]; function installSessionCrypto({ privateKey }) { const original = window.NostrTools || {}; let encryptCalls = 0; let decryptCalls = 0; window.NostrTools = { ...original, getEventHash: (event) => `hash-${event.kind}-${event.created_at}-${event.tags?.length || 0}`, signEvent: (event, key) => ({ ...event, id: `signed-${event.kind}-${event.created_at}-${event.tags?.length || 0}`, sig: `sig-${key}`, }), nip04: { ...(original.nip04 || {}), encrypt: async (secret, pub, plaintext) => { encryptCalls += 1; const payload = Buffer.from(plaintext, "utf8").toString("base64"); return `session:${secret}:${pub}:${payload}`; }, decrypt: async (secret, pub, ciphertext) => { decryptCalls += 1; const prefix = `session:${secret}:${pub}:`; if (!ciphertext.startsWith(prefix)) { throw new Error("invalid-session-ciphertext"); } const encoded = ciphertext.slice(prefix.length); return Buffer.from(encoded, "base64").toString("utf8"); }, }, }; return { restore() { window.NostrTools = original; }, getEncryptCalls: () => encryptCalls, getDecryptCalls: () => decryptCalls, }; } function installExtensionCrypto({ actor }) { const originalNostr = window.nostr; const originalTools = window.NostrTools || {}; let extensionEncrypts = 0; let extensionDecrypts = 0; let fallbackEncrypts = 0; window.nostr = { signEvent: async (event) => ({ ...event, id: `ext-${event.kind}-${event.created_at}`, sig: "ext-signature", }), nip04: { encrypt: async (target, plaintext) => { extensionEncrypts += 1; const payload = Buffer.from(plaintext, "utf8").toString("base64"); return `extension:${target}:${payload}`; }, decrypt: async (target, ciphertext) => { extensionDecrypts += 1; const prefix = `extension:${target}:`; if (!ciphertext.startsWith(prefix)) { throw new Error("invalid-extension-ciphertext"); } const encoded = ciphertext.slice(prefix.length); return Buffer.from(encoded, "base64").toString("utf8"); }, }, }; window.NostrTools = { ...originalTools, nip04: { ...(originalTools.nip04 || {}), encrypt: () => { fallbackEncrypts += 1; throw new Error("fallback-encrypt-used"); }, decrypt: async (secret, pub, ciphertext) => { const prefix = `session:${secret}:${pub}:`; if (!ciphertext.startsWith(prefix)) { throw new Error("invalid-session-ciphertext"); } const encoded = ciphertext.slice(prefix.length); return Buffer.from(encoded, "base64").toString("utf8"); }, }, }; return { restore() { window.nostr = originalNostr; window.NostrTools = originalTools; }, getExtensionEncrypts: () => extensionEncrypts, getExtensionDecrypts: () => extensionDecrypts, getFallbackEncrypts: () => fallbackEncrypts, }; } function extractChunkIdentifier(event) { const tags = Array.isArray(event?.tags) ? event.tags : []; for (const tag of tags) { if (Array.isArray(tag) && tag[0] === "d" && typeof tag[1] === "string") { return tag[1]; } } return ""; } function maxCreatedAt(events = []) { return events.reduce((max, event) => { if (!event || typeof event !== "object") { return max; } const created = Number.isFinite(event.created_at) ? event.created_at : 0; return created > max ? created : max; }, 0); } async function testPublishSnapshotCanonicalizationAndChunking() { poolHarness.reset(); poolHarness.setResolver(() => ({ ok: true })); const actor = "session-pubkey"; const restoreCrypto = installSessionCrypto({ privateKey: "session-priv" }); const originalEnsure = nostrClient.ensureSessionActor; const originalSession = nostrClient.sessionActor; const originalPub = nostrClient.pubkey; const originalLastCreated = nostrClient.watchHistoryLastCreatedAt; const originalDateNow = Date.now; try { nostrClient.pubkey = ""; nostrClient.sessionActor = { pubkey: actor, privateKey: "session-priv" }; nostrClient.ensureSessionActor = async () => actor; nostrClient.watchHistoryLastCreatedAt = 0; let nowValue = 1_700_000_000_000; Date.now = () => nowValue; const hugeValueA = `event-${"a".repeat(35000)}`; const hugeValueB = `event-${"b".repeat(35000)}`; const rawItems = [ { type: "e", value: hugeValueB, watchedAt: 210 }, { type: "e", value: hugeValueA, watchedAt: 200 }, { type: "e", value: "pointer-dup", watchedAt: 100 }, { type: "e", value: "pointer-dup", watchedAt: 150 }, { type: "a", value: "30023:pub:episode", relay: "wss://relay.one", watchedAt: 90 }, { tag: ["a", "30023:pub:episode", "wss://relay.two"] }, { type: "e", value: "pointer-small", watchedAt: 80 }, ]; const firstResult = await nostrClient.publishWatchHistorySnapshot(rawItems, { actorPubkey: actor, snapshotId: "session-snapshot", }); assert.ok(firstResult.ok, "snapshot should succeed with session crypto"); assert.equal( firstResult.items.length, 5, "canonicalization should dedupe pointer entries by key", ); const dupMatches = firstResult.items.filter( (item) => item.value === "pointer-dup", ); assert.equal( dupMatches.length, 1, "duplicate pointer should be collapsed into a single canonical entry", ); assert.equal( dupMatches[0].watchedAt, 150, "canonical pointer should retain the newest watchedAt timestamp", ); const anchorPointer = firstResult.items.find( (item) => item.value === "30023:pub:episode", ); assert.ok(anchorPointer, "address pointer should survive normalization"); assert.equal( anchorPointer.relay, "wss://relay.one", "existing relay metadata should persist when deduping", ); assert.equal( firstResult.chunkEvents.length, 2, "large payload should be chunked across two encrypted events", ); const decrypt = window.NostrTools?.nip04?.decrypt; assert.equal( typeof decrypt, "function", "session decrypt stub should be installed", ); for (const chunkEvent of firstResult.chunkEvents) { assert.notEqual( chunkEvent.content.trim()[0], "{", "chunk content must remain encrypted and avoid plaintext fallbacks", ); const plaintext = await decrypt( "session-priv", actor, chunkEvent.content, ); const payload = JSON.parse(plaintext); assert.equal(payload.snapshot, "session-snapshot"); assert(Array.isArray(payload.items), "chunk payload should include items"); const serializedLength = plaintext.length; assert( serializedLength <= WATCH_HISTORY_PAYLOAD_MAX_BYTES, `chunk payload should respect WATCH_HISTORY_PAYLOAD_MAX_BYTES (observed ${serializedLength})`, ); } const pointerAddresses = firstResult.pointerEvent.tags.filter( (tag) => Array.isArray(tag) && tag[0] === "a", ); assert.equal( pointerAddresses.length, 2, "pointer event should reference each published chunk", ); const firstCreatedMax = maxCreatedAt([ ...firstResult.chunkEvents, firstResult.pointerEvent, ]); const fingerprintOne = await nostrClient.getWatchHistoryFingerprint( actor, firstResult.items, ); assert.equal( typeof fingerprintOne, "string", "fingerprint generation should yield a deterministic digest", ); nowValue -= 120_000; const secondResult = await nostrClient.publishWatchHistorySnapshot( [{ type: "e", value: "second-pointer", watchedAt: 50 }], { actorPubkey: actor, snapshotId: "session-followup" }, ); assert.ok( secondResult.ok, "follow-up snapshot should succeed when time moves backwards", ); const secondCreatedMin = maxCreatedAt([ ...secondResult.chunkEvents, secondResult.pointerEvent, ]); assert( secondCreatedMin > firstCreatedMax, "created_at guard should enforce monotonic timestamps between snapshots", ); const fingerprintTwo = await nostrClient.getWatchHistoryFingerprint( actor, secondResult.items, ); assert.notEqual( fingerprintOne, fingerprintTwo, "fingerprint should change when canonical items differ", ); const chunkingPreview = chunkWatchHistoryPayloadItems( firstResult.items, "preview", 40_000, ); assert.equal( chunkingPreview.chunks.length, 2, "chunk helper should split payloads when the configured limit is smaller than the canonical snapshot", ); assert.equal( chunkingPreview.skipped.length, 0, "no canonical entries should be skipped when the limit exceeds individual pointer size", ); } finally { restoreCrypto.restore(); nostrClient.ensureSessionActor = originalEnsure; nostrClient.sessionActor = originalSession; nostrClient.pubkey = originalPub; nostrClient.watchHistoryLastCreatedAt = originalLastCreated; Date.now = originalDateNow; } } async function testPublishSnapshotUsesExtensionCrypto() { poolHarness.reset(); poolHarness.setResolver(() => ({ ok: true })); const actor = "ext-pubkey"; const sessionRestore = installSessionCrypto({ privateKey: "session-priv" }); const extension = installExtensionCrypto({ actor }); const originalEnsure = nostrClient.ensureSessionActor; const originalSession = nostrClient.sessionActor; const originalPub = nostrClient.pubkey; try { nostrClient.pubkey = actor; nostrClient.sessionActor = null; nostrClient.ensureSessionActor = async () => actor; const result = await nostrClient.publishWatchHistorySnapshot( [ { type: "e", value: "ext-pointer-1", watchedAt: 100 }, { type: "e", value: "ext-pointer-2", watchedAt: 50 }, ], { actorPubkey: actor, snapshotId: "extension" }, ); assert.ok(result.ok, "extension-driven snapshot should succeed"); assert.equal( extension.getFallbackEncrypts(), 0, "session fallback encryptor should remain unused when extension path is active", ); assert(extension.getExtensionEncrypts() > 0, "extension encrypt should be invoked"); const decrypt = window.nostr?.nip04?.decrypt; assert.equal( typeof decrypt, "function", "extension decrypt helper should be available", ); const decrypted = await decrypt(actor, result.chunkEvents[0].content); const payload = JSON.parse(decrypted); assert.equal(payload.snapshot, "extension"); assert.equal( payload.items.length, result.items.length, "decrypted payload should match canonical item count", ); } finally { extension.restore(); sessionRestore.restore(); nostrClient.ensureSessionActor = originalEnsure; nostrClient.sessionActor = originalSession; nostrClient.pubkey = originalPub; } } async function testPublishSnapshotFailureRetry() { poolHarness.reset(); nostrClient.watchHistoryLastCreatedAt = 0; const actor = "retry-actor"; const restoreCrypto = installSessionCrypto({ privateKey: "retry-priv" }); const originalEnsure = nostrClient.ensureSessionActor; const originalSession = nostrClient.sessionActor; const originalPub = nostrClient.pubkey; try { nostrClient.pubkey = ""; nostrClient.sessionActor = { pubkey: actor, privateKey: "retry-priv" }; nostrClient.ensureSessionActor = async () => actor; let failureCount = 0; poolHarness.setResolver(({ event }) => { const identifier = extractChunkIdentifier(event); if (identifier.endsWith(":0")) { failureCount += 1; return { ok: false, error: new Error("relay-rejection") }; } return { ok: true }; }); const failed = await nostrClient.publishWatchHistorySnapshot( [{ type: "e", value: "retry-pointer", watchedAt: 42 }], { actorPubkey: actor, snapshotId: "retry" }, ); assert.equal(failed.ok, false, "snapshot should surface relay rejections"); assert.equal(failed.retryable, true, "chunk rejection should be retryable"); assert.equal( failureCount, 1, "resolver should be invoked for the failed chunk", ); poolHarness.setResolver(() => ({ ok: true })); const succeeded = await nostrClient.publishWatchHistorySnapshot( [{ type: "e", value: "retry-pointer", watchedAt: 43 }], { actorPubkey: actor, snapshotId: "retry" }, ); assert.ok(succeeded.ok, "subsequent snapshot should succeed after failure"); assert( poolHarness.getPublishLog().length >= 3, "publish harness should record chunk and pointer attempts", ); } finally { restoreCrypto.restore(); nostrClient.ensureSessionActor = originalEnsure; nostrClient.sessionActor = originalSession; nostrClient.pubkey = originalPub; } } async function testWatchHistoryPartialRelayRetry() { poolHarness.reset(); watchHistoryService.resetProgress(); nostrClient.watchHistoryLastCreatedAt = 0; const actor = "partial-actor"; const restoreCrypto = installSessionCrypto({ privateKey: "partial-priv" }); const originalEnsure = nostrClient.ensureSessionActor; const originalSession = nostrClient.sessionActor; const originalPub = nostrClient.pubkey; const originalRelays = Array.isArray(nostrClient.relays) ? [...nostrClient.relays] : nostrClient.relays; const originalWriteRelays = Array.isArray(nostrClient.writeRelays) ? [...nostrClient.writeRelays] : nostrClient.writeRelays; const originalPublishSnapshot = nostrClient.publishWatchHistorySnapshot; const originalSchedule = nostrClient.scheduleWatchHistoryRepublish; const originalRecordView = nostrClient.recordVideoView; try { nostrClient.pubkey = actor; nostrClient.sessionActor = { pubkey: actor, privateKey: "partial-priv" }; nostrClient.ensureSessionActor = async () => actor; const relaySet = [ "wss://relay.one", "wss://relay.two", "wss://relay.three", ]; nostrClient.relays = [...relaySet]; nostrClient.writeRelays = [...relaySet]; let attemptIndex = 0; let currentAttempt = 0; nostrClient.publishWatchHistorySnapshot = async function publishWithTracking( ...args ) { attemptIndex += 1; currentAttempt = attemptIndex; return originalPublishSnapshot.apply(this, args); }; nostrClient.recordVideoView = async () => ({ ok: true, event: { id: `view-${Date.now()}`, pubkey: actor, created_at: Math.floor(Date.now() / 1000), }, }); await watchHistoryService.publishView( { type: "e", value: "partial-pointer" }, Math.floor(Date.now() / 1000), { actor }, ); const queuedBefore = watchHistoryService.getQueuedPointers(actor); assert.equal( queuedBefore.length, 1, "queue should contain the partial pointer before snapshot", ); assert.deepEqual( nostrClient.writeRelays, relaySet, "write relays should include all configured endpoints", ); const attemptPlans = [ { "wss://relay.one": true, "wss://relay.two": true, "wss://relay.three": false, }, { "wss://relay.one": true, "wss://relay.two": false, "wss://relay.three": true, }, { "wss://relay.one": true, "wss://relay.two": true, "wss://relay.three": true, }, ]; poolHarness.setResolver(({ relays }) => { const relayUrl = Array.isArray(relays) && relays.length ? relays[0] : ""; const index = Math.min( Math.max(currentAttempt, 1) - 1, attemptPlans.length - 1, ); const plan = attemptPlans[index] || {}; const accept = plan?.[relayUrl]; if (accept) { return { ok: true }; } return { ok: false, error: new Error(`reject-${relayUrl || "unknown"}`) }; }); const scheduledRuns = []; nostrClient.scheduleWatchHistoryRepublish = (snapshotId, operation) => { const promise = (async () => { let attempt = 1; let result = null; for (; attempt <= attemptPlans.length + 1; attempt += 1) { result = await operation(attempt); if (result?.ok || !result?.retryable) { break; } } return result; })(); scheduledRuns.push({ snapshotId, promise }); return { attempt: 1, delay: 0 }; }; let thrownError = null; try { await watchHistoryService.snapshot(null, { actor, reason: "partial-test" }); } catch (error) { thrownError = error; } assert(thrownError, "snapshot should throw when partial acceptance occurs"); assert.equal( thrownError?.result?.retryable, true, "partial failures should be marked retryable", ); assert.equal( thrownError?.result?.error, "partial-relay-acceptance", "partial failures should expose the partial acceptance error code", ); assert(thrownError?.result?.partial, "result should report partial acceptance"); const initialPointerStatus = thrownError?.result?.publishResults?.relayStatus?.pointer || []; assert( initialPointerStatus.some((entry) => entry && entry.success === false), "initial relay status should capture pointer rejections", ); assert( attemptIndex >= 1, "initial snapshot attempt should increment the attempt counter", ); assert.equal( scheduledRuns.length, 1, "partial failure should schedule a republish operation", ); const finalResult = await scheduledRuns[0].promise; assert(finalResult?.ok, "republish attempts should converge to success"); assert.equal( attemptIndex, 3, "republish loop should retry until every relay accepts", ); assert.equal( finalResult?.partial, false, "final result should not mark the publish as partial", ); const finalPointerStatus = finalResult?.publishResults?.relayStatus?.pointer || []; assert.equal( finalPointerStatus.filter((entry) => entry?.success).length, relaySet.length, "final pointer publish should succeed on all relays", ); for (const chunkStatus of finalResult?.publishResults?.relayStatus?.chunks || []) { assert.equal( chunkStatus.filter((entry) => entry?.success).length, relaySet.length, "final chunk publish should succeed on all relays", ); } const remainingQueue = watchHistoryService.getQueuedPointers(actor); assert.equal( remainingQueue.length, 0, "queue should remain empty after successful retries", ); } finally { nostrClient.scheduleWatchHistoryRepublish = originalSchedule; nostrClient.publishWatchHistorySnapshot = originalPublishSnapshot; nostrClient.relays = originalRelays; nostrClient.writeRelays = originalWriteRelays; nostrClient.recordVideoView = originalRecordView; nostrClient.ensureSessionActor = originalEnsure; nostrClient.sessionActor = originalSession; nostrClient.pubkey = originalPub; poolHarness.setResolver(() => ({ ok: true })); watchHistoryService.resetProgress(actor); restoreCrypto.restore(); } } async function testResolveWatchHistoryBatchingWindow() { const actor = "batch-window-actor"; const originalEnsure = nostrClient.ensureSessionActor; const originalPub = nostrClient.pubkey; const originalFetch = nostrClient.fetchWatchHistory; const originalCache = nostrClient.watchHistoryCache; const originalStorage = nostrClient.watchHistoryStorage; try { nostrClient.pubkey = ""; nostrClient.ensureSessionActor = async () => actor; nostrClient.watchHistoryCache = new Map(); nostrClient.watchHistoryStorage = { version: 2, actors: {} }; const syntheticItems = Array.from({ length: 24 }, (_, index) => ({ type: "e", value: `pointer-${index}`, watchedAt: 1_700_100_000 + index, })); nostrClient.fetchWatchHistory = async () => ({ items: syntheticItems.map((item) => ({ ...item })), snapshotId: "batch-snapshot", pointerEvent: null, }); const resolved = await nostrClient.resolveWatchHistory(actor, { forceRefresh: true, }); assert.equal( resolved.length, Math.min(WATCH_HISTORY_MAX_ITEMS, syntheticItems.length), "batched resolve should return the full window when no page size override is configured", ); assert(resolved.length > 1, "resolved history should include multiple entries"); assert.equal( resolved[0]?.value, "pointer-23", "resolved items should remain sorted by newest watchedAt value", ); const uniqueValues = new Set(resolved.map((item) => item?.value)); assert.equal( uniqueValues.size, resolved.length, "resolved history should retain all unique pointers", ); } finally { nostrClient.ensureSessionActor = originalEnsure; nostrClient.pubkey = originalPub; nostrClient.fetchWatchHistory = originalFetch; nostrClient.watchHistoryCache = originalCache; nostrClient.watchHistoryStorage = originalStorage; } } async function testWatchHistoryServiceIntegration() { poolHarness.reset(); poolHarness.setResolver(() => ({ ok: true })); const actor = "service-actor"; const restoreCrypto = installSessionCrypto({ privateKey: "service-priv" }); const originalEnsure = nostrClient.ensureSessionActor; const originalSession = nostrClient.sessionActor; const originalPub = nostrClient.pubkey; const originalWatchHistoryCacheTtl = nostrClient.watchHistoryCacheTtlMs; try { nostrClient.pubkey = "npub-logged"; nostrClient.sessionActor = { pubkey: actor, privateKey: "service-priv" }; nostrClient.ensureSessionActor = async () => actor; nostrClient.watchHistoryLastCreatedAt = 0; nostrClient.watchHistoryCache.clear(); nostrClient.watchHistoryFingerprints = new Map(); nostrClient.watchHistoryCacheTtlMs = 60_000; localStorage.clear(); watchHistoryService.resetProgress(); let viewCreatedAt = 1_700_000_010; nostrClient.recordVideoView = async (_pointer, options = {}) => ({ ok: true, event: { id: `view-${viewCreatedAt}`, pubkey: actor, created_at: options.created_at || viewCreatedAt, }, }); const beforeFingerprint = await watchHistoryService.getFingerprint(actor); assert.equal( typeof beforeFingerprint, "string", "fingerprint lookup should always return a string value", ); await watchHistoryService.publishView( { type: "e", value: "video-one" }, viewCreatedAt, { actor }, ); viewCreatedAt += 60; await watchHistoryService.publishView( { type: "e", value: "video-one" }, viewCreatedAt, { actor }, ); viewCreatedAt += 30; await watchHistoryService.publishView( { type: "a", value: "30023:pub:episode" }, viewCreatedAt, { actor }, ); const queued = watchHistoryService.getQueuedPointers(actor); assert.equal(queued.length, 2, "queue should dedupe repeated pointers"); const snapshotResult = await watchHistoryService.snapshot(null, { actor, reason: "integration", }); assert.ok(snapshotResult.ok, "snapshot should publish queued pointers"); assert.equal( watchHistoryService.getQueuedPointers(actor).length, 0, "queue should be cleared after successful snapshot", ); const resolvedItems = await watchHistoryService.loadLatest(actor); assert.deepEqual( resolvedItems, snapshotResult.items, "loadLatest should return decrypted canonical pointers", ); assert(resolvedItems[0].watchedAt >= resolvedItems[1].watchedAt); assert.equal( resolvedItems[0].session, true, "session flag should persist through publish and load", ); const afterFingerprint = await watchHistoryService.getFingerprint(actor); assert.notEqual( afterFingerprint, beforeFingerprint, "fingerprint should update after publishing a snapshot", ); const latestFingerprint = await nostrClient.getWatchHistoryFingerprint( actor, resolvedItems, ); assert.equal( afterFingerprint, latestFingerprint, "service fingerprint cache should align with canonical digest", ); } finally { restoreCrypto.restore(); nostrClient.ensureSessionActor = originalEnsure; nostrClient.sessionActor = originalSession; nostrClient.pubkey = originalPub; nostrClient.watchHistoryCacheTtlMs = originalWatchHistoryCacheTtl; nostrClient.watchHistoryCache.clear(); nostrClient.watchHistoryFingerprints = new Map(); } } async function testWatchHistoryLocalFallbackWhenDisabled() { console.log("Running watch history local fallback test..."); const actor = "local-fallback-actor"; const originalRecordView = nostrClient.recordVideoView; const originalPub = nostrClient.pubkey; const originalSession = nostrClient.sessionActor; try { setWatchHistoryV2Enabled(false); localStorage.clear(); watchHistoryService.resetProgress(); nostrClient.pubkey = ""; nostrClient.sessionActor = { pubkey: actor, privateKey: "local-priv" }; nostrClient.recordVideoView = async (_pointer, options = {}) => ({ ok: true, event: { id: "local-view", pubkey: actor, created_at: options.created_at || 1_700_500_000, }, }); const supported = typeof watchHistoryService.supportsLocalHistory === "function" ? watchHistoryService.supportsLocalHistory(actor) : true; const localOnly = typeof watchHistoryService.isLocalOnly === "function" ? watchHistoryService.isLocalOnly(actor) : true; const enabled = typeof watchHistoryService.isEnabled === "function" ? watchHistoryService.isEnabled(actor) : false; assert.equal( supported, true, "service should report local history support while sync disabled", ); assert.equal(localOnly, true, "guest session should be treated as local only"); assert.equal( enabled, false, "sync should be disabled for session actors when feature flag is off", ); const createdAt = 1_700_500_000; await watchHistoryService.publishView( { type: "e", value: "local-pointer" }, createdAt, { actor }, ); const queued = watchHistoryService.getQueuedPointers(actor); assert.equal( queued.length, 1, "local queue should retain pointer when sync is disabled", ); const latest = await watchHistoryService.loadLatest(actor); assert.equal( latest.length, 1, "loadLatest should surface session queue entries when sync disabled", ); assert.equal(latest[0]?.value, "local-pointer"); assert.equal(latest[0]?.type, "e"); assert( Number.isFinite(latest[0]?.watchedAt) && latest[0].watchedAt > 0, "local fallback entries should carry watchedAt timestamps", ); } finally { setWatchHistoryV2Enabled(true); watchHistoryService.resetProgress(); nostrClient.recordVideoView = originalRecordView; nostrClient.pubkey = originalPub; nostrClient.sessionActor = originalSession; } } async function testWatchHistorySyncEnabledForLoggedInUsers() { console.log("Running watch history logged-in sync override test..."); const actor = "npub-logged-sync"; const originalPub = nostrClient.pubkey; const originalSession = nostrClient.sessionActor; const originalResolve = nostrClient.resolveWatchHistory; const originalFingerprint = nostrClient.getWatchHistoryFingerprint; try { setWatchHistoryV2Enabled(false); localStorage.clear(); watchHistoryService.resetProgress(); nostrClient.pubkey = actor; nostrClient.sessionActor = null; let resolveCalls = 0; nostrClient.resolveWatchHistory = async (requestedActor, options = {}) => { resolveCalls += 1; assert.equal( requestedActor, actor, "resolveWatchHistory should target the logged-in actor", ); assert.equal( options?.forceRefresh, true, "loadLatest should force a relay refresh for logged-in actors", ); return [ { type: "e", value: "remote-pointer", watchedAt: 1_700_600_000, }, ]; }; let fingerprintCalls = 0; nostrClient.getWatchHistoryFingerprint = async (requestedActor, items) => { fingerprintCalls += 1; assert.equal( requestedActor, actor, "fingerprint lookup should use the logged-in actor", ); assert(Array.isArray(items), "fingerprint helper expects item array input"); return "fingerprint-logged"; }; const enabled = typeof watchHistoryService.isEnabled === "function" ? watchHistoryService.isEnabled(actor) : false; assert.equal( enabled, true, "sync should remain enabled for logged-in actors even when the flag is disabled", ); const items = await watchHistoryService.loadLatest(actor); assert.equal( resolveCalls > 0, true, "loadLatest should fetch from relays for logged-in actors", ); assert.equal(items.length, 1, "loadLatest should return relay data for logged-in actors"); assert.equal(items[0]?.value, "remote-pointer"); assert.equal( fingerprintCalls > 0, true, "fingerprint cache should update for logged-in actors", ); } finally { setWatchHistoryV2Enabled(true); watchHistoryService.resetProgress(); nostrClient.resolveWatchHistory = originalResolve; nostrClient.getWatchHistoryFingerprint = originalFingerprint; nostrClient.pubkey = originalPub; nostrClient.sessionActor = originalSession; } } async function testWatchHistoryAppLoginFallback() { console.log("Running watch history app login fallback test..."); const actor = "f".repeat(64); const originalPub = nostrClient.pubkey; const originalSession = nostrClient.sessionActor; const originalApp = getApplication(); try { setWatchHistoryV2Enabled(false); localStorage.clear(); watchHistoryService.resetProgress(); nostrClient.pubkey = ""; nostrClient.sessionActor = null; setApplication({ pubkey: actor, normalizeHexPubkey(value) { if (typeof value === "string" && value.trim()) { return value.trim().toLowerCase(); } return null; }, }); const enabled = typeof watchHistoryService.isEnabled === "function" ? watchHistoryService.isEnabled(actor) : false; assert.equal( enabled, true, "sync should be enabled when the app reports a logged-in pubkey", ); } finally { setWatchHistoryV2Enabled(true); watchHistoryService.resetProgress(); nostrClient.pubkey = originalPub; nostrClient.sessionActor = originalSession; setApplication(originalApp || null); } } await testPublishSnapshotCanonicalizationAndChunking(); await testPublishSnapshotUsesExtensionCrypto(); await testPublishSnapshotFailureRetry(); await testWatchHistoryPartialRelayRetry(); await testResolveWatchHistoryBatchingWindow(); await testWatchHistoryServiceIntegration(); await testWatchHistoryLocalFallbackWhenDisabled(); await testWatchHistorySyncEnabledForLoggedInUsers(); await testWatchHistoryAppLoginFallback(); console.log("watch-history.test.mjs completed successfully"); window.nostr = originalWindowNostr; window.NostrTools = originalNostrTools; nostrClient.pool = originalPool; nostrClient.relays = originalRelays; nostrClient.readRelays = originalReadRelays; nostrClient.writeRelays = originalWriteRelays; nostrClient.ensureSessionActor = originalEnsureSessionActor; nostrClient.sessionActor = originalSessionActor; nostrClient.pubkey = originalPubkey; nostrClient.watchHistoryLastCreatedAt = originalWatchHistoryLastCreatedAt; nostrClient.recordVideoView = originalRecordVideoView; nostrClient.watchHistoryCache = originalWatchHistoryCache; nostrClient.watchHistoryStorage = originalWatchHistoryStorage; nostrClient.scheduleWatchHistoryRepublish = originalScheduleWatchHistoryRepublish; nostrClient.resolveWatchHistory = originalResolveWatchHistory; nostrClient.getWatchHistoryFingerprint = originalGetWatchHistoryFingerprint; if (!originalFlag) { setWatchHistoryV2Enabled(false); }