// Run with: node tests/feed-engine.test.mjs import assert from "node:assert/strict"; if (typeof globalThis.window === "undefined") { globalThis.window = {}; } if (typeof globalThis.localStorage === "undefined") { const storage = new Map(); globalThis.localStorage = { getItem(key) { return storage.has(key) ? storage.get(key) : null; }, setItem(key, value) { storage.set(String(key), String(value)); }, removeItem(key) { storage.delete(key); }, clear() { storage.clear(); }, }; } if (typeof window.localStorage === "undefined") { window.localStorage = globalThis.localStorage; } const { createFeedEngine, createDedupeByRootStage, createBlacklistFilterStage, createWatchHistorySuppressionStage, createResolvePostedAtStage, createChronologicalSorter, } = await import("../js/feedEngine/index.js"); async function testDedupeOrdering() { const engine = createFeedEngine(); const feedName = "dedupe"; const videoOld = { id: "a-old", videoRootId: "rootA", created_at: 100 }; const videoNew = { id: "a-new", videoRootId: "rootA", created_at: 200 }; const videoOther = { id: "b", created_at: 150 }; engine.registerFeed(feedName, { source: async () => [ { video: videoOld }, { video: videoOther }, { video: videoNew }, ], stages: [createDedupeByRootStage()], sorter: createChronologicalSorter(), }); const result = await engine.runFeed(feedName); assert.equal(result.videos.length, 2, "dedupe stage should drop older root entries"); assert.deepEqual( result.videos.map((video) => video.id), ["a-new", "b"], "videos should be returned newest first", ); const dedupeReasons = result.metadata.why.filter( (entry) => entry.reason === "older-root-version", ); assert.equal(dedupeReasons.length, 1, "dedupe stage should log one older-root reason"); assert.equal(dedupeReasons[0].videoId, "a-old"); assert.equal(dedupeReasons[0].rootId, "rootA"); } async function testRootCreatedAtSorting() { const engine = createFeedEngine(); const feedName = "root-created-sort"; const originalVideo = { id: "video-original", videoRootId: "root-1", created_at: 100, rootCreatedAt: 100, }; const updatedVideo = { id: "video-updated", videoRootId: "root-1", created_at: 400, rootCreatedAt: 100, }; const newerRootVideo = { id: "video-newer-root", videoRootId: "root-2", created_at: 300, rootCreatedAt: 300, }; engine.registerFeed(feedName, { source: async () => [ { video: originalVideo }, { video: updatedVideo }, { video: newerRootVideo }, ], stages: [createDedupeByRootStage()], sorter: createChronologicalSorter(), }); const result = await engine.runFeed(feedName); assert.deepEqual( result.videos.map((video) => video.id), ["video-newer-root", "video-updated"], "videos should be sorted by original posting time, not last edit", ); } async function testTimestampHookSorting() { const engine = createFeedEngine(); const feedName = "timestamp-hook"; const editedVideo = { id: "edited", // newest edit should not jump ahead of older roots videoRootId: "root-hook-a", created_at: 500, }; const newerRoot = { id: "new-root", videoRootId: "root-hook-b", created_at: 450, }; engine.registerFeed(feedName, { source: async () => [ { video: editedVideo }, { video: newerRoot }, ], stages: [createDedupeByRootStage()], sorter: createChronologicalSorter(), }); const hookCalls = []; const result = await engine.runFeed(feedName, { hooks: { timestamps: { getKnownVideoPostedAt(video) { hookCalls.push(video.id); if (video.videoRootId === "root-hook-a") { return 100; } if (video.videoRootId === "root-hook-b") { return 300; } return null; }, }, }, }); assert.deepEqual( result.videos.map((video) => video.id), ["new-root", "edited"], "timestamp hook should drive chronological order", ); assert.deepEqual( new Set(hookCalls), new Set(["edited", "new-root"]), "timestamp hook should be invoked for each video", ); } async function testResolvePostedAtStageHydration() { const engine = createFeedEngine(); const feedName = "resolve-stage"; const editedVideo = { id: "edited-stage", videoRootId: "root-stage-a", created_at: 500, }; const newerRoot = { id: "new-root-stage", videoRootId: "root-stage-b", created_at: 450, }; engine.registerFeed(feedName, { source: async () => [ { video: editedVideo }, { video: newerRoot }, ], stages: [createResolvePostedAtStage()], sorter: createChronologicalSorter(), }); const resolverCalls = []; const result = await engine.runFeed(feedName, { hooks: { timestamps: { async resolveVideoPostedAt(video) { resolverCalls.push(video.id); if (video.videoRootId === "root-stage-a") { await new Promise((resolve) => setTimeout(resolve, 0)); return 100; } if (video.videoRootId === "root-stage-b") { return 300; } return null; }, }, }, }); assert.deepEqual( result.videos.map((video) => video.id), ["new-root-stage", "edited-stage"], "resolve stage should hydrate timestamps before sorting", ); assert.equal( editedVideo.rootCreatedAt, 100, "resolve stage should stamp the original posted time on edited videos", ); assert.equal(newerRoot.rootCreatedAt, 300); assert.deepEqual( new Set(resolverCalls), new Set(["edited-stage", "new-root-stage"]), "resolver should run for each video lacking a cached timestamp", ); } async function testBlacklistFiltering() { const engine = createFeedEngine(); const feedName = "blacklist"; engine.registerFeed(feedName, { source: async () => [ { video: { id: "safe", pubkey: "npub1", created_at: 1 } }, { video: { id: "blocked", pubkey: "npub2", created_at: 2 } }, { video: { id: "blocked-author", pubkey: "blocked", created_at: 3 } }, ], stages: [ createBlacklistFilterStage({ shouldIncludeVideo(video, { blacklistedEventIds, isAuthorBlocked }) { if (blacklistedEventIds.has(video.id)) { return false; } if (isAuthorBlocked(video.pubkey)) { return false; } return true; }, }), ], sorter: createChronologicalSorter(), }); const result = await engine.runFeed(feedName, { runtime: { blacklistedEventIds: new Set(["blocked"]), isAuthorBlocked: (pubkey) => pubkey === "blocked", }, }); assert.deepEqual(result.videos.map((video) => video.id), ["safe"]); const blacklistReasons = result.metadata.why.filter( (entry) => entry.reason === "blacklist", ); assert.equal( blacklistReasons.length, 2, "blacklist stage should log two exclusions (one per blocked item)", ); const removedIds = new Set(blacklistReasons.map((entry) => entry.videoId)); assert.ok(removedIds.has("blocked")); assert.ok(removedIds.has("blocked-author")); } async function testWatchHistoryHookIsolation() { const engine = createFeedEngine(); engine.registerFeed("feed-a", { source: async () => [ { video: { id: "x1", created_at: 10 }, pointer: { type: "e", value: "x1" } }, ], stages: [createWatchHistorySuppressionStage()], }); engine.registerFeed("feed-b", { source: async () => [ { video: { id: "y1", created_at: 20 }, pointer: { type: "e", value: "y1" } }, ], stages: [createWatchHistorySuppressionStage()], }); let hookACount = 0; let hookBCount = 0; const resultA = await engine.runFeed("feed-a", { hooks: { watchHistory: { async shouldSuppress(item) { hookACount += 1; return item?.video?.id === "x1"; }, }, }, }); const resultB = await engine.runFeed("feed-b", { hooks: { watchHistory: { async shouldSuppress(item) { hookBCount += 1; return false; }, }, }, }); assert.equal(hookACount, 1, "feed A hook should run once"); assert.equal(hookBCount, 1, "feed B hook should run once"); assert.equal(resultA.videos.length, 0, "feed A should suppress the single video"); assert.equal(resultB.videos.length, 1, "feed B should keep its video"); const whyA = resultA.metadata.why.find((entry) => entry.reason === "watch-history"); assert.ok(whyA, "feed A should report watch-history suppression metadata"); assert.equal(whyA.videoId, "x1"); } async function testBlacklistOrderingWithRuntimeChanges() { const engine = createFeedEngine(); const feedName = "runtime-order"; engine.registerFeed(feedName, { source: async () => [ { video: { id: "v1", created_at: 100 } }, { video: { id: "v2", created_at: 200 } }, { video: { id: "v3", created_at: 300 } }, ], stages: [ createBlacklistFilterStage({ shouldIncludeVideo(video, { blacklistedEventIds }) { return !blacklistedEventIds.has(video.id); }, }), ], sorter: createChronologicalSorter(), }); const baseline = await engine.runFeed(feedName, { runtime: { blacklistedEventIds: new Set() }, }); assert.deepEqual(baseline.videos.map((video) => video.id), [ "v3", "v2", "v1", ]); const withoutTop = await engine.runFeed(feedName, { runtime: { blacklistedEventIds: new Set(["v3"]) }, }); assert.deepEqual(withoutTop.videos.map((video) => video.id), ["v2", "v1"]); const withoutMiddle = await engine.runFeed(feedName, { runtime: { blacklistedEventIds: new Set(["v2"]) }, }); assert.deepEqual(withoutMiddle.videos.map((video) => video.id), [ "v3", "v1", ]); } async function testTrustedMuteDownrank() { const sorter = createChronologicalSorter(); const items = [ { video: { id: "muted", created_at: 400 }, metadata: { moderation: { trustedMuted: true } }, }, { video: { id: "fresh", created_at: 500 }, metadata: { moderation: { trustedMuted: false } }, }, { video: { id: "older", created_at: 300 }, metadata: {}, }, ]; const sorted = sorter(items); assert.deepEqual( sorted.map((entry) => entry.video.id), ["fresh", "older", "muted"], ); } await testDedupeOrdering(); await testRootCreatedAtSorting(); await testTimestampHookSorting(); await testResolvePostedAtStageHydration(); await testBlacklistFiltering(); await testWatchHistoryHookIsolation(); await testBlacklistOrderingWithRuntimeChanges(); await testTrustedMuteDownrank(); console.log("All feed engine tests passed");