Merge pull request #708 from PR0M3TH3AN/codex/add-tests-for-moderation-service-events-and-actions

Add moderation block regressions and fixture controls
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-20 11:24:51 -04:00
commit 0a520287a0
5 files changed
+526 -39

No files matched your search

+190 -39
View File
@@ -47,6 +47,7 @@
</main>
<script type="module">
import { VideoCard } from "/js/ui/components/VideoCard.js";
import moderationService from "/js/services/moderationService.js";
const STORAGE_KEY = "bitvid:moderation:fixture-overrides";
const fixtures = [
@@ -55,6 +56,7 @@
heading: "Blur threshold (≥3 trusted nudity reports)",
description:
"Card should be blurred and autoplay disabled until the viewer overrides it.",
thresholds: { blur: 3, autoplay: 2 },
video: {
id: "1".repeat(64),
title: "Fixture: Blurred Preview",
@@ -72,6 +74,10 @@
{ name: "Mira F1", pubkey: "c".repeat(64), latest: 1_700_000_020 },
{ name: "Omar F1", pubkey: "d".repeat(64), latest: 1_700_000_030 },
],
blockToggle: {
label: "Simulate viewer blocking Mira F1",
pubkey: "c".repeat(64),
},
},
{
key: "autoplay-threshold",
@@ -140,6 +146,89 @@
},
];
const blockedReportersByVideo = new Map();
function getBlockedSet(videoId) {
if (typeof videoId !== "string" || !videoId) {
return null;
}
return blockedReportersByVideo.get(videoId) || null;
}
function setReporterBlocked(videoId, pubkey, blocked) {
if (typeof videoId !== "string" || !videoId || typeof pubkey !== "string" || !pubkey) {
return;
}
let entry = blockedReportersByVideo.get(videoId);
if (blocked) {
if (!entry) {
entry = new Set();
blockedReportersByVideo.set(videoId, entry);
}
entry.add(pubkey);
return;
}
if (!entry) {
return;
}
entry.delete(pubkey);
if (!entry.size) {
blockedReportersByVideo.delete(videoId);
}
}
function isReporterBlocked(videoId, pubkey) {
const entry = getBlockedSet(videoId);
if (!entry) {
return false;
}
return entry.has(pubkey);
}
function getActiveReporters(fixture, videoId) {
const reporters = Array.isArray(fixture?.reporters) ? fixture.reporters : [];
if (!reporters.length) {
return [];
}
const entry = getBlockedSet(videoId);
if (!entry || !entry.size) {
return reporters.slice();
}
return reporters.filter((reporter) => {
const key = typeof reporter?.pubkey === "string" ? reporter.pubkey : "";
if (!key) {
return false;
}
return !entry.has(key);
});
}
function normalizeFixtureReporters(reporters) {
if (!Array.isArray(reporters)) {
return [];
}
return reporters
.map((reporter) => {
if (!reporter || typeof reporter !== "object") {
return null;
}
const pubkey = typeof reporter.pubkey === "string" ? reporter.pubkey : "";
if (!pubkey) {
return null;
}
const latest = Number.isFinite(reporter.latest) ? Math.floor(reporter.latest) : 0;
const name = typeof reporter.name === "string" ? reporter.name : "";
return { name, pubkey, latest };
})
.filter(Boolean);
}
window.__MODERATION_FIXTURE_STORAGE_KEY__ = STORAGE_KEY;
function loadOverrides() {
@@ -163,26 +252,25 @@
}
}
function buildSummary(fixture) {
function buildSummary(fixture, videoId, sanitizedReporters = null) {
if (!fixture?.moderation?.reportType) {
return null;
}
const latest = Array.isArray(fixture.reporters)
? fixture.reporters.reduce(
(max, reporter) =>
Number.isFinite(reporter?.latest)
? Math.max(max, Math.floor(reporter.latest))
: max,
0,
)
: 0;
const reporters =
Array.isArray(sanitizedReporters) && sanitizedReporters
? sanitizedReporters
: normalizeFixtureReporters(getActiveReporters(fixture, videoId));
const latest = reporters.reduce((max, reporter) => Math.max(max, reporter.latest), 0);
return {
eventId: fixture.video.id,
totalTrusted: fixture.moderation.trustedCount,
totalTrusted: reporters.length,
types: {
[fixture.moderation.reportType]: {
trusted: fixture.moderation.trustedCount,
total: fixture.moderation.trustedCount,
trusted: reporters.length,
total: reporters.length,
latest,
},
},
@@ -192,28 +280,46 @@
function buildModerationState(fixture, overrides, videoId) {
const overrideEntry = overrides?.[videoId];
const activeReporters = getActiveReporters(fixture, videoId);
const sanitizedReporters = normalizeFixtureReporters(activeReporters);
const thresholds =
fixture.thresholds && typeof fixture.thresholds === "object"
? { blur: 3, autoplay: 2, ...fixture.thresholds }
: { blur: 3, autoplay: 2 };
const autoplayThreshold = Number.isFinite(thresholds.autoplay)
? Math.max(0, Math.floor(thresholds.autoplay))
: 2;
const blurThreshold = Number.isFinite(thresholds.blur)
? Math.max(0, Math.floor(thresholds.blur))
: 3;
const initialBlock = fixture.moderation.blockAutoplay === true;
const initialBlur = fixture.moderation.blurThumbnail === true;
const trustedCount = sanitizedReporters.length;
const reporterDisplayNames = sanitizedReporters
.map((reporter) => reporter.name)
.filter((value) => typeof value === "string" && value);
const reporterPubkeys = sanitizedReporters.map((reporter) => reporter.pubkey);
const trustedReporterEntries = sanitizedReporters.map((reporter) => ({
pubkey: reporter.pubkey,
latest: reporter.latest,
}));
const base = {
reportType: fixture.moderation.reportType,
trustedCount: fixture.moderation.trustedCount,
reporterDisplayNames: Array.isArray(fixture.reporters)
? fixture.reporters.map((reporter) => reporter.name)
: [],
reporterPubkeys: Array.isArray(fixture.reporters)
? fixture.reporters.map((reporter) => reporter.pubkey)
: [],
trustedReporters: Array.isArray(fixture.reporters)
? fixture.reporters.map((reporter) => ({
pubkey: reporter.pubkey,
latest: reporter.latest,
}))
: [],
trustedCount,
reporterDisplayNames,
reporterPubkeys,
trustedReporters: trustedReporterEntries,
original: {
blockAutoplay: fixture.moderation.blockAutoplay === true,
blurThumbnail: fixture.moderation.blurThumbnail === true,
blockAutoplay: initialBlock,
blurThumbnail: initialBlur,
},
};
const summary = buildSummary(fixture);
const summary = buildSummary(fixture, videoId, sanitizedReporters);
if (summary) {
base.summary = summary;
}
@@ -247,8 +353,8 @@
base.blockAutoplay = false;
base.blurThumbnail = false;
} else {
base.blockAutoplay = fixture.moderation.blockAutoplay === true;
base.blurThumbnail = fixture.moderation.blurThumbnail === true;
base.blockAutoplay = initialBlock && trustedCount >= autoplayThreshold;
base.blurThumbnail = initialBlur && trustedCount >= blurThreshold;
}
return base;
@@ -304,22 +410,24 @@
},
});
const refreshModeration = () => {
card.video.moderation = buildModerationState(fixture, overrides, video.id);
if (typeof card.refreshModerationUi === "function") {
card.refreshModerationUi();
}
};
if (fixture.enableOverride) {
card.onModerationOverride = () => {
overrides = setOverride(overrides, video.id, { showAnyway: true });
card.video.moderation = buildModerationState(
fixture,
overrides,
video.id,
);
card.refreshModerationUi();
refreshModeration();
return true;
};
}
const root = card.getRoot();
root.dataset.testId = fixture.key;
return { card, root };
return { card, root, refreshModeration };
}
function renderFixtures() {
@@ -343,8 +451,51 @@
section.appendChild(description);
}
const { root: cardRoot } = createCard(fixture, overrides);
const { root: cardRoot, refreshModeration } = createCard(fixture, overrides);
section.appendChild(cardRoot);
if (fixture.blockToggle && fixture.blockToggle.pubkey) {
const toggleWrapper = document.createElement("div");
toggleWrapper.style.marginTop = "0.75rem";
toggleWrapper.style.fontSize = "0.875rem";
toggleWrapper.style.color = "#cbd5f5";
const label = document.createElement("label");
label.style.display = "inline-flex";
label.style.alignItems = "center";
label.style.gap = "0.5rem";
const input = document.createElement("input");
input.type = "checkbox";
input.checked = isReporterBlocked(fixture.video.id, fixture.blockToggle.pubkey);
input.addEventListener("change", () => {
const checked = input.checked;
setReporterBlocked(fixture.video.id, fixture.blockToggle.pubkey, checked);
const action = checked ? "block" : "unblock";
try {
const detail = { action, targetPubkey: fixture.blockToggle.pubkey };
const maybePromise = moderationService?.queueUserBlockRefresh?.(detail);
if (maybePromise && typeof maybePromise.then === "function") {
maybePromise.catch(() => {});
}
} catch {
/* ignore fixture errors */
}
refreshModeration();
});
const text = document.createElement("span");
text.textContent =
typeof fixture.blockToggle.label === "string"
? fixture.blockToggle.label
: "Viewer blocks reporter";
label.appendChild(input);
label.appendChild(text);
toggleWrapper.appendChild(label);
section.appendChild(toggleWrapper);
}
root.appendChild(section);
});
}
+72
View File
@@ -0,0 +1,72 @@
import test from "node:test";
import assert from "node:assert/strict";
import "../test-helpers/setup-localstorage.mjs";
import {
withMockedNostrTools,
createModerationAppHarness,
} from "../helpers/moderation-test-helpers.mjs";
const REPORTER_HEX = "b".repeat(64);
const MUTER_HEX = "c".repeat(64);
function buildModerationState() {
return {
blockAutoplay: true,
blurThumbnail: true,
reportType: "nudity",
trustedCount: 2,
trustedReporters: [{ pubkey: REPORTER_HEX, latest: 1_700_000_000 }],
trustedMuted: true,
trustedMuters: [MUTER_HEX],
trustedMuteCount: 1,
};
}
test("handleModerationOverride decorates stored and current videos then refreshes UI", async (t) => {
withMockedNostrTools(t);
const hadNavigator = typeof globalThis.navigator !== "undefined";
if (!hadNavigator) {
globalThis.navigator = { userAgent: "node-test" };
t.after(() => {
delete globalThis.navigator;
});
}
const app = await createModerationAppHarness();
const originalDecorate = app.decorateVideoModeration;
const decoratedTargets = [];
app.decorateVideoModeration = function decorateSpy(video) {
decoratedTargets.push(video);
return originalDecorate.call(this, video);
};
const videoId = "a".repeat(64);
const incomingVideo = { id: videoId, moderation: buildModerationState() };
const storedVideo = { id: videoId, moderation: buildModerationState() };
const currentVideo = { id: videoId, moderation: buildModerationState() };
app.videosMap.set(videoId, storedVideo);
app.currentVideo = currentVideo;
let refreshCount = 0;
const card = {
refreshModerationUi() {
refreshCount += 1;
},
};
const result = app.handleModerationOverride({ video: incomingVideo, card });
assert.equal(result, true);
assert.equal(refreshCount, 1);
assert.deepEqual(decoratedTargets, [storedVideo, currentVideo]);
assert.equal(storedVideo.moderation.viewerOverride?.showAnyway, true);
assert.equal(storedVideo.moderation.blockAutoplay, false);
assert.equal(storedVideo.moderation.blurThumbnail, false);
assert.equal(currentVideo.moderation.viewerOverride?.showAnyway, true);
assert.equal(currentVideo.moderation.blockAutoplay, false);
assert.equal(currentVideo.moderation.blurThumbnail, false);
});
+119
View File
@@ -1,6 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { ModerationService } from "../js/services/moderationService.js";
import { USER_BLOCK_EVENTS } from "../js/userBlocks.js";
const nip19 = {
npubEncode(hex) {
@@ -101,6 +102,124 @@ test("trusted report summaries respect personal blocks and admin lists", async (
assert.equal(service.trustedReportCount(eventId, "nudity"), 2);
});
test("user block updates recompute summaries and emit notifications", async (t) => {
const blockedHex = "1".repeat(64);
const trustedHex = "2".repeat(64);
const randomHex = "3".repeat(64);
const eventId = "4".repeat(64);
class FakeUserBlocks {
constructor() {
this.blocked = new Set();
this.listeners = new Set();
}
async ensureLoaded() {}
isBlocked(pubkey) {
return this.blocked.has(pubkey);
}
on(eventName, handler) {
if (eventName !== USER_BLOCK_EVENTS.CHANGE || typeof handler !== "function") {
return () => {};
}
this.listeners.add(handler);
return () => {
this.listeners.delete(handler);
};
}
emitChange(detail) {
for (const handler of Array.from(this.listeners)) {
handler(detail);
}
}
}
const userBlocks = new FakeUserBlocks();
const service = new ModerationService({ logger: () => {}, userBlocks });
service.trustedContacts = new Set([blockedHex, trustedHex]);
const reports = new Map();
reports.set(
blockedHex,
new Map([["nudity", { created_at: 200 }]]),
);
reports.set(
trustedHex,
new Map([["nudity", { created_at: 150 }]]),
);
reports.set(
randomHex,
new Map([["nudity", { created_at: 100 }]]),
);
service.reportEvents.set(eventId, reports);
service.recomputeSummaryForEvent(eventId);
const initialSummary = service.getTrustedReportSummary(eventId);
assert.equal(initialSummary.totalTrusted, 2);
assert.equal(initialSummary.types.nudity.total, 3);
assert.equal(initialSummary.types.nudity.trusted, 2);
const initialReporters = service.getTrustedReporters(eventId, "nudity");
assert.deepEqual(
initialReporters.map((entry) => entry.pubkey),
[blockedHex, trustedHex],
);
const blockEvent = new Promise((resolve) => {
const unsubscribe = service.on("user-blocks", (detail) => {
unsubscribe?.();
resolve(detail);
});
});
userBlocks.blocked.add(blockedHex);
userBlocks.emitChange({ action: "block", targetPubkey: blockedHex });
const blockDetail = await blockEvent;
assert.equal(blockDetail.action, "block");
assert.equal(blockDetail.targetPubkey, blockedHex);
const afterBlockSummary = service.getTrustedReportSummary(eventId);
assert.equal(afterBlockSummary.totalTrusted, 1);
assert.equal(afterBlockSummary.types.nudity.total, 2);
assert.equal(afterBlockSummary.types.nudity.trusted, 1);
assert.equal(service.trustedReportCount(eventId, "nudity"), 1);
const reportersAfterBlock = service.getTrustedReporters(eventId, "nudity");
assert.deepEqual(reportersAfterBlock.map((entry) => entry.pubkey), [trustedHex]);
const unblockEvent = new Promise((resolve) => {
const unsubscribe = service.on("user-blocks", (detail) => {
unsubscribe?.();
resolve(detail);
});
});
userBlocks.blocked.delete(blockedHex);
userBlocks.emitChange({ action: "unblock", targetPubkey: blockedHex });
const unblockDetail = await unblockEvent;
assert.equal(unblockDetail.action, "unblock");
assert.equal(unblockDetail.targetPubkey, blockedHex);
const afterUnblockSummary = service.getTrustedReportSummary(eventId);
assert.equal(afterUnblockSummary.totalTrusted, 2);
assert.equal(afterUnblockSummary.types.nudity.total, 3);
assert.equal(afterUnblockSummary.types.nudity.trusted, 2);
assert.equal(service.trustedReportCount(eventId, "nudity"), 2);
const reportersAfterUnblock = service.getTrustedReporters(eventId, "nudity");
assert.deepEqual(
reportersAfterUnblock.map((entry) => entry.pubkey),
[blockedHex, trustedHex],
);
});
test("trusted mute aggregation tracks F1 mute lists", () => {
const service = new ModerationService({ logger: () => {} });
+109
View File
@@ -295,3 +295,112 @@ test("moderation stage propagates whitelist, muters, and threshold updates", asy
assert.equal(updatedMuted.video.moderation.blockAutoplay, false);
assert.equal(updatedMuted.video.moderation.blurThumbnail, false);
});
test("moderation stage clears cached reporters and muters after service signals", async () => {
const reporterHex = "d".repeat(64);
const muterHex = "e".repeat(64);
const authorHex = "f".repeat(64);
const videoId = "9".repeat(64);
const listeners = new Map();
const service = {
reporters: [{ pubkey: reporterHex, latest: 1_700_000_100 }],
muters: [muterHex],
async refreshViewerFromClient() {},
async setActiveEventIds() {},
getAdminListSnapshot() {
return { whitelist: new Set(), whitelistHex: new Set(), blacklist: new Set(), blacklistHex: new Set() };
},
getAccessControlStatus(identifier) {
return { hex: identifier, whitelisted: false, blacklisted: false };
},
getTrustedReportSummary(targetId) {
const trusted = this.reporters.length;
return {
eventId: targetId,
totalTrusted: trusted,
types: {
nudity: {
trusted,
total: trusted,
latest: this.reporters[0]?.latest ?? 0,
},
},
updatedAt: Date.now(),
};
},
trustedReportCount() {
return this.reporters.length;
},
getTrustedReporters() {
return this.reporters.slice();
},
isAuthorMutedByTrusted() {
return this.muters.length > 0;
},
getTrustedMutersForAuthor() {
return this.muters.slice();
},
on(eventName, handler) {
if (typeof handler !== "function") {
return () => {};
}
if (!listeners.has(eventName)) {
listeners.set(eventName, new Set());
}
const bucket = listeners.get(eventName);
bucket.add(handler);
return () => {
bucket.delete(handler);
if (!bucket.size) {
listeners.delete(eventName);
}
};
},
emit(eventName, detail) {
const bucket = listeners.get(eventName);
if (!bucket) {
return;
}
for (const handler of Array.from(bucket)) {
handler(detail);
}
},
};
const stage = createModerationStage({ service, reportType: "nudity" });
const context = {
addWhy() {},
log() {},
};
const items = [{ video: { id: videoId, pubkey: authorHex }, metadata: {} }];
const firstPass = await stage(items, context);
const firstVideo = firstPass[0].video;
assert.equal(firstVideo.moderation.trustedCount, 1);
assert.equal(firstVideo.moderation.trustedMuted, true);
assert.deepEqual(firstVideo.moderation.trustedMuters, [muterHex]);
assert.deepEqual(firstVideo.moderation.trustedReporters, [{ pubkey: reporterHex, latest: 1_700_000_100 }]);
service.reporters = [];
service.muters = [];
service.emit("user-blocks", { action: "block", targetPubkey: reporterHex });
service.emit("trusted-mutes", { total: 0 });
const secondPass = await stage(firstPass, context);
const updatedVideo = secondPass[0].video;
const updatedMetadata = secondPass[0].metadata.moderation;
assert.equal(updatedVideo.moderation.trustedCount, 0);
assert.equal(updatedVideo.moderation.trustedMuted, false);
assert.equal("trustedMuters" in updatedVideo.moderation, false);
assert.equal("trustedReporters" in updatedVideo.moderation, false);
assert.ok(Array.isArray(updatedMetadata.trustedReporters));
assert.equal(updatedMetadata.trustedReporters.length, 0);
assert.ok(Array.isArray(updatedMetadata.trustedMuters));
assert.equal(updatedMetadata.trustedMuters.length, 0);
});
+36
View File
@@ -36,6 +36,20 @@ async function setupModal({ lazyLoad = false } = {}) {
globalThis.location = window.location;
globalThis.KeyboardEvent = window.KeyboardEvent;
globalThis.MouseEvent = window.MouseEvent;
const hadWebSocket = typeof globalThis.WebSocket !== "undefined";
if (typeof window.scrollTo !== "function") {
window.scrollTo = () => {};
}
globalThis.scrollTo = window.scrollTo;
if (!hadWebSocket) {
globalThis.WebSocket = class {
constructor() {}
close() {}
addEventListener() {}
removeEventListener() {}
send() {}
};
}
applyDesignSystemAttributes(document);
@@ -119,6 +133,10 @@ async function setupModal({ lazyLoad = false } = {}) {
delete globalThis.location;
delete globalThis.KeyboardEvent;
delete globalThis.MouseEvent;
delete globalThis.scrollTo;
if (!hadWebSocket) {
delete globalThis.WebSocket;
}
if (restoreFetch) {
restoreFetch();
}
@@ -302,6 +320,20 @@ async function setupPlaybackHarness() {
globalThis.KeyboardEvent = window.KeyboardEvent;
globalThis.MouseEvent = window.MouseEvent;
globalThis.self = window;
const hadHarnessWebSocket = typeof globalThis.WebSocket !== "undefined";
if (typeof window.scrollTo !== "function") {
window.scrollTo = () => {};
}
globalThis.scrollTo = window.scrollTo;
if (!hadHarnessWebSocket) {
globalThis.WebSocket = class {
constructor() {}
close() {}
addEventListener() {}
removeEventListener() {}
send() {}
};
}
window.NostrTools = {
nip19: {
@@ -383,6 +415,10 @@ async function setupPlaybackHarness() {
delete globalThis.KeyboardEvent;
delete globalThis.MouseEvent;
delete globalThis.self;
delete globalThis.scrollTo;
if (!hadHarnessWebSocket) {
delete globalThis.WebSocket;
}
};
return { app, modalStub, playbackRecords, window, cleanup };