import "./test-helpers/setup-localstorage.mjs"; import assert from "node:assert/strict"; const { WATCH_HISTORY_LIST_IDENTIFIER, WATCH_HISTORY_KIND, WATCH_HISTORY_FETCH_EVENT_LIMIT, } = await import("../js/config.js"); const { NOTE_TYPES, getNostrEventSchema, setNostrEventSchemaOverrides, buildWatchHistoryIndexEvent, buildWatchHistoryChunkEvent, } = await import("../js/nostrEventSchemas.js"); const { compareByWatchedAtDesc, getWatchedAtScore } = await import( "../js/watchHistorySort.js" ); if (typeof globalThis.window === "undefined") { globalThis.window = {}; } if (!globalThis.window.NostrTools) { globalThis.window.NostrTools = {}; } globalThis.window.NostrTools.getEventHash = (event) => { const payload = `${event.kind}:${event.pubkey}:${event.created_at}`; return `hash-${Buffer.from(payload).toString("hex")}`; }; globalThis.window.NostrTools.signEvent = (event, privateKey) => `sig-${privateKey.slice(0, 8)}-${event.created_at}`; delete globalThis.window.NostrTools.nip04; delete globalThis.window.nostr; const { nostrClient } = await import("../js/nostr.js"); function createPublishingClient(actorPubkey, options = {}) { const client = new nostrClient.constructor(); const publishedEvents = []; const payloads = []; const scheduledRepublishes = []; const cancelledRepublishes = []; const persistedEntries = []; const onPublish = typeof options.onPublish === "function" ? options.onPublish : null; client.pool = { publish(urls, event) { publishedEvents.push(event); const publishDecision = onPublish ? onPublish(event) : null; const shouldSucceed = publishDecision?.ok !== false; return { on(eventName, handler) { setTimeout(() => { if (shouldSucceed) { if (eventName === "ok" || eventName === "seen") { handler(); } } else if (eventName === "failed") { const providedError = publishDecision?.error; const error = providedError instanceof Error ? providedError : new Error( providedError ? String(providedError) : "mock publish failure" ); handler(error); } }, 0); }, }; }, list: async () => [], }; client.relays = ["wss://unit.test"]; client.pubkey = ""; client.sessionActor = { pubkey: actorPubkey, privateKey: "unit-secret" }; client.ensureSessionActor = async () => actorPubkey; client.encryptWatchHistoryPayload = async (_actor, payload) => { payloads.push(payload); return { ok: true, ciphertext: JSON.stringify(payload) }; }; client.persistWatchHistoryEntry = (_actor, entry) => { persistedEntries.push(entry); }; client.cancelWatchHistoryRepublish = (actor) => { cancelledRepublishes.push(actor); }; client.scheduleWatchHistoryRepublish = (actor, items) => { scheduledRepublishes.push({ actor, items }); }; return { client, publishedEvents, payloads, scheduledRepublishes, cancelledRepublishes, persistedEntries, }; } function createDecryptClient(actorPubkey) { const client = new nostrClient.constructor(); client.sessionActor = { pubkey: actorPubkey, privateKey: "unit-secret" }; client.ensureSessionActor = async () => actorPubkey; return client; } const ACTOR = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const LEGACY_WATCH_HISTORY_KIND = 30078; function deriveChunkPrefix(identifier) { if (typeof identifier !== "string") { return "watch-history:v2"; } const trimmed = identifier.trim().replace(/\/+$/, ""); if (!trimmed) { return "watch-history:v2"; } if (/[:/]index$/i.test(trimmed)) { const base = trimmed.replace(/[:/]index$/i, "").replace(/\/+$/, ""); return base || "watch-history:v2"; } if (/:v\d+$/i.test(trimmed)) { return trimmed; } return `${trimmed}:v2`; } const WATCH_HISTORY_CHUNK_PREFIX = deriveChunkPrefix( WATCH_HISTORY_LIST_IDENTIFIER ); const indexSchema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_INDEX); assert(indexSchema, "index schema should be available"); const chunkSchema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_CHUNK); assert(chunkSchema, "chunk schema should be available"); const sampleChunkIdentifier = `${WATCH_HISTORY_CHUNK_PREFIX}/sample/0`; const sampleChunkAddresses = [ `${WATCH_HISTORY_KIND}:${ACTOR}:${sampleChunkIdentifier}`, `${WATCH_HISTORY_KIND}:${ACTOR}:${WATCH_HISTORY_CHUNK_PREFIX}/sample/1`, ]; const sampleIndexEvent = buildWatchHistoryIndexEvent({ pubkey: ACTOR, created_at: 111, snapshotId: "sample", totalChunks: 2, chunkAddresses: sampleChunkAddresses, }); const sampleIndexIdentifierTag = sampleIndexEvent.tags.find( (tag) => Array.isArray(tag) && tag[0] === "d" ); assert(sampleIndexIdentifierTag, "index event should include identifier tag"); assert.equal( sampleIndexIdentifierTag[1], WATCH_HISTORY_LIST_IDENTIFIER, "index event should use canonical watch history identifier" ); const sampleChunksTag = sampleIndexEvent.tags.find( (tag) => Array.isArray(tag) && tag[0] === "chunks" ); assert(sampleChunksTag, "index event should include chunks tag"); assert.equal( Number.parseInt(sampleChunksTag[1], 10), 2, "chunks tag should reflect total chunks" ); const sampleIndexAddresses = sampleIndexEvent.tags .filter((tag) => Array.isArray(tag) && tag[0] === "a") .map((tag) => tag[1]); assert.deepEqual( sampleIndexAddresses, sampleChunkAddresses, "index event should list chunk addresses" ); const sampleChunkEvent = buildWatchHistoryChunkEvent({ pubkey: ACTOR, created_at: 222, chunkIdentifier: sampleChunkIdentifier, snapshotId: "sample", chunkIndex: 0, totalChunks: 2, pointerTags: [["e", "sample-event"]], content: "ciphertext", }); const chunkIdentifierTag = sampleChunkEvent.tags.find( (tag) => Array.isArray(tag) && tag[0] === "d" ); assert(chunkIdentifierTag, "chunk event should include identifier tag"); assert.equal( chunkIdentifierTag[1], sampleChunkIdentifier, "chunk event should use provided identifier" ); assert( sampleChunkEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "snapshot"), "chunk event should include snapshot tag" ); assert( sampleChunkEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "chunk"), "chunk event should include chunk tag" ); assert( sampleChunkEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "encrypted"), "chunk event should include encrypted tag" ); setNostrEventSchemaOverrides({ [NOTE_TYPES.WATCH_HISTORY_INDEX]: { identifierTag: { name: "d", value: "custom:index" }, }, [NOTE_TYPES.WATCH_HISTORY_CHUNK]: { identifierTag: { name: "d", value: "custom:chunk" }, }, }); const overriddenIndexEvent = buildWatchHistoryIndexEvent({ pubkey: ACTOR, created_at: 333, snapshotId: "override", totalChunks: 1, }); assert( overriddenIndexEvent.tags.some( (tag) => Array.isArray(tag) && tag[0] === "d" && tag[1] === "custom:index" ), "index builder should honor overrides" ); const overriddenChunkEvent = buildWatchHistoryChunkEvent({ pubkey: ACTOR, created_at: 444, snapshotId: "override", chunkIndex: 0, totalChunks: 1, pointerTags: [], content: "cipher", }); assert( overriddenChunkEvent.tags.some( (tag) => Array.isArray(tag) && tag[0] === "d" && tag[1] === "custom:chunk" ), "chunk builder should honor identifier override" ); setNostrEventSchemaOverrides({}); const { client: publishingClient, publishedEvents, payloads } = createPublishingClient(ACTOR); publishingClient.watchHistoryPayloadMaxBytes = 400; publishingClient.watchHistoryFetchEventLimit = WATCH_HISTORY_FETCH_EVENT_LIMIT; const baseWatchedAt = 1_700_000_000_000; const longPointers = Array.from({ length: 8 }, (_, index) => { const relay = index % 3 === 0 ? "wss://relay.unit" : null; const watchedAt = baseWatchedAt + index * 60_000; if (index % 2 === 0) { return { type: "e", value: `event-${index}-${"x".repeat(90)}`, relay, watchedAt, }; } return { type: "a", value: `${WATCH_HISTORY_KIND}:${ACTOR}:history-${index}-${"y".repeat(60)}`, relay, watchedAt, }; }); const publishResult = await publishingClient.publishWatchHistorySnapshot( ACTOR, longPointers ); assert.equal(publishResult.ok, true, "publish should report success"); assert(publishResult.events.length > 1, "snapshot should include index and chunk events"); assert.equal( publishResult.events.length, payloads.length + 1, "index event should accompany each chunk payload" ); payloads.forEach((payload) => { const size = JSON.stringify(payload).length; assert( size <= publishingClient.watchHistoryPayloadMaxBytes, `payload size ${size} should respect cap` ); assert.equal(payload.version, 2, "chunk payload should use version 2"); }); const snapshotIds = new Set( publishResult.events.map((event) => event.tags.find((tag) => Array.isArray(tag) && tag[0] === "snapshot")?.[1] ) ); assert.equal(snapshotIds.size, 1, "all events should share a snapshot id"); const indexEvent = publishResult.indexEvent; assert(indexEvent, "publish result should include index event"); const indexIdentifierTag = indexEvent.tags.find( (tag) => Array.isArray(tag) && tag[0] === "d" ); assert(indexIdentifierTag, "index event should include identifier tag"); assert.equal( indexIdentifierTag[1], WATCH_HISTORY_LIST_IDENTIFIER, "index event should use canonical identifier" ); const indexChunksTag = indexEvent.tags.find( (tag) => Array.isArray(tag) && tag[0] === "chunks" ); assert(indexChunksTag, "index event should include chunks tag"); assert.equal( Number.parseInt(indexChunksTag[1], 10), publishResult.chunks.length, "index event should report chunk count" ); const chunkEvents = publishResult.chunks.map((chunk) => chunk.event); const chunkIdentifiers = chunkEvents.map((event) => event.tags.find((tag) => Array.isArray(tag) && tag[0] === "d")?.[1] ); assert.equal( chunkIdentifiers.length, chunkEvents.length, "each chunk should expose a d tag" ); const uniqueChunkIdentifiers = new Set(chunkIdentifiers); assert.equal( uniqueChunkIdentifiers.size, chunkEvents.length, "each chunk should have a unique d tag" ); chunkIdentifiers.forEach((identifier, index) => { assert(identifier, `chunk ${index} should include identifier`); assert( identifier.startsWith(WATCH_HISTORY_CHUNK_PREFIX), `chunk ${index} should use the chunk prefix` ); assert.notEqual( identifier, WATCH_HISTORY_LIST_IDENTIFIER, "chunk identifiers should differ from index identifier" ); }); const indexAddresses = indexEvent.tags .filter((tag) => Array.isArray(tag) && tag[0] === "a") .map((tag) => tag[1]); const expectedChunkAddresses = chunkEvents.map((event) => { const identifier = event.tags.find( (tag) => Array.isArray(tag) && tag[0] === "d" )?.[1]; assert(identifier, "chunk should include identifier tag"); return `${WATCH_HISTORY_KIND}:${ACTOR}:${identifier}`; }); expectedChunkAddresses.forEach((address) => { assert( indexAddresses.includes(address), `index event should reference ${address}` ); }); const CHUNK_FAILURE_ACTOR = `${ACTOR}-chunk-failure`; const { client: chunkFailureClient, publishedEvents: chunkFailureEvents, payloads: chunkFailurePayloads, scheduledRepublishes: chunkFailureRepublishes, cancelledRepublishes: chunkFailureCancels, } = createPublishingClient(CHUNK_FAILURE_ACTOR, { onPublish(event) { const chunkTag = event.tags.find( (tag) => Array.isArray(tag) && tag[0] === "chunk" ); if (chunkTag) { const chunkIndex = Number.parseInt(chunkTag[1], 10); const total = Number.parseInt(chunkTag[2], 10); if ( Number.isInteger(chunkIndex) && Number.isInteger(total) && (total > 1 ? chunkIndex === total - 1 : chunkIndex === 0) ) { return { ok: false, error: new Error("chunk publish failed") }; } } return { ok: true }; }, }); chunkFailureClient.watchHistoryPayloadMaxBytes = publishingClient.watchHistoryPayloadMaxBytes; chunkFailureClient.watchHistoryFetchEventLimit = WATCH_HISTORY_FETCH_EVENT_LIMIT; const chunkFailureResult = await chunkFailureClient.publishWatchHistorySnapshot( CHUNK_FAILURE_ACTOR, longPointers ); assert.equal( chunkFailureResult.ok, false, "chunk failure should report overall failure" ); assert.equal( chunkFailureResult.indexEvent, null, "chunk failure should omit index event" ); assert.equal( chunkFailureResult.events.length, chunkFailureResult.chunks.length, "chunk failure should not include index event in combined events" ); assert.equal( chunkFailureResult.indexResults.length, 0, "chunk failure should skip index publish attempts" ); assert( chunkFailureResult.chunks.some((chunk) => chunk.success === false), "chunk failure results should flag the failed chunk" ); assert.equal( chunkFailureRepublishes.length, 1, "chunk failure should schedule a retry" ); assert.equal( chunkFailureCancels.length, 0, "chunk failure should not cancel retries" ); assert.equal( chunkFailureRepublishes[0].actor, CHUNK_FAILURE_ACTOR, "chunk failure retry should target the affected actor" ); assert( chunkFailureEvents.every((event) => event.tags.some((tag) => Array.isArray(tag) && tag[0] === "chunk") ), "chunk failure should avoid publishing index events" ); assert.equal( chunkFailurePayloads.length, chunkFailureResult.chunks.length, "chunk failure should still prepare payloads for each chunk" ); const chunkFailureCache = chunkFailureClient.watchHistoryCache.get( CHUNK_FAILURE_ACTOR ); assert(chunkFailureCache, "chunk failure should cache a watch history entry"); assert.equal( chunkFailureCache.chunkPointers.index, null, "chunk failure cache should not retain an index pointer" ); assert( Array.from(chunkFailureCache.chunkEvents.values()).every((event) => event.tags.some((tag) => Array.isArray(tag) && tag[0] === "chunk") ), "chunk failure cache should only persist chunk events" ); assert.deepEqual( chunkFailureRepublishes[0].items.map((item) => ({ type: item.type, value: item.value, relay: item.relay || null, watchedAt: item.watchedAt, })), chunkFailureResult.items.map((item) => ({ type: item.type, value: item.value, relay: item.relay || null, watchedAt: item.watchedAt, })), "republish scheduler should receive the remaining items" ); const LOGGED_ACTOR = `${ACTOR}-logged`; const { client: noSignerClient, publishedEvents: noSignerPublishedEvents, scheduledRepublishes: noSignerRepublishes, persistedEntries: noSignerPersistedEntries, } = createPublishingClient(LOGGED_ACTOR); noSignerClient.pubkey = LOGGED_ACTOR; const noSignerItems = [ { type: "e", value: `${LOGGED_ACTOR}-event`, watchedAt: baseWatchedAt + 123, }, { type: "a", value: `${WATCH_HISTORY_KIND}:${LOGGED_ACTOR}:history`, watchedAt: baseWatchedAt + 456, }, ]; const noSignerResult = await noSignerClient.publishWatchHistorySnapshot( LOGGED_ACTOR, noSignerItems ); assert.equal( noSignerResult.ok, false, "logged actor without signer should report failure" ); assert.equal( noSignerResult.error, "signing-unavailable", "missing signer failure should flag availability" ); assert.equal( noSignerPublishedEvents.length, 0, "no events should publish when signer unavailable" ); assert.equal( noSignerRepublishes.length, 1, "missing signer should schedule a retry" ); assert.equal( noSignerRepublishes[0].actor, LOGGED_ACTOR, "retry should target the logged actor" ); const cachedLoggedEntry = noSignerClient.watchHistoryCache.get(LOGGED_ACTOR); assert(cachedLoggedEntry, "missing signer should keep snapshot cached"); assert.equal( cachedLoggedEntry.items.length, noSignerItems.length, "cached entry should retain merged items" ); assert.equal( noSignerPersistedEntries.length, 1, "missing signer should persist cached payload" ); assert.deepEqual( cachedLoggedEntry.items.map((item) => ({ type: item.type, value: item.value, })), noSignerResult.items.map((item) => ({ type: item.type, value: item.value, })), "returned items should mirror cached payload" ); const scheduledLoggedKeys = new Set( noSignerRepublishes[0].items.map((item) => `${item.type}:${item.value}`) ); const cachedLoggedKeys = new Set( cachedLoggedEntry.items.map((item) => `${item.type}:${item.value}`) ); assert.deepEqual( scheduledLoggedKeys, cachedLoggedKeys, "scheduled retry should include cached items" ); const NO_SEEN_ACTOR = `${ACTOR}-no-seen`; const { client: noSeenClient, publishedEvents: noSeenPublishedEvents, } = createPublishingClient(NO_SEEN_ACTOR); noSeenClient.pool.publish = (_urls, event) => { noSeenPublishedEvents.push(event); return { on(eventName, handler) { if (eventName === "seen") { throw new TypeError("seen unsupported"); } if (eventName === "ok") { setTimeout(handler, 0); } else if (eventName === "failed") { // Never invoke failed handlers in this mock; they should remain idle. } }, }; }; const noSeenResult = await noSeenClient.publishWatchHistorySnapshot( NO_SEEN_ACTOR, [{ type: "e", value: "no-seen-pointer", relay: null }] ); assert.equal( noSeenResult.ok, true, "publish should succeed when relay handle rejects seen handlers" ); assert( noSeenResult.events.length > 0, "publish should still return the emitted events when seen is unsupported" ); delete globalThis.window.nostr; if (!globalThis.window.NostrTools.nip04) { globalThis.window.NostrTools.nip04 = {}; } globalThis.window.NostrTools.nip04.decrypt = async ( _priv, _pub, ciphertext ) => ciphertext; const decryptClient = createDecryptClient(ACTOR); decryptClient.relays = ["wss://unit.test"]; decryptClient.watchHistoryFetchEventLimit = WATCH_HISTORY_FETCH_EVENT_LIMIT; decryptClient.pool = { list: async () => publishedEvents, }; const fetched = await decryptClient.fetchWatchHistory(ACTOR); assert.equal( fetched.items.length, longPointers.length, "fetch should reassemble all chunked items" ); assert.deepEqual( fetched.items.map((item) => ({ type: item.type, value: item.value, relay: item.relay || null, watchedAt: item.watchedAt, })), longPointers.map((item) => ({ type: item.type, value: item.value, relay: item.relay || null, watchedAt: item.watchedAt, })), "chunked fetch should preserve pointer order and timestamps" ); if (typeof localStorage !== "undefined") { localStorage.clear(); } const headEvent = publishResult.event; const legacyPointerIdentifiers = new Set(); const legacyIndexEvent = JSON.parse(JSON.stringify(headEvent)); legacyIndexEvent.kind = LEGACY_WATCH_HISTORY_KIND; legacyIndexEvent.tags = legacyIndexEvent.tags.map((tag) => { if ( Array.isArray(tag) && tag[0] === "a" && typeof tag[1] === "string" && !legacyPointerIdentifiers.size ) { const [kindStr, pubkey, ...identifierParts] = tag[1].split(":"); if (kindStr === String(WATCH_HISTORY_KIND) && identifierParts.length) { const identifier = identifierParts.join(":"); legacyPointerIdentifiers.add(identifier); const legacyAddress = `${LEGACY_WATCH_HISTORY_KIND}:${pubkey}:${identifier}`; const clone = tag.slice(); clone[1] = legacyAddress; return clone; } } return tag; }); const legacyChunkIdentifier = legacyPointerIdentifiers.size ? Array.from(legacyPointerIdentifiers)[0] : null; const snapshotId = headEvent.tags.find( (tag) => Array.isArray(tag) && tag[0] === "snapshot" )?.[1]; const freshClient = createDecryptClient(ACTOR); freshClient.relays = ["wss://unit.test"]; freshClient.watchHistoryFetchEventLimit = WATCH_HISTORY_FETCH_EVENT_LIMIT; const observedFilters = []; freshClient.pool = { list: async (_relays, filters) => { observedFilters.push(filters); const firstFilter = filters[0] || {}; const dFilter = Array.isArray(firstFilter["#d"]) ? firstFilter["#d"] : []; if (filters.length === 1 && dFilter.includes(WATCH_HISTORY_LIST_IDENTIFIER)) { return [legacyIndexEvent]; } const requestedIdentifiers = new Set(); const requestedSnapshots = new Set(); for (const filter of filters) { if (Array.isArray(filter?.["#d"])) { for (const identifier of filter["#d"]) { requestedIdentifiers.add(identifier); } } if (Array.isArray(filter?.["#snapshot"])) { for (const value of filter["#snapshot"]) { requestedSnapshots.add(value); } } } const responses = []; for (const event of publishResult.events) { if (event.id === headEvent.id) { continue; } const cloned = JSON.parse(JSON.stringify(event)); if (legacyChunkIdentifier) { const identifierTag = cloned.tags.find( (tag) => Array.isArray(tag) && tag[0] === "d" ); if (identifierTag?.[1] === legacyChunkIdentifier) { cloned.kind = LEGACY_WATCH_HISTORY_KIND; } } const identifier = event.tags.find( (tag) => Array.isArray(tag) && tag[0] === "d" )?.[1]; const eventSnapshot = event.tags.find( (tag) => Array.isArray(tag) && tag[0] === "snapshot" )?.[1]; if (identifier && requestedIdentifiers.has(identifier)) { responses.push(cloned); continue; } if (eventSnapshot && requestedSnapshots.has(eventSnapshot)) { responses.push(cloned); } } return responses; }, }; const rebuilt = await freshClient.fetchWatchHistory(ACTOR); assert.equal( rebuilt.items.length, longPointers.length, "fresh client should rebuild full watch history list" ); assert.deepEqual( rebuilt.items.map((item) => ({ type: item.type, value: item.value, relay: item.relay || null, watchedAt: item.watchedAt, })), longPointers.map((item) => ({ type: item.type, value: item.value, relay: item.relay || null, watchedAt: item.watchedAt, })), "rebuilt snapshot should preserve timestamps" ); assert( observedFilters.some((filters) => filters.some((filter) => { const kinds = Array.isArray(filter?.kinds) ? filter.kinds : []; return ( kinds.includes(WATCH_HISTORY_KIND) && kinds.includes(LEGACY_WATCH_HISTORY_KIND) ); }) ), "fetchWatchHistory should request both new and legacy kinds during migration" ); assert( observedFilters.length >= 2, "fetch should include a follow-up query for chunk events" ); const followUpFilters = observedFilters[observedFilters.length - 1]; const identifiersRequested = new Set(); let snapshotRequested = false; for (const filter of followUpFilters) { if (Array.isArray(filter?.["#d"])) { for (const identifier of filter["#d"]) { identifiersRequested.add(identifier); } } if (Array.isArray(filter?.["#snapshot"])) { snapshotRequested = snapshotRequested || filter["#snapshot"].includes(snapshotId); } } assert( chunkEvents.every((event) => { const identifier = event.tags.find( (tag) => Array.isArray(tag) && tag[0] === "d" )?.[1]; return ( identifiersRequested.has(identifier) || (snapshotId && snapshotRequested) ); }), "follow-up query should target chunk identifiers or snapshot" ); const OVERFLOW_ACTOR = `${ACTOR}-overflow`; const overflowSnapshotId = "overflow-snapshot"; const overflowTotalChunks = 18; const overflowBaseCreatedAt = 1_700_555_000; const overflowPointers = Array.from({ length: overflowTotalChunks }, (_, index) => { const relay = index % 3 === 0 ? "wss://overflow.unit" : null; return { type: index % 2 === 0 ? "e" : "a", value: index % 2 === 0 ? `overflow-event-${index}` : `${WATCH_HISTORY_KIND}:${OVERFLOW_ACTOR}:overflow-${index}`, relay, watchedAt: baseWatchedAt + index * 45_000, }; }); const overflowChunkEvents = overflowPointers.map((pointer, index) => { const chunkIdentifier = `${WATCH_HISTORY_CHUNK_PREFIX}/${overflowSnapshotId}/${index}`; const pointerTag = [pointer.type, pointer.value]; if (pointer.relay) { pointerTag.push(pointer.relay); } const payload = { version: 2, snapshot: overflowSnapshotId, chunkIndex: index, totalChunks: overflowTotalChunks, items: [pointer], }; const event = buildWatchHistoryChunkEvent({ pubkey: OVERFLOW_ACTOR, created_at: overflowBaseCreatedAt + (overflowTotalChunks - index), chunkIdentifier, snapshotId: overflowSnapshotId, chunkIndex: index, totalChunks: overflowTotalChunks, pointerTags: [pointerTag], chunkAddresses: [], content: JSON.stringify(payload), }); event.id = window.NostrTools.getEventHash(event); event.sig = `sig-overflow-chunk-${index}`; return event; }); const overflowIndexEvent = buildWatchHistoryIndexEvent({ pubkey: OVERFLOW_ACTOR, created_at: overflowBaseCreatedAt + overflowTotalChunks + 5, snapshotId: overflowSnapshotId, totalChunks: overflowTotalChunks, chunkAddresses: [], }); overflowIndexEvent.id = window.NostrTools.getEventHash(overflowIndexEvent); overflowIndexEvent.sig = "sig-overflow-index"; const overflowClient = createDecryptClient(OVERFLOW_ACTOR); overflowClient.relays = ["wss://overflow.test"]; // Simulate deployments that kept the fetch limit near the historical default. overflowClient.watchHistoryFetchEventLimit = 12; const overflowChunkLimits = []; overflowClient.pool = { list: async (_relays, filters) => { const firstFilter = filters[0] || {}; const dFilter = Array.isArray(firstFilter["#d"]) ? firstFilter["#d"] : []; if (filters.length === 1 && dFilter.includes(WATCH_HISTORY_LIST_IDENTIFIER)) { return [overflowIndexEvent]; } filters.forEach((filter) => { if (Number.isFinite(filter?.limit)) { overflowChunkLimits.push(filter.limit); } }); const requestedSnapshots = new Set(); filters.forEach((filter) => { if (Array.isArray(filter?.["#snapshot"])) { filter["#snapshot"].forEach((value) => { if (typeof value === "string" && value) { requestedSnapshots.add(value); } }); } }); if (!requestedSnapshots.size) { return []; } return overflowChunkEvents.filter((event) => { const snapshotTag = event.tags.find( (tag) => Array.isArray(tag) && tag[0] === "snapshot" ); return snapshotTag && requestedSnapshots.has(snapshotTag[1]); }); }, }; const overflowFetched = await overflowClient.fetchWatchHistory(OVERFLOW_ACTOR); assert.equal( overflowFetched.items.length, overflowTotalChunks, "fetch should hydrate snapshots larger than the legacy limit" ); const maxOverflowLimit = overflowChunkLimits.length ? Math.max(...overflowChunkLimits) : 0; assert( maxOverflowLimit >= overflowTotalChunks + 1, "chunk fetch should cover all chunks plus the newest index event" ); const MONOTONIC_ACTOR = `${ACTOR}-monotonic`; const { client: monotonicPublishingClient, publishedEvents: monotonicEvents } = createPublishingClient(MONOTONIC_ACTOR); const monotonicOriginalDateNow = Date.now; try { const monotonicBaseSeconds = 1_700_123_456; Date.now = () => monotonicBaseSeconds * 1000; const firstMonotonicPointers = [ { type: "e", value: "event-monotonic-1", relay: null }, { type: "e", value: "event-monotonic-2", relay: null }, ]; const firstSnapshot = await monotonicPublishingClient.publishWatchHistorySnapshot( MONOTONIC_ACTOR, firstMonotonicPointers ); const baselineEntry = monotonicPublishingClient.createWatchHistoryEntry( firstSnapshot.event, firstMonotonicPointers, Date.now() ); monotonicPublishingClient.watchHistoryCache.set( MONOTONIC_ACTOR, baselineEntry ); const secondMonotonicPointers = [ { type: "e", value: "event-monotonic-3", relay: null }, { type: "e", value: "event-monotonic-1", relay: null }, ]; const secondSnapshot = await monotonicPublishingClient.publishWatchHistorySnapshot( MONOTONIC_ACTOR, secondMonotonicPointers, baselineEntry ); assert( secondSnapshot.event.created_at > firstSnapshot.event.created_at, "new snapshot should advance created_at when reusing the same second" ); const monotonicDecryptClient = createDecryptClient(MONOTONIC_ACTOR); monotonicDecryptClient.pool = { list: async () => monotonicEvents }; const monotonicFetched = await monotonicDecryptClient.fetchWatchHistory( MONOTONIC_ACTOR ); assert.deepEqual( monotonicFetched.items.map((item) => ({ type: item.type, value: item.value, relay: item.relay || null, })), [ { type: "e", value: "event-monotonic-3", relay: null }, { type: "e", value: "event-monotonic-1", relay: null }, ], "newer snapshot should win pointer ordering" ); } finally { Date.now = monotonicOriginalDateNow; } localStorage.clear(); const UPDATE_ACTOR = `${ACTOR}-update`; const { client: updateClient } = createPublishingClient(UPDATE_ACTOR); const updateOriginalDateNow = Date.now; try { const updateTimestamp = 1_701_111_111_111; Date.now = () => updateTimestamp; const updateResult = await updateClient.updateWatchHistoryList({ type: "e", value: "update-pointer", relay: null, }); assert.equal( updateResult.ok, true, "updateWatchHistoryList should publish when adding a new pointer" ); const updateEntry = updateClient.watchHistoryCache.get(UPDATE_ACTOR); assert(updateEntry, "updateWatchHistoryList should cache the new entry"); assert.equal( updateEntry.items[0].watchedAt, updateTimestamp, "cached entry should record watchedAt from Date.now()" ); assert.equal( updateResult.items[0].watchedAt, updateTimestamp, "returned publish result should include the watchedAt timestamp" ); } finally { Date.now = updateOriginalDateNow; } const REMOVE_ACTOR = `${ACTOR}-remove`; const { client: removalClient, payloads: removalPayloads, } = createPublishingClient(REMOVE_ACTOR); const removalPointers = [ { type: "e", value: "remove-first", relay: null, watchedAt: 1_701_222_000_000, }, { type: "e", value: "remove-second", relay: null, watchedAt: 1_701_222_100_000, }, ]; await removalClient.publishWatchHistorySnapshot( REMOVE_ACTOR, removalPointers ); const removalKey = `e:${removalPointers[1].value.trim().toLowerCase()}`; const removalResult = await removalClient.removeWatchHistoryItem(removalKey); assert.equal(removalResult.ok, true, "removeWatchHistoryItem should succeed"); assert.equal( removalResult.removedKey, removalKey, "removeWatchHistoryItem should echo the removed pointer key" ); const latestRemovalPayload = removalPayloads[removalPayloads.length - 1]; assert.equal( latestRemovalPayload.items.length, 1, "removal republish should trim the pointer list" ); assert.equal( latestRemovalPayload.items[0].value, removalPointers[0].value, "republished payload should only include remaining pointer" ); assert.equal( latestRemovalPayload.items[0].watchedAt, removalPointers[0].watchedAt, "republished payload should preserve watchedAt" ); const removalEntry = removalClient.watchHistoryCache.get(REMOVE_ACTOR); assert.equal( removalEntry.items.length, 1, "watch history cache should drop the removed pointer" ); assert.equal( removalEntry.items[0].value, removalPointers[0].value, "remaining cached pointer should match the survivor" ); assert.equal( removalEntry.items[0].watchedAt, removalPointers[0].watchedAt, "remaining cached pointer should retain watchedAt" ); assert.equal( removalResult.items.map((item) => item.value).join(","), removalPointers.slice(0, 1).map((item) => item.value).join(","), "removeWatchHistoryItem result should reflect updated items" ); assert.equal( removalResult.items[0].watchedAt, removalPointers[0].watchedAt, "removeWatchHistoryItem result should preserve watchedAt" ); const VIDEO_POINTER_AUTHOR = `${ACTOR}-video-author`; const VIDEO_POINTER_IDENTIFIER = "pointer-video-identifier"; const videoPointer = { type: "a", value: `${WATCH_HISTORY_KIND}:${VIDEO_POINTER_AUTHOR}:${VIDEO_POINTER_IDENTIFIER}`, relay: null, }; const pointerClient = createDecryptClient(ACTOR); const pointerEntry = pointerClient.createWatchHistoryEntry( null, [videoPointer], Date.now() ); pointerClient.watchHistoryCache.set(ACTOR, pointerEntry); pointerClient.fetchWatchHistory = async () => {}; const observedResolveFilters = []; const videoEvent = { id: "video-pointer-event", kind: WATCH_HISTORY_KIND, pubkey: VIDEO_POINTER_AUTHOR, created_at: 1_700_555_000, tags: [["d", VIDEO_POINTER_IDENTIFIER]], content: JSON.stringify({ version: 3, title: "Pointer Video", url: "https://cdn.example.com/video.mp4", }), }; pointerClient.pool = { list: async (_relays, filters) => { observedResolveFilters.push(filters); return [videoEvent]; }, }; const resolvedFromPointer = await pointerClient.resolveWatchHistory(1); const pointerKey = `a:${videoPointer.value.trim().toLowerCase()}`; assert.equal( resolvedFromPointer.length, 1, "resolveWatchHistory should return results for 'a' style video pointers" ); assert.equal( resolvedFromPointer[0].id, videoEvent.id, "resolved video should match the pointer event id" ); assert.equal( resolvedFromPointer[0].title, "Pointer Video", "resolved video should parse title from the event payload" ); assert(resolvedFromPointer[0].watchHistory, "resolved video should include watchHistory metadata"); assert.equal( resolvedFromPointer[0].watchHistory.key, pointerKey, "watchHistory metadata should expose the pointer key" ); assert.equal( resolvedFromPointer[0].watchHistory.pointer.value, videoPointer.value, "watchHistory metadata should include the pointer payload" ); assert.equal( resolvedFromPointer[0].watchHistory.pointer.watchedAt, pointerEntry.items[0].watchedAt, "watchHistory metadata should preserve watchedAt" ); const cachedEntry = pointerClient.watchHistoryCache.get(ACTOR); assert( cachedEntry.resolvedVideos.has(pointerKey), "resolved cache should retain the video" ); assert.equal( cachedEntry.resolvedVideos.get(pointerKey)?.id, videoEvent.id, "resolved cache should store the converted video result" ); assert( cachedEntry.delivered.has(pointerKey), "delivery set should mark the pointer as delivered" ); assert( observedResolveFilters.some((filters) => filters.some((filter) => Array.isArray(filter?.["#d"]) && filter["#d"].includes(VIDEO_POINTER_IDENTIFIER) ) ), "resolveWatchHistory should request the video identifier instead of treating it as a chunk" ); const secondResolve = await pointerClient.resolveWatchHistory(1); assert.equal( secondResolve.length, 0, "subsequent resolve calls should avoid refetching already delivered videos" ); pointerClient.resetWatchHistoryProgress(ACTOR); assert.equal( cachedEntry.delivered.size, 0, "resetWatchHistoryProgress should clear delivered pointers" ); pointerClient.pool = { list: async () => { throw new Error("cached resolve should not trigger network fetch"); }, }; const cachedResolve = await pointerClient.resolveWatchHistory(1); assert.equal( cachedResolve.length, 1, "reset should allow cached videos to be delivered again" ); assert.equal( cachedResolve[0].id, videoEvent.id, "cached resolve should reuse the stored video payload" ); assert( cachedResolve[0].watchHistory?.watchedAt === pointerEntry.items[0].watchedAt, "cached resolve should retain watchHistory metadata" ); const originalDateNow = Date.now; try { const baseTime = 1_700_000_000_000; const ttlActor = `${ACTOR}-ttl`; const pointer = { type: "e", value: "event-ttl", relay: null }; const cachingClient = createDecryptClient(ttlActor); cachingClient.watchHistoryCacheTtlMs = 24 * 60 * 60 * 1000; Date.now = () => baseTime; const entry = cachingClient.createWatchHistoryEntry(null, [pointer], baseTime); cachingClient.watchHistoryCache.set(ttlActor, entry); cachingClient.persistWatchHistoryEntry(ttlActor, entry); const reloadClient = createDecryptClient(ttlActor); reloadClient.watchHistoryCacheTtlMs = cachingClient.watchHistoryCacheTtlMs; Date.now = () => baseTime + 60 * 60 * 1000; const cachedSnapshot = await reloadClient.fetchWatchHistory(ttlActor); assert.equal( cachedSnapshot.items.length, 1, "cached snapshot should survive within extended TTL" ); assert.equal( cachedSnapshot.items[0].value, pointer.value, "cached snapshot should preserve pointer" ); localStorage.clear(); const expiryClient = createDecryptClient(ttlActor); expiryClient.watchHistoryCacheTtlMs = 1_000; Date.now = () => baseTime; const expiringEntry = expiryClient.createWatchHistoryEntry( null, [pointer], baseTime ); expiryClient.watchHistoryCache.set(ttlActor, expiringEntry); expiryClient.persistWatchHistoryEntry(ttlActor, expiringEntry); Date.now = () => baseTime + 1_500; const expiredSnapshot = await expiryClient.fetchWatchHistory(ttlActor); assert.equal( expiredSnapshot.items.length, 0, "expired snapshot should be dropped" ); const cachedFallback = expiryClient.watchHistoryCache.get(ttlActor); assert.equal( cachedFallback?.items?.length ?? 0, 0, "expired snapshot should not preserve stale items in memory" ); const storage = expiryClient.getWatchHistoryStorage(); assert.equal( Object.prototype.hasOwnProperty.call(storage.actors, ttlActor), false, "expired snapshot should be removed from localStorage" ); } finally { Date.now = originalDateNow; } const plaintextPayload = { version: 2, snapshot: "plaintext-snapshot", chunkIndex: 0, totalChunks: 1, items: [ { type: "e", value: "plaintext-pointer", relay: "wss://relay.unit", watchedAt: 1_700_000_123_000, }, ], }; const plaintextChunkEvent = { kind: WATCH_HISTORY_KIND, pubkey: ACTOR, created_at: 333, tags: [ ["d", `${WATCH_HISTORY_CHUNK_PREFIX}/plaintext/0`], ["snapshot", plaintextPayload.snapshot], ["chunk", String(plaintextPayload.chunkIndex), String(plaintextPayload.totalChunks)], ["e", plaintextPayload.items[0].value, plaintextPayload.items[0].relay], ], content: JSON.stringify(plaintextPayload), }; const originalWindowNostr = globalThis.window.nostr; if (!globalThis.window.NostrTools) { globalThis.window.NostrTools = {}; } if (!globalThis.window.NostrTools.nip04) { globalThis.window.NostrTools.nip04 = {}; } const originalSessionDecrypt = globalThis.window.NostrTools.nip04.decrypt; try { globalThis.window.nostr = { nip04: { decrypt: () => { throw new Error("plaintext chunk should not trigger extension decrypt"); }, }, }; globalThis.window.NostrTools.nip04.decrypt = () => { throw new Error("plaintext chunk should not trigger session decrypt"); }; const plaintextClient = createDecryptClient(ACTOR); const plaintextResult = await plaintextClient.decryptWatchHistoryEvent( plaintextChunkEvent, ACTOR ); assert.deepEqual( plaintextResult.items.map((item) => ({ type: item.type, value: item.value, relay: item.relay, watchedAt: item.watchedAt, })), plaintextPayload.items, "plaintext payload should be parsed without invoking nip04 helpers", ); assert.equal( plaintextResult.snapshot, plaintextPayload.snapshot, "plaintext payload should preserve snapshot metadata", ); assert.equal( plaintextResult.chunkIndex, plaintextPayload.chunkIndex, "plaintext payload should preserve chunk index", ); assert.equal( plaintextResult.totalChunks, plaintextPayload.totalChunks, "plaintext payload should preserve chunk count", ); } finally { globalThis.window.nostr = originalWindowNostr; if (originalSessionDecrypt === undefined) { delete globalThis.window.NostrTools.nip04.decrypt; } else { globalThis.window.NostrTools.nip04.decrypt = originalSessionDecrypt; } } const encryptedPayload = { version: 2, snapshot: "encrypted-snapshot", chunkIndex: 0, totalChunks: 1, items: [ { type: "e", value: "encrypted-pointer", relay: "wss://relay.unit", watchedAt: 1_700_000_456_000, }, ], }; const encryptedCiphertext = "ZW5jcnlwdGVkQ29udGVudD9mb29iYXI=?iv=YWJjMTIz"; const encryptedChunkEvent = { kind: WATCH_HISTORY_KIND, pubkey: ACTOR, created_at: 444, tags: [ ["d", `${WATCH_HISTORY_CHUNK_PREFIX}/encrypted/0`], ["encrypted", "nip04"], ["snapshot", encryptedPayload.snapshot], ["chunk", String(encryptedPayload.chunkIndex), String(encryptedPayload.totalChunks)], ["e", encryptedPayload.items[0].value, encryptedPayload.items[0].relay], ], content: encryptedCiphertext, }; const encryptedClient = createDecryptClient(ACTOR); encryptedClient.pubkey = ACTOR; const priorWindowNostr = globalThis.window.nostr; const priorSessionDecrypt = globalThis.window.NostrTools.nip04.decrypt; let extensionDecryptCalls = 0; let sessionDecryptCalls = 0; try { globalThis.window.nostr = { nip04: { decrypt: async (pubkey, ciphertext) => { extensionDecryptCalls += 1; assert.equal(pubkey, ACTOR, "extension decrypt should target the actor"); assert.equal( ciphertext, encryptedCiphertext, "extension decrypt should receive the chunk ciphertext", ); return JSON.stringify(encryptedPayload); }, }, }; globalThis.window.NostrTools.nip04.decrypt = async () => { sessionDecryptCalls += 1; return JSON.stringify(encryptedPayload); }; const decryptedResult = await encryptedClient.decryptWatchHistoryEvent( encryptedChunkEvent, ACTOR ); assert.equal( extensionDecryptCalls, 1, "encrypted chunks should invoke extension nip04 decrypt", ); assert.equal( sessionDecryptCalls, 0, "successful extension decrypt should skip session fallback", ); assert.deepEqual( decryptedResult.items.map((item) => ({ type: item.type, value: item.value, relay: item.relay, watchedAt: item.watchedAt, })), encryptedPayload.items, "encrypted payload should be reconstructed from decrypted content", ); assert.equal( decryptedResult.snapshot, encryptedPayload.snapshot, "encrypted payload should preserve snapshot metadata", ); } finally { globalThis.window.nostr = priorWindowNostr; if (priorSessionDecrypt === undefined) { delete globalThis.window.NostrTools.nip04.decrypt; } else { globalThis.window.NostrTools.nip04.decrypt = priorSessionDecrypt; } } const missingSignerClient = createDecryptClient(LOGGED_ACTOR); missingSignerClient.pubkey = LOGGED_ACTOR; missingSignerClient.sessionActor = null; let missingSignerEnsureCalls = 0; missingSignerClient.ensureSessionActor = async () => { missingSignerEnsureCalls += 1; return LOGGED_ACTOR; }; const priorMissingSignerNostr = globalThis.window.nostr; const priorMissingSignerSessionDecrypt = globalThis.window.NostrTools.nip04.decrypt; let missingSignerSessionDecryptCalls = 0; try { delete globalThis.window.nostr; globalThis.window.NostrTools.nip04.decrypt = async () => { missingSignerSessionDecryptCalls += 1; return JSON.stringify(encryptedPayload); }; const missingSignerChunkEvent = { ...encryptedChunkEvent, pubkey: LOGGED_ACTOR, id: "missing-signer-event", }; const missingSignerResult = await missingSignerClient.decryptWatchHistoryEvent( missingSignerChunkEvent, LOGGED_ACTOR ); assert.equal( missingSignerEnsureCalls, 0, "missing signer should not trigger session actor ensure" ); assert.equal( missingSignerSessionDecryptCalls, 0, "missing signer should skip session decrypt fallback" ); assert.equal( missingSignerResult.needsSignerRetry, true, "missing signer fallback should mark retry flag" ); assert.deepEqual( missingSignerResult.items.map((item) => ({ type: item.type, value: item.value, relay: item.relay || null, })), encryptedPayload.items.map((item) => ({ type: item.type, value: item.value, relay: item.relay || null, })), "missing signer fallback should preserve pointer metadata" ); } finally { if (priorMissingSignerNostr === undefined) { delete globalThis.window.nostr; } else { globalThis.window.nostr = priorMissingSignerNostr; } if (priorMissingSignerSessionDecrypt === undefined) { delete globalThis.window.NostrTools.nip04.decrypt; } else { globalThis.window.NostrTools.nip04.decrypt = priorMissingSignerSessionDecrypt; } } const unsortedVideos = [ { id: "alpha", watchHistory: { watchedAt: 1_700_000_000_500 } }, { id: "bravo", watchHistory: { watchedAt: 1_700_000_010_000 } }, { id: "charlie", created_at: 1_699_999_000 }, ]; const sortedVideos = unsortedVideos.slice().sort(compareByWatchedAtDesc); assert.deepEqual( sortedVideos.map((video) => video.id), ["bravo", "alpha", "charlie"], "videos should be ordered by watchedAt descending with created_at fallback", ); const tieVideos = [ { id: "echo", watchHistory: { watchedAt: 1_700_100_000_000 }, created_at: 1_700_100_100 }, { id: "delta", watchHistory: { watchedAt: 1_700_100_000_000 }, created_at: 1_700_100_200 }, { id: "foxtrot", watchHistory: { watchedAt: 1_700_100_000_000 }, created_at: 1_700_100_200 }, ]; tieVideos.sort(compareByWatchedAtDesc); assert.equal( getWatchedAtScore(tieVideos[0]), getWatchedAtScore(tieVideos[1]), "ties should preserve identical watchedAt scores", ); assert.deepEqual( tieVideos.map((video) => video.id), ["delta", "foxtrot", "echo"], "created_at should break watchedAt ties with stable fallback", ); console.log("watch history tests passed");