diff --git a/docs/moderation/fixtures/index.html b/docs/moderation/fixtures/index.html new file mode 100644 index 00000000..c5d151d5 --- /dev/null +++ b/docs/moderation/fixtures/index.html @@ -0,0 +1,317 @@ + + + + + Moderation Fixtures • bitvid + + + + + + +
+

Moderation Test Fixtures

+

+ These scenarios mirror the QA vectors in docs/moderation/testing.md so + automated checks can assert blur thresholds, autoplay blocking, and local + override persistence. +

+
+ + + diff --git a/docs/moderation/testing.md b/docs/moderation/testing.md index ebf42d84..baca76e6 100644 --- a/docs/moderation/testing.md +++ b/docs/moderation/testing.md @@ -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. diff --git a/tests/moderation/trusted-report-count.test.mjs b/tests/moderation/trusted-report-count.test.mjs new file mode 100644 index 00000000..6f068ce0 --- /dev/null +++ b/tests/moderation/trusted-report-count.test.mjs @@ -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]); +}); diff --git a/tests/visual/moderation.spec.ts b/tests/visual/moderation.spec.ts new file mode 100644 index 00000000..450f6ddc --- /dev/null +++ b/tests/visual/moderation.spec.ts @@ -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); + }); +});