Add automated moderation coverage

This commit is contained in:
thePR0M3TH3AN committed 2025-10-17 18:07:08 -04:00
1 parent 84c2b647e7
commit 157fa86a8f
4 files changed
+582 -2

No files matched your search

+317
View File
@@ -0,0 +1,317 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Moderation Fixtures • bitvid</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/css/tailwind.generated.css" />
<style>
body {
background-color: #0f172a;
color: #f8fafc;
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main {
max-width: 960px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 1.5rem;
}
section {
margin-bottom: 2.5rem;
}
section h2 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
section p {
color: #cbd5f5;
margin-bottom: 1rem;
}
</style>
</head>
<body data-ds="new">
<main id="moderationFixtureRoot">
<h1>Moderation Test Fixtures</h1>
<p>
These scenarios mirror the QA vectors in <code>docs/moderation/testing.md</code> so
automated checks can assert blur thresholds, autoplay blocking, and local
override persistence.
</p>
</main>
<script type="module">
import { VideoCard } from "/js/ui/components/VideoCard.js";
const STORAGE_KEY = "bitvid:moderation:fixture-overrides";
const fixtures = [
{
key: "blur-threshold",
heading: "Blur threshold (≥3 trusted nudity reports)",
description:
"Card should be blurred and autoplay disabled until the viewer overrides it.",
video: {
id: "1".repeat(64),
title: "Fixture: Blurred Preview",
pubkey: "a".repeat(64),
thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg",
},
moderation: {
blockAutoplay: true,
blurThumbnail: true,
trustedCount: 3,
reportType: "nudity",
},
reporters: [
{ name: "Jess F1", pubkey: "b".repeat(64), latest: 1_700_000_010 },
{ name: "Mira F1", pubkey: "c".repeat(64), latest: 1_700_000_020 },
{ name: "Omar F1", pubkey: "d".repeat(64), latest: 1_700_000_030 },
],
},
{
key: "autoplay-threshold",
heading: "Autoplay threshold (≥2 trusted nudity reports)",
description:
"Card should block autoplay but stay unblurred so users can inspect the thumbnail.",
video: {
id: "2".repeat(64),
title: "Fixture: Autoplay Blocked",
pubkey: "e".repeat(64),
thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg",
},
moderation: {
blockAutoplay: true,
blurThumbnail: false,
trustedCount: 2,
reportType: "nudity",
},
reporters: [
{ name: "Jess F1", pubkey: "b".repeat(64), latest: 1_700_000_010 },
{ name: "Mira F1", pubkey: "c".repeat(64), latest: 1_700_000_020 },
],
},
{
key: "show-anyway",
heading: "\"Show anyway\" override (persists locally)",
description:
"Card starts blurred and blocked; activating \"Show anyway\" should persist across reloads.",
enableOverride: true,
video: {
id: "3".repeat(64),
title: "Fixture: Override Persistence",
pubkey: "f".repeat(64),
thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg",
},
moderation: {
blockAutoplay: true,
blurThumbnail: true,
trustedCount: 3,
reportType: "nudity",
},
reporters: [
{ name: "Jess F1", pubkey: "b".repeat(64), latest: 1_700_000_010 },
{ name: "Mira F1", pubkey: "c".repeat(64), latest: 1_700_000_020 },
{ name: "Omar F1", pubkey: "d".repeat(64), latest: 1_700_000_030 },
],
},
];
window.__MODERATION_FIXTURE_STORAGE_KEY__ = STORAGE_KEY;
function loadOverrides() {
try {
const raw = window.localStorage?.getItem(STORAGE_KEY);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function persistOverrides(next) {
try {
window.localStorage?.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
/* ignore persistence issues in test fixtures */
}
}
function buildSummary(fixture) {
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;
return {
eventId: fixture.video.id,
totalTrusted: fixture.moderation.trustedCount,
types: {
[fixture.moderation.reportType]: {
trusted: fixture.moderation.trustedCount,
total: fixture.moderation.trustedCount,
latest,
},
},
updatedAt: Date.now(),
};
}
function buildModerationState(fixture, overrides, videoId) {
const overrideEntry = overrides?.[videoId];
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,
}))
: [],
original: {
blockAutoplay: fixture.moderation.blockAutoplay === true,
blurThumbnail: fixture.moderation.blurThumbnail === true,
},
};
const summary = buildSummary(fixture);
if (summary) {
base.summary = summary;
}
if (overrideEntry?.showAnyway) {
base.viewerOverride = {
showAnyway: true,
updatedAt: Number.isFinite(overrideEntry.updatedAt)
? Math.floor(overrideEntry.updatedAt)
: Date.now(),
};
base.blockAutoplay = false;
base.blurThumbnail = false;
} else {
base.blockAutoplay = fixture.moderation.blockAutoplay === true;
base.blurThumbnail = fixture.moderation.blurThumbnail === true;
}
return base;
}
function setOverride(overrides, videoId, entry) {
const next = { ...overrides };
next[videoId] = {
...entry,
updatedAt: Number.isFinite(entry?.updatedAt)
? Math.floor(entry.updatedAt)
: Date.now(),
};
persistOverrides(next);
return next;
}
function createCard(fixture, overrides) {
const video = {
...fixture.video,
};
video.moderation = buildModerationState(fixture, overrides, video.id);
const card = new VideoCard({
document,
video,
shareUrl: "#",
formatters: {
formatTimeAgo: () => "just now",
formatNumber: (value) => {
if (typeof value === "number") {
return value.toLocaleString("en-US");
}
return String(value ?? "");
},
},
helpers: {
isMagnetSupported: () => true,
escapeHtml: (value) => String(value ?? ""),
toLocaleString: (value, locales, options) => {
if (value && typeof value.toLocaleString === "function") {
return value.toLocaleString(locales || "en-US", options);
}
return String(value ?? "");
},
},
assets: {
fallbackThumbnailSrc: "/assets/jpg/video-thumbnail-fallback.jpg",
},
designSystem: {
getMode: () => "new",
isNew: () => true,
},
});
if (fixture.enableOverride) {
card.onModerationOverride = () => {
overrides = setOverride(overrides, video.id, { showAnyway: true });
card.video.moderation = buildModerationState(
fixture,
overrides,
video.id,
);
card.refreshModerationUi();
return true;
};
}
const root = card.getRoot();
root.dataset.testId = fixture.key;
return { card, root };
}
function renderFixtures() {
const root = document.getElementById("moderationFixtureRoot");
if (!root) {
return;
}
let overrides = loadOverrides();
fixtures.forEach((fixture) => {
const section = document.createElement("section");
const heading = document.createElement("h2");
heading.textContent = fixture.heading;
section.appendChild(heading);
if (fixture.description) {
const description = document.createElement("p");
description.textContent = fixture.description;
section.appendChild(description);
}
const { root: cardRoot } = createCard(fixture, overrides);
section.appendChild(cardRoot);
root.appendChild(section);
});
}
renderFixtures();
</script>
</body>
</html>
+11 -2
View File
@@ -2,15 +2,23 @@
## Quick checklist
- [ ] F1 set cached and used for “trusted reports”.
- Automated coverage: `tests/moderation/trusted-report-count.test.mjs` (run via `npm run test:unit`).
- [ ] Blur at ≥3 F1 reports of `nudity`; autoplay blocked at ≥2.
- Automated coverage: `tests/visual/moderation.spec.ts` blur and autoplay scenarios (run via `npm run test:visual`).
- [ ] “Show anyway” works and records only local pref.
- Automated coverage: `tests/visual/moderation.spec.ts` show-anyway persistence scenario (run via `npm run test:visual`).
- [ ] Mute list (10000) downranks/hides consistently.
- Manual: subscribe to fixture B and verify muted author disappears from feeds after refresh.
- [ ] Admin lists (30000) take effect only when subscribed.
- Manual: load fixture C, toggle subscription, and confirm the hard-hide only applies while subscribed.
- [ ] COUNT fallback shows placeholders and doesn’t crash UI.
- Manual: point the client at a relay without COUNT support and confirm placeholders render instead of errors.
- [ ] Comment threads render with NIP-10; moderation badges show in context.
- Manual: open a reported thread and confirm badges + reply ordering remain intact.
- [ ] Reports from muted reporters are ignored.
- Automated coverage: `tests/moderation/trusted-report-count.test.mjs` (muted reporter case).
## Fixtures (create on test relays)
## Fixtures (create on test relays / served in `/docs/moderation/fixtures/`)
### A) NIP-56 report events
Create three distinct F1 reporter keys; each sends:
@@ -66,6 +74,7 @@ Expected: when viewer subscribes to this list, author is hard-hidden.
## Automated checks (suggested)
* Unit test `trustedReportCount`.
* `npm run test:unit` — covers `tests/moderation/trusted-report-count.test.mjs` for deduping and mute handling.
* `npm run test:visual` — runs Playwright fixtures in `tests/visual/moderation.spec.ts` against `/docs/moderation/fixtures/index.html`.
* Integration test: emit events over a local relay; assert UI state.
* Regression test: ensure Home feed ignores reputation gating.
@@ -0,0 +1,154 @@
import test from "node:test";
import assert from "node:assert/strict";
import { ModerationService } from "../../js/services/moderationService.js";
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",
};
}
test("trustedReportCount dedupes multiple reports from the same trusted reporter", (t) => {
withMockedNostrTools(t);
const eventId = "f".repeat(64);
const reporterHex = "a".repeat(64);
const service = new ModerationService({
logger: () => {},
userBlocks: {
async ensureLoaded() {},
isBlocked() {
return false;
},
},
});
service.trustedContacts = new Set([reporterHex]);
const firstReport = createReport({
id: "1".repeat(64),
reporter: reporterHex,
eventId,
createdAt: 1_700_000_000,
});
const duplicateReport = createReport({
id: "2".repeat(64),
reporter: reporterHex,
eventId,
createdAt: 1_700_000_500,
});
service.ingestReportEvent(firstReport);
service.ingestReportEvent(duplicateReport);
const summary = service.getTrustedReportSummary(eventId);
assert.equal(summary.types.nudity.total, 1);
assert.equal(summary.types.nudity.trusted, 1);
assert.equal(service.trustedReportCount(eventId, "nudity"), 1);
});
test("trustedReportCount ignores reports from muted or blocked reporters", (t) => {
withMockedNostrTools(t);
const eventId = "e".repeat(64);
const mutedReporter = "b".repeat(64);
const trustedReporter = "c".repeat(64);
const service = new ModerationService({
logger: () => {},
userBlocks: {
async ensureLoaded() {},
isBlocked(pubkey) {
return pubkey === mutedReporter;
},
},
});
service.trustedContacts = new Set([mutedReporter, trustedReporter]);
const mutedReport = createReport({
id: "3".repeat(64),
reporter: mutedReporter,
eventId,
createdAt: 1_700_000_100,
});
const trustedReport = createReport({
id: "4".repeat(64),
reporter: trustedReporter,
eventId,
createdAt: 1_700_000_200,
});
service.ingestReportEvent(mutedReport);
service.ingestReportEvent(trustedReport);
const summary = service.getTrustedReportSummary(eventId);
assert.equal(summary.types.nudity.total, 1);
assert.equal(summary.types.nudity.trusted, 1);
assert.equal(service.trustedReportCount(eventId, "nudity"), 1);
const reporters = service.getTrustedReporters(eventId, "nudity");
assert.deepEqual(reporters.map((entry) => entry.pubkey), [trustedReporter]);
});
+100
View File
@@ -0,0 +1,100 @@
import { expect, test } from "@playwright/test";
const FIXTURE_URL = "/docs/moderation/fixtures/index.html";
const STORAGE_KEY = "bitvid:moderation:fixture-overrides";
const SENTINEL_KEY = "__moderationFixturesInit__";
function setupStorageReset(page) {
return page.addInitScript(({ storageKey, sentinelKey }) => {
try {
const session = window.sessionStorage;
const local = window.localStorage;
if (session && !session.getItem(sentinelKey)) {
local?.removeItem(storageKey);
session.setItem(sentinelKey, "1");
}
} catch {
/* ignore reset failures in tests */
}
}, { storageKey: STORAGE_KEY, sentinelKey: SENTINEL_KEY });
}
test.beforeEach(async ({ page }) => {
await setupStorageReset(page);
});
test.afterEach(async ({ page }) => {
try {
await page.evaluate(({ key, sentinel }) => {
window.localStorage?.removeItem(key);
window.sessionStorage?.removeItem(sentinel);
}, { key: STORAGE_KEY, sentinel: SENTINEL_KEY });
} catch {
// ignore cleanup errors when page already closed
}
});
test.describe("moderation fixtures", () => {
test("blur fixture masks thumbnails and blocks autoplay", async ({ page }) => {
await page.goto(FIXTURE_URL, { waitUntil: "networkidle" });
const blurCard = page.locator('[data-test-id="blur-threshold"]');
const thumbnail = blurCard.locator('img[data-video-thumbnail]');
await expect(blurCard).toHaveAttribute("data-moderation-report-count", "3");
await expect(blurCard).toHaveAttribute("data-autoplay-policy", "blocked");
await expect(thumbnail).toHaveAttribute("data-thumbnail-state", "blurred");
await expect(blurCard).not.toHaveAttribute(
"data-moderation-override",
"show-anyway"
);
});
test("autoplay fixture disables preview without blurring", async ({ page }) => {
await page.goto(FIXTURE_URL, { waitUntil: "networkidle" });
const autoplayCard = page.locator('[data-test-id="autoplay-threshold"]');
const thumbnail = autoplayCard.locator('img[data-video-thumbnail]');
await expect(autoplayCard).toHaveAttribute("data-moderation-report-count", "2");
await expect(autoplayCard).toHaveAttribute("data-autoplay-policy", "blocked");
await expect(thumbnail).not.toHaveAttribute("data-thumbnail-state", "blurred");
});
test("show anyway override persists across reloads", async ({ page }) => {
await page.goto(FIXTURE_URL, { waitUntil: "networkidle" });
const overrideCard = page.locator('[data-test-id="show-anyway"]');
const thumbnail = overrideCard.locator('img[data-video-thumbnail]');
const showAnywayButton = overrideCard.getByRole("button", { name: "Show anyway" });
await expect(overrideCard).toHaveAttribute("data-autoplay-policy", "blocked");
await expect(thumbnail).toHaveAttribute("data-thumbnail-state", "blurred");
await expect(showAnywayButton).toBeVisible();
await showAnywayButton.click();
await expect(overrideCard).toHaveAttribute(
"data-moderation-override",
"show-anyway"
);
await expect(overrideCard).not.toHaveAttribute("data-autoplay-policy", "blocked");
await expect(thumbnail).not.toHaveAttribute("data-thumbnail-state", "blurred");
await expect(overrideCard.getByRole("button", { name: "Show anyway" })).toHaveCount(0);
await page.reload({ waitUntil: "networkidle" });
const reloadedCard = page.locator('[data-test-id="show-anyway"]');
const reloadedThumbnail = reloadedCard.locator('img[data-video-thumbnail]');
await expect(reloadedCard).toHaveAttribute(
"data-moderation-override",
"show-anyway"
);
await expect(reloadedCard).not.toHaveAttribute("data-autoplay-policy", "blocked");
await expect(reloadedThumbnail).not.toHaveAttribute("data-thumbnail-state", "blurred");
await expect(
reloadedCard.getByRole("button", { name: "Show anyway" })
).toHaveCount(0);
});
});