mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-12 07:42:32 +00:00
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:
5 files changed
+526
-39
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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: () => {} });
|
||||
|
||||
|
||||
@@ -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,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 };
|
||||
|
||||
Reference in new issue
Block a user