From 4cc129973cb8e29f96577745f9d23f66e8545415 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 20 Oct 2025 10:47:41 -0400 Subject: [PATCH] Add moderation service helpers and expand moderation tests --- tests/helpers/moderation-test-helpers.mjs | 205 ++++++++++++++++++ tests/moderation-stage.test.mjs | 129 +++++++++++ .../moderation/trusted-report-count.test.mjs | 171 +++++++-------- tests/moderation/video-card.test.mjs | 164 ++++++++++++++ 4 files changed, 580 insertions(+), 89 deletions(-) create mode 100644 tests/helpers/moderation-test-helpers.mjs create mode 100644 tests/moderation/video-card.test.mjs diff --git a/tests/helpers/moderation-test-helpers.mjs b/tests/helpers/moderation-test-helpers.mjs new file mode 100644 index 00000000..3b2dab3f --- /dev/null +++ b/tests/helpers/moderation-test-helpers.mjs @@ -0,0 +1,205 @@ +import "../test-helpers/setup-localstorage.mjs"; +import { ModerationService } from "../../js/services/moderationService.js"; + +const DEFAULT_CONTACT_OWNER = "f".repeat(64); + +export function withMockedNostrTools(t) { + const hadWindow = typeof globalThis.window !== "undefined"; + const previousWindow = hadWindow ? globalThis.window : undefined; + if (!hadWindow) { + globalThis.window = {}; + } + + const previousGlobalTools = globalThis.NostrTools; + const previousWindowTools = globalThis.window?.NostrTools; + + const nip19 = { + npubEncode(hex) { + if (typeof hex !== "string" || !hex) { + throw new Error("invalid hex"); + } + return `npub${hex}`; + }, + decode(value) { + if (typeof value !== "string" || !value.startsWith("npub")) { + throw new Error("invalid npub"); + } + return { type: "npub", data: value.slice(4) }; + }, + }; + + globalThis.NostrTools = { nip19 }; + if (globalThis.window) { + globalThis.window.NostrTools = globalThis.NostrTools; + } + + t.after(() => { + if (typeof previousGlobalTools === "undefined") { + delete globalThis.NostrTools; + } else { + globalThis.NostrTools = previousGlobalTools; + } + + if (globalThis.window) { + if (typeof previousWindowTools === "undefined") { + delete globalThis.window.NostrTools; + } else { + globalThis.window.NostrTools = previousWindowTools; + } + } + + if (!hadWindow) { + delete globalThis.window; + } else { + globalThis.window = previousWindow; + } + }); +} + +export function createModerationServiceHarness(t, { userBlocks, accessControl } = {}) { + const service = new ModerationService({ + logger: () => {}, + userBlocks: + userBlocks || { + async ensureLoaded() {}, + isBlocked() { + return false; + }, + }, + accessControl: accessControl || null, + }); + + const tracked = new Map(); + + function capture(eventName) { + if (!tracked.has(eventName)) { + tracked.set(eventName, []); + } + const records = tracked.get(eventName); + const unsubscribe = service.on(eventName, (detail) => { + records.push(detail); + }); + if (typeof unsubscribe === "function") { + t.after(() => { + try { + unsubscribe(); + } catch { + // noop for tests + } + }); + } + return records; + } + + return { service, capture, trackedEvents: tracked }; +} + +export function createReportEvent({ + id, + reporter, + eventId, + createdAt = Math.floor(Date.now() / 1000), + type = "nudity", +} = {}) { + const reportId = + typeof id === "string" && id.length + ? id + : `${Math.random().toString(16).slice(2)}${"0".repeat(64)}`.slice(0, 64); + const reporterPubkey = typeof reporter === "string" && reporter ? reporter : ""; + const targetEventId = typeof eventId === "string" && eventId ? eventId : ""; + const normalizedType = typeof type === "string" && type ? type : "nudity"; + + return { + kind: 1984, + id: reportId, + pubkey: reporterPubkey, + created_at: Number.isFinite(createdAt) ? Math.floor(createdAt) : Math.floor(Date.now() / 1000), + tags: [ + ["e", targetEventId], + ["report", normalizedType], + ], + content: "fixture report", + }; +} + +export function createContactEvent({ + owner = DEFAULT_CONTACT_OWNER, + contacts = [], + createdAt = Math.floor(Date.now() / 1000), + id, +} = {}) { + const eventId = + typeof id === "string" && id.length + ? id + : `${Math.random().toString(16).slice(2)}${"1".repeat(64)}`.slice(0, 64); + const ownerHex = typeof owner === "string" && owner ? owner : DEFAULT_CONTACT_OWNER; + const tags = contacts + .filter((value) => typeof value === "string" && value) + .map((value) => ["p", value]); + + return { + kind: 3, + id: eventId, + pubkey: ownerHex, + created_at: Number.isFinite(createdAt) ? Math.floor(createdAt) : Math.floor(Date.now() / 1000), + tags, + content: "", + }; +} + +export function applyTrustedContacts(service, contacts = []) { + const event = createContactEvent({ contacts }); + service.applyContactEvent(event); + return event; +} + +let cachedApplicationClass = null; + +async function getApplicationClass() { + if (!cachedApplicationClass) { + if (typeof globalThis.self === "undefined") { + globalThis.self = globalThis; + } + if (typeof globalThis.WebSocket === "undefined") { + globalThis.WebSocket = class { + constructor() {} + close() {} + addEventListener() {} + removeEventListener() {} + send() {} + }; + } + const module = await import("../../js/app.js"); + cachedApplicationClass = module.Application || module.default; + } + return cachedApplicationClass; +} + +export async function createModerationAppHarness() { + const Application = await getApplicationClass(); + const app = Object.create(Application.prototype); + + Object.defineProperty(app, "__profiles", { + value: new Map(), + writable: true, + configurable: true, + }); + + app.getProfileCacheEntry = (pubkey) => { + if (!pubkey || typeof pubkey !== "string") { + return null; + } + return app.__profiles.get(pubkey) || null; + }; + + app.decorateVideoModeration = Application.prototype.decorateVideoModeration; + app.deriveModerationReportType = Application.prototype.deriveModerationReportType; + app.deriveModerationTrustedCount = Application.prototype.deriveModerationTrustedCount; + app.getReporterDisplayName = Application.prototype.getReporterDisplayName; + app.handleModerationOverride = Application.prototype.handleModerationOverride; + + app.videosMap = new Map(); + app.currentVideo = null; + + return app; +} diff --git a/tests/moderation-stage.test.mjs b/tests/moderation-stage.test.mjs index 7e331779..77301ea3 100644 --- a/tests/moderation-stage.test.mjs +++ b/tests/moderation-stage.test.mjs @@ -166,3 +166,132 @@ test("moderation stage annotates trusted mute metadata", async () => { assert.ok(trustedMuteReason); assert.equal(trustedMuteReason.videoId, "muted"); }); + +test("moderation stage propagates whitelist, muters, and threshold updates", async () => { + const whitelistedHex = "a".repeat(64); + const mutedHex = "b".repeat(64); + const muterHex = "c".repeat(64); + + const state = { + whitelistHex: new Set([whitelistedHex]), + summaryById: new Map([ + [ + "muted-video", + { + eventId: "muted-video", + totalTrusted: 3, + types: { nudity: { trusted: 3, total: 3, latest: 1_700_000_000 } }, + updatedAt: 1_700_000_100, + }, + ], + ]), + trustedCounts: new Map([ + ["whitelisted-video", 4], + ["muted-video", 3], + ]), + reportersById: new Map([ + ["muted-video", [{ pubkey: muterHex, latest: 1_700_000_050 }]], + ]), + mutedAuthors: new Set([mutedHex]), + mutersByAuthor: new Map([[mutedHex, [muterHex]]]), + }; + + const service = { + async refreshViewerFromClient() {}, + async setActiveEventIds() {}, + getAdminListSnapshot() { + return { + whitelist: new Set([`npub${whitelistedHex}`]), + whitelistHex: new Set(state.whitelistHex), + blacklist: new Set(), + blacklistHex: new Set(), + }; + }, + getAccessControlStatus(identifier) { + const normalized = typeof identifier === "string" ? identifier.trim().toLowerCase() : ""; + return { + hex: normalized, + whitelisted: state.whitelistHex.has(normalized), + blacklisted: false, + }; + }, + getTrustedReportSummary(videoId) { + return state.summaryById.get(videoId) || null; + }, + trustedReportCount(videoId) { + return state.trustedCounts.get(videoId) || 0; + }, + getTrustedReporters(videoId) { + return state.reportersById.get(videoId) || []; + }, + isAuthorMutedByTrusted(pubkey) { + return state.mutedAuthors.has(pubkey); + }, + getTrustedMutersForAuthor(pubkey) { + return state.mutersByAuthor.get(pubkey) || []; + }, + }; + + const stage = createModerationStage({ + service, + autoplayThreshold: 2, + blurThreshold: 3, + reportType: "nudity", + }); + + const items = [ + { video: { id: "whitelisted-video", pubkey: whitelistedHex }, metadata: {} }, + { video: { id: "muted-video", pubkey: mutedHex }, metadata: {} }, + ]; + + const reasons = []; + const context = { + feedName: "discovery", + addWhy(detail) { + reasons.push(detail); + return detail; + }, + log() {}, + }; + + const firstPass = await stage(items, context); + const whitelistedItem = firstPass.find((entry) => entry.video.id === "whitelisted-video"); + const mutedItem = firstPass.find((entry) => entry.video.id === "muted-video"); + + assert.ok(whitelistedItem); + assert.ok(mutedItem); + + assert.equal(whitelistedItem.metadata.moderation.adminWhitelist, true); + assert.equal(whitelistedItem.metadata.moderation.blockAutoplay, false); + assert.equal(whitelistedItem.metadata.moderation.blurThumbnail, false); + assert.equal(whitelistedItem.video.moderation.adminWhitelistBypass, true); + + assert.equal(mutedItem.metadata.moderation.trustedMuted, true); + assert.deepEqual(mutedItem.metadata.moderation.trustedMuters, [muterHex]); + assert.equal(mutedItem.metadata.moderation.blockAutoplay, true); + assert.equal(mutedItem.metadata.moderation.blurThumbnail, true); + assert.equal(mutedItem.video.moderation.trustedMuted, true); + assert.deepEqual(mutedItem.video.moderation.trustedMuters, [muterHex]); + + state.summaryById.set("muted-video", { + eventId: "muted-video", + totalTrusted: 1, + types: { nudity: { trusted: 1, total: 1, latest: 1_700_000_500 } }, + updatedAt: 1_700_000_600, + }); + state.trustedCounts.set("muted-video", 1); + state.mutersByAuthor.set(mutedHex, []); + state.mutedAuthors.delete(mutedHex); + + const secondPass = await stage(firstPass, context); + const updatedMuted = secondPass.find((entry) => entry.video.id === "muted-video"); + + assert.ok(updatedMuted); + assert.equal(updatedMuted.metadata.moderation.trustedMuted, false); + assert.deepEqual(updatedMuted.metadata.moderation.trustedMuters, []); + assert.equal(updatedMuted.metadata.moderation.blockAutoplay, false); + assert.equal(updatedMuted.metadata.moderation.blurThumbnail, false); + assert.equal(updatedMuted.video.moderation.trustedMuted, false); + assert.equal(updatedMuted.video.moderation.blockAutoplay, false); + assert.equal(updatedMuted.video.moderation.blurThumbnail, false); +}); diff --git a/tests/moderation/trusted-report-count.test.mjs b/tests/moderation/trusted-report-count.test.mjs index c3fb5306..449f1051 100644 --- a/tests/moderation/trusted-report-count.test.mjs +++ b/tests/moderation/trusted-report-count.test.mjs @@ -1,71 +1,14 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { ModerationService } from "../../js/services/moderationService.js"; +import { + withMockedNostrTools, + createModerationServiceHarness, + createReportEvent, + applyTrustedContacts, +} from "../helpers/moderation-test-helpers.mjs"; -const nip19 = { - npubEncode(hex) { - if (typeof hex !== "string" || !hex) { - throw new Error("invalid hex"); - } - return `npub${hex}`; - }, - decode(value) { - if (typeof value !== "string" || !value.startsWith("npub")) { - throw new Error("invalid npub"); - } - return { type: "npub", data: value.slice(4) }; - }, -}; - -function withMockedNostrTools(t) { - const hadWindow = typeof globalThis.window !== "undefined"; - const previousWindow = hadWindow ? globalThis.window : undefined; - if (!hadWindow) { - globalThis.window = {}; - } - const previousGlobalTools = globalThis.NostrTools; - const previousWindowTools = globalThis.window?.NostrTools; - - globalThis.NostrTools = { nip19 }; - if (globalThis.window) { - globalThis.window.NostrTools = globalThis.NostrTools; - } - - t.after(() => { - if (typeof previousGlobalTools === "undefined") { - delete globalThis.NostrTools; - } else { - globalThis.NostrTools = previousGlobalTools; - } - - if (globalThis.window) { - if (typeof previousWindowTools === "undefined") { - delete globalThis.window.NostrTools; - } else { - globalThis.window.NostrTools = previousWindowTools; - } - } - - if (!hadWindow) { - delete globalThis.window; - } else { - globalThis.window = previousWindow; - } - }); -} - -function createReport({ id, reporter, eventId, createdAt, type = "nudity" }) { - return { - kind: 1984, - id, - pubkey: reporter, - created_at: createdAt, - tags: [ - ["e", eventId], - ["report", type], - ], - content: "fixture report", - }; +function createReport(options) { + return createReportEvent(options); } test("trustedReportCount dedupes multiple reports from the same trusted reporter", (t) => { @@ -74,17 +17,9 @@ test("trustedReportCount dedupes multiple reports from the same trusted reporter const eventId = "f".repeat(64); const reporterHex = "a".repeat(64); - const service = new ModerationService({ - logger: () => {}, - userBlocks: { - async ensureLoaded() {}, - isBlocked() { - return false; - }, - }, - }); + const { service } = createModerationServiceHarness(t); - service.trustedContacts = new Set([reporterHex]); + applyTrustedContacts(service, [reporterHex]); const firstReport = createReport({ id: "1".repeat(64), @@ -116,17 +51,16 @@ test("trustedReportCount ignores reports from muted or blocked reporters", (t) = const mutedReporter = "b".repeat(64); const trustedReporter = "c".repeat(64); - const service = new ModerationService({ - logger: () => {}, - userBlocks: { - async ensureLoaded() {}, - isBlocked(pubkey) { - return pubkey === mutedReporter; - }, + const userBlocks = { + async ensureLoaded() {}, + isBlocked(pubkey) { + return pubkey === mutedReporter; }, - }); + }; - service.trustedContacts = new Set([mutedReporter, trustedReporter]); + const { service } = createModerationServiceHarness(t, { userBlocks }); + + applyTrustedContacts(service, [mutedReporter, trustedReporter]); const mutedReport = createReport({ id: "3".repeat(64), @@ -182,12 +116,9 @@ test("blocking and unblocking reporters recomputes trusted summaries", async (t) }, }; - const service = new ModerationService({ - logger: () => {}, - userBlocks: userBlocksMock, - }); + const { service } = createModerationServiceHarness(t, { userBlocks: userBlocksMock }); - service.trustedContacts = new Set([reporterHex]); + applyTrustedContacts(service, [reporterHex]); const report = createReport({ id: "b".repeat(64), @@ -235,3 +166,65 @@ test("blocking and unblocking reporters recomputes trusted summaries", async (t) assert.equal(service.trustedReportCount(eventId, "nudity"), 1); assert.deepEqual(setActiveCalls, [[eventId], [eventId]]); }); + +test("trustedReportCount only counts eligible F1 reporters and admin whitelist", (t) => { + withMockedNostrTools(t); + + const eventId = "c".repeat(64); + const viewerBlocked = "1".repeat(64); + const f1Reporter = "2".repeat(64); + const f2Reporter = "3".repeat(64); + const adminWhitelisted = "4".repeat(64); + const adminBlacklisted = "5".repeat(64); + + const userBlocks = { + async ensureLoaded() {}, + isBlocked(pubkey) { + return pubkey === viewerBlocked; + }, + }; + + const accessControl = { + getWhitelist() { + return [`npub${adminWhitelisted}`]; + }, + getBlacklist() { + return [`npub${adminBlacklisted}`]; + }, + }; + + const { service } = createModerationServiceHarness(t, { userBlocks, accessControl }); + + applyTrustedContacts(service, [f1Reporter, viewerBlocked]); + + const reports = [ + { id: "r1".repeat(32), reporter: f1Reporter, createdAt: 1_700_000_000 }, + { id: "r2".repeat(32), reporter: viewerBlocked, createdAt: 1_700_000_050 }, + { id: "r3".repeat(32), reporter: f2Reporter, createdAt: 1_700_000_100 }, + { id: "r4".repeat(32), reporter: adminWhitelisted, createdAt: 1_700_000_200 }, + { id: "r5".repeat(32), reporter: adminBlacklisted, createdAt: 1_700_000_300 }, + ]; + + for (const entry of reports) { + service.ingestReportEvent( + createReport({ + id: entry.id.padEnd(64, entry.id[0]), + reporter: entry.reporter, + eventId, + createdAt: entry.createdAt, + }), + ); + } + + const summary = service.getTrustedReportSummary(eventId); + assert.equal(summary.totalTrusted, 2); + assert.equal(summary.types.nudity.total, 3); + assert.equal(summary.types.nudity.trusted, 2); + assert.equal(service.trustedReportCount(eventId, "nudity"), 2); + + const reporters = service.getTrustedReporters(eventId, "nudity"); + assert.deepEqual( + reporters.map((entry) => entry.pubkey).sort(), + [adminWhitelisted, f1Reporter].sort(), + ); +}); diff --git a/tests/moderation/video-card.test.mjs b/tests/moderation/video-card.test.mjs new file mode 100644 index 00000000..2969cb1d --- /dev/null +++ b/tests/moderation/video-card.test.mjs @@ -0,0 +1,164 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; + +import "../test-helpers/setup-localstorage.mjs"; +import { VideoCard } from "../../js/ui/components/VideoCard.js"; +import { clearModerationOverride, setModerationOverride } from "../../js/state/cache.js"; +import { + withMockedNostrTools, + createModerationAppHarness, +} from "../helpers/moderation-test-helpers.mjs"; + +function setupDom(t) { + const dom = new JSDOM("", { + url: "https://example.com", + pretendToBeVisual: true, + }); + + const { window } = dom; + const previous = new Map(); + const keys = [ + "window", + "document", + "HTMLElement", + "Element", + "Node", + "MouseEvent", + "CustomEvent", + "ResizeObserver", + "requestAnimationFrame", + "cancelAnimationFrame", + "navigator", + ]; + + keys.forEach((key) => { + const hadValue = Object.prototype.hasOwnProperty.call(globalThis, key); + previous.set(key, hadValue ? globalThis[key] : Symbol.for("__undefined__")); + if (key === "requestAnimationFrame" && typeof window[key] !== "function") { + window.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 16); + } else if (key === "cancelAnimationFrame" && typeof window[key] !== "function") { + window.cancelAnimationFrame = (id) => clearTimeout(id); + } else if (key === "ResizeObserver" && typeof window[key] !== "function") { + window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } + globalThis[key] = window[key]; + }); + + if (!window.requestAnimationFrame) { + window.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 16); + globalThis.requestAnimationFrame = window.requestAnimationFrame; + } + + if (!window.cancelAnimationFrame) { + window.cancelAnimationFrame = (id) => clearTimeout(id); + globalThis.cancelAnimationFrame = window.cancelAnimationFrame; + } + + if (!window.ResizeObserver) { + class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} + } + window.ResizeObserver = ResizeObserverStub; + globalThis.ResizeObserver = ResizeObserverStub; + } + + t.after(() => { + dom.window.close(); + keys.forEach((key) => { + const previousValue = previous.get(key); + if (previousValue === Symbol.for("__undefined__")) { + delete globalThis[key]; + } else { + globalThis[key] = previousValue; + } + }); + }); + + return { window, document: window.document }; +} + +test("VideoCard renders moderation badges and respects viewer override", async (t) => { + const { document } = setupDom(t); + withMockedNostrTools(t); + + const app = await createModerationAppHarness(); + const videoId = "a".repeat(64); + + const video = { + id: videoId, + title: "Blurred Clip", + pubkey: "d".repeat(64), + moderation: { + blockAutoplay: true, + blurThumbnail: true, + reportType: "nudity", + trustedCount: 3, + summary: { + eventId: videoId, + totalTrusted: 3, + types: { nudity: { trusted: 3, total: 3, latest: 1_700_000_000 } }, + updatedAt: 1_700_000_100, + }, + trustedReporters: [{ pubkey: "e".repeat(64), latest: 1_700_000_050 }], + adminWhitelist: false, + trustedMuted: false, + }, + }; + + t.after(() => { + clearModerationOverride(video.id, { persist: false }); + }); + + app.videosMap.set(video.id, video); + app.currentVideo = video; + app.decorateVideoModeration(video); + + const card = new VideoCard({ + document, + video, + formatters: { + formatTimeAgo: () => "moments ago", + }, + helpers: { + isMagnetSupported: () => false, + }, + }); + + document.body.appendChild(card.getRoot()); + + assert.equal(card.getRoot().dataset.autoplayPolicy, "blocked"); + assert.equal(card.thumbnailEl.dataset.thumbnailState, "blurred"); + assert.equal(card.getRoot().dataset.moderationReportCount, "3"); + assert.ok(card.moderationBadgeEl); + assert.equal(card.moderationBadgeEl.dataset.moderationState, "blocked"); + assert.ok(card.moderationActionButton); + + card.onModerationOverride = ({ video: overrideVideo }) => { + app.videosMap.set(overrideVideo.id, overrideVideo); + app.currentVideo = overrideVideo; + setModerationOverride(overrideVideo.id, { showAnyway: true, updatedAt: Date.now() }, { persist: false }); + app.decorateVideoModeration(overrideVideo); + card.refreshModerationUi(); + return true; + }; + + card.moderationActionButton.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const contextAfterOverride = card.getModerationContext(); + assert.equal(contextAfterOverride.overrideActive, true); + assert.equal(contextAfterOverride.activeBlockAutoplay, false); + + assert.equal(card.getRoot().dataset.autoplayPolicy, undefined); + assert.equal(card.getRoot().dataset.moderationOverride, "show-anyway"); + assert.equal(card.thumbnailEl.dataset.thumbnailState, undefined); + assert.equal(card.moderationBadgeEl.dataset.moderationState, "override"); + assert.ok(card.moderationBadgeEl.textContent.includes("Showing despite")); +});