Files
bitvid/tests/app/channel-profile-moderation.test.mjs
T

524 lines
17 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import "../test-helpers/setup-localstorage.mjs";
import {
renderChannelVideosFromList,
applyChannelVisualBlur,
__setChannelProfileTestState,
__ensureChannelModerationEventsForTests,
} from "../../js/channelProfile.js";
import { nostrClient } from "../../js/nostrClientFacade.js";
import moderationService from "../../js/services/moderationService.js";
import { setApplication } from "../../js/applicationContext.js";
import { normalizeVideoModerationContext } from "../../js/ui/moderationUiHelpers.js";
import { buildModerationBadgeText } from "../../js/ui/moderationCopy.js";
import { withMockedNostrTools, createModerationAppHarness } from "../helpers/moderation-test-helpers.mjs";
import { waitFor } from "../test-helpers/wait-for.mjs";
function setupDom(t) {
const dom = new JSDOM(
"<!DOCTYPE html><html><body>" +
'<div id="channelModerationBadge"></div>' +
'<div id="channelBanner"></div>' +
'<img id="channelAvatar" />' +
'<div id="channelVideoList"></div>' +
"</body></html>",
{ url: "https://example.invalid/channel" },
);
const { window } = dom;
const { document } = window;
const previousGlobals = {
window: globalThis.window,
document: globalThis.document,
HTMLElement: globalThis.HTMLElement,
CustomEvent: globalThis.CustomEvent,
Event: globalThis.Event,
Node: globalThis.Node,
location: globalThis.location,
MutationObserver: globalThis.MutationObserver,
IntersectionObserver: globalThis.IntersectionObserver,
};
globalThis.window = window;
globalThis.document = document;
globalThis.HTMLElement = window.HTMLElement;
globalThis.CustomEvent = window.CustomEvent;
globalThis.Event = window.Event;
globalThis.Node = window.Node;
Object.defineProperty(globalThis, "navigator", {
value: window.navigator,
writable: true,
configurable: true,
});
globalThis.location = window.location;
globalThis.MutationObserver = window.MutationObserver;
globalThis.IntersectionObserver =
window.IntersectionObserver ||
class {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
};
t.after(() => {
Object.entries(previousGlobals).forEach(([key, value]) => {
if (typeof value === "undefined") {
delete globalThis[key];
} else {
globalThis[key] = value;
}
});
});
return { document, window };
}
function createVideoFixture(overrides = {}) {
return {
id: overrides.id || "v".repeat(64),
title: overrides.title || "Fixture Video",
pubkey: overrides.pubkey || "a".repeat(64),
created_at: overrides.created_at || Math.floor(Date.now() / 1000),
url: overrides.url || "https://example.invalid/video.mp4",
moderation: overrides.moderation || {},
...overrides,
};
}
test("renderChannelVideosFromList decorates videos before storing", async (t) => {
const { document } = setupDom(t);
withMockedNostrTools(t);
nostrClient.allEvents = new Map();
nostrClient.rawEvents = new Set();
const app = await createModerationAppHarness();
app.loadedThumbnails = new Map();
app.videosMap = new Map();
app.buildShareUrlFromEventId = (id) => `https://example.invalid/watch/${id}`;
app.formatTimeAgo = () => "just now";
app.ensureGlobalMoreMenuHandlers = () => {};
app.closeAllMoreMenus = () => {};
app.normalizeHexPubkey = (value) =>
typeof value === "string" ? value.trim().toLowerCase() : "";
app.hasOlderVersion = () => false;
app.deriveVideoPointerInfo = () => null;
app.persistWatchHistoryMetadataForVideo = () => {};
app.canCurrentUserManageBlacklist = () => false;
app.isMagnetUriSupported = () => false;
app.mountVideoListView = () => {};
app.mediaLoader = { observe() {} };
app.attachMoreMenuHandlers = () => {};
app.decorateVideoModeration = (video) => video;
const originalDecorate = app.decorateVideoModeration;
let decoratedCount = 0;
app.decorateVideoModeration = function decorated(video) {
decoratedCount += 1;
if (!video.moderation || typeof video.moderation !== "object") {
video.moderation = {};
}
video.moderation.decoratedByTest = true;
return originalDecorate.call(this, video);
};
const container = document.getElementById("channelVideoList");
const video = createVideoFixture();
const rendered = await renderChannelVideosFromList({
videos: [video],
container,
app,
loadToken: 0,
});
assert.equal(rendered, true);
assert.equal(decoratedCount, 1);
const stored = app.videosMap.get(video.id);
assert.ok(stored, "decorated video stored in videosMap");
assert.equal(stored.moderation.decoratedByTest, true);
});
test("renderChannelVideosFromList applies moderation blur without existing metadata", async (t) => {
const { document } = setupDom(t);
withMockedNostrTools(t);
nostrClient.allEvents = new Map();
nostrClient.rawEvents = new Set();
const app = await createModerationAppHarness();
app.loadedThumbnails = new Map();
app.videosMap = new Map();
app.buildShareUrlFromEventId = (id) => `https://example.invalid/watch/${id}`;
app.formatTimeAgo = () => "just now";
app.ensureGlobalMoreMenuHandlers = () => {};
app.closeAllMoreMenus = () => {};
app.normalizeHexPubkey = (value) =>
typeof value === "string" ? value.trim().toLowerCase() : "";
app.hasOlderVersion = () => false;
app.deriveVideoPointerInfo = () => null;
app.persistWatchHistoryMetadataForVideo = () => {};
app.canCurrentUserManageBlacklist = () => false;
app.isMagnetUriSupported = () => false;
app.mountVideoListView = () => {};
app.mediaLoader = { observe() {} };
app.attachMoreMenuHandlers = () => {};
app.getActiveModerationThresholds = () => ({
blurThreshold: 1,
autoplayBlockThreshold: Number.POSITIVE_INFINITY,
trustedMuteHideThreshold: Number.POSITIVE_INFINITY,
trustedSpamHideThreshold: Number.POSITIVE_INFINITY,
});
const stubModerationService = {
async refreshViewerFromClient() {},
async setActiveEventIds() {},
getAdminListSnapshot() {
return null;
},
getAccessControlStatus() {
return { hex: "", whitelisted: false, blacklisted: false };
},
getTrustedReportSummary() {
return { types: { nudity: { trusted: 2 } } };
},
trustedReportCount() {
return 2;
},
getTrustedReporters() {
return [];
},
isAuthorMutedByTrusted() {
return false;
},
};
app.nostrService = {
getModerationService() {
return stubModerationService;
},
};
const container = document.getElementById("channelVideoList");
const channelPubkey = "e".repeat(64);
__setChannelProfileTestState({ pubkey: channelPubkey });
const video = createVideoFixture({ pubkey: channelPubkey });
delete video.moderation;
const rendered = await renderChannelVideosFromList({
videos: [video],
container,
app,
loadToken: 0,
});
assert.equal(rendered, true, "channel render succeeded with moderation stage");
const stored = app.videosMap.get(video.id);
assert.ok(stored, "video stored in app map after moderation");
assert.equal(stored.moderation?.blurThumbnail, true, "moderation blur applied");
const thumbnail = container.querySelector('[data-video-thumbnail="true"]');
assert.ok(thumbnail, "video card thumbnail rendered");
assert.equal(thumbnail.dataset.thumbnailState, "blurred");
const bannerEl = document.getElementById("channelBanner");
assert.equal(bannerEl.dataset.visualState, "blurred");
});
test("applyChannelVisualBlur blurs banner and avatar when viewer mutes author", async (t) => {
const { document } = setupDom(t);
withMockedNostrTools(t);
const bannerEl = document.getElementById("channelBanner");
const avatarEl = document.getElementById("channelAvatar");
const pubkey = "b".repeat(64);
const originalMute = moderationService.isAuthorMutedByViewer;
moderationService.isAuthorMutedByViewer = () => true;
t.after(() => {
moderationService.isAuthorMutedByViewer = originalMute;
});
applyChannelVisualBlur({
bannerEl,
avatarEl,
pubkey,
app: { videosMap: new Map() },
});
assert.equal(bannerEl.dataset.visualState, "blurred");
assert.equal(avatarEl.dataset.visualState, "blurred");
});
test("moderation override clears channel blur via event wiring", async (t) => {
const { document } = setupDom(t);
withMockedNostrTools(t);
nostrClient.allEvents = new Map();
nostrClient.rawEvents = new Set();
const app = await createModerationAppHarness();
app.loadedThumbnails = new Map();
app.videosMap = new Map();
app.buildShareUrlFromEventId = (id) => `https://example.invalid/watch/${id}`;
app.formatTimeAgo = () => "just now";
app.ensureGlobalMoreMenuHandlers = () => {};
app.closeAllMoreMenus = () => {};
app.normalizeHexPubkey = (value) =>
typeof value === "string" ? value.trim().toLowerCase() : "";
app.hasOlderVersion = () => false;
app.deriveVideoPointerInfo = () => null;
app.persistWatchHistoryMetadataForVideo = () => {};
app.canCurrentUserManageBlacklist = () => false;
app.isMagnetUriSupported = () => false;
app.mountVideoListView = () => {};
app.mediaLoader = { observe() {} };
app.attachMoreMenuHandlers = () => {};
app.getActiveModerationThresholds = () => ({
blurThreshold: 1,
autoplayBlockThreshold: 1,
trustedMuteHideThreshold: 1,
trustedSpamHideThreshold: Number.POSITIVE_INFINITY,
});
const channelPubkey = "c".repeat(64);
__setChannelProfileTestState({ pubkey: channelPubkey });
__ensureChannelModerationEventsForTests();
const container = document.getElementById("channelVideoList");
const moderationState = {
// BitVid's current default blur threshold is three trusted reports.
trustedCount: 3,
summary: {
types: { nudity: { trusted: 3 } },
},
};
const video = createVideoFixture({
id: "a".repeat(64),
pubkey: channelPubkey,
moderation: moderationState,
});
await renderChannelVideosFromList({
videos: [video],
container,
app,
loadToken: 0,
});
const bannerEl = document.getElementById("channelBanner");
const avatarEl = document.getElementById("channelAvatar");
assert.equal(bannerEl.dataset.visualState, "blurred");
assert.equal(avatarEl.dataset.visualState, "blurred");
const cardStub = { refreshModerationUi() {} };
app.handleModerationOverride({ video, card: cardStub });
assert.equal(bannerEl.dataset.visualState, undefined);
assert.equal(avatarEl.dataset.visualState, undefined);
});
test("channel header moderation badge reflects blur state and override actions", async (t) => {
const { document } = setupDom(t);
withMockedNostrTools(t);
nostrClient.allEvents = new Map();
nostrClient.rawEvents = new Set();
const app = await createModerationAppHarness();
app.loadedThumbnails = new Map();
app.videosMap = new Map();
app.buildShareUrlFromEventId = (id) => `https://example.invalid/watch/${id}`;
app.formatTimeAgo = () => "just now";
app.ensureGlobalMoreMenuHandlers = () => {};
app.closeAllMoreMenus = () => {};
app.normalizeHexPubkey = (value) =>
(typeof value === "string" ? value.trim().toLowerCase() : "");
app.hasOlderVersion = () => false;
app.deriveVideoPointerInfo = () => null;
app.persistWatchHistoryMetadataForVideo = () => {};
app.canCurrentUserManageBlacklist = () => false;
app.isMagnetUriSupported = () => false;
app.mountVideoListView = () => {};
app.mediaLoader = { observe() {} };
app.attachMoreMenuHandlers = () => {};
app.getActiveModerationThresholds = () => ({
blurThreshold: 1,
autoplayBlockThreshold: 1,
trustedMuteHideThreshold: 1,
trustedSpamHideThreshold: Number.POSITIVE_INFINITY,
});
let overrideCalls = 0;
app.handleModerationOverride = () => {
overrideCalls += 1;
return true;
};
setApplication(app);
t.after(() => {
setApplication(null);
});
const channelPubkey = "d".repeat(64);
__setChannelProfileTestState({ pubkey: channelPubkey });
__ensureChannelModerationEventsForTests();
const container = document.getElementById("channelVideoList");
const moderationState = {
blurThumbnail: true,
trustedCount: 2,
reportType: "nudity",
summary: { types: { nudity: { trusted: 2 } } },
original: { blurThumbnail: true, blockAutoplay: false },
};
const video = createVideoFixture({ pubkey: channelPubkey, moderation: moderationState });
await renderChannelVideosFromList({
videos: [video],
container,
app,
loadToken: 0,
});
applyChannelVisualBlur({ app, pubkey: channelPubkey });
const storedVideo = app.videosMap.get(video.id);
const bannerEl = document.getElementById("channelBanner");
const avatarEl = document.getElementById("channelAvatar");
assert.equal(bannerEl.dataset.visualState, "blurred");
assert.equal(avatarEl.dataset.visualState, "blurred");
const badgeContainer = document.getElementById("channelModerationBadge");
const badge = badgeContainer.querySelector('[data-moderation-badge="true"]');
assert.ok(badge, "badge renders in channel header");
const textEl = badge.querySelector(".moderation-badge__text");
const context = normalizeVideoModerationContext(storedVideo.moderation);
const expectedText = buildModerationBadgeText(context, { variant: "card" });
assert.equal(textEl.textContent, expectedText);
const overrideButton = badge.querySelector('[data-moderation-action="override"]');
assert.ok(overrideButton, "override control is available");
overrideButton.click();
await waitFor(() => {
assert.equal(overrideCalls, 1);
});
const updatedVideo = app.videosMap.get(video.id);
if (!updatedVideo.moderation || typeof updatedVideo.moderation !== "object") {
updatedVideo.moderation = {};
}
updatedVideo.moderation.viewerOverride = { showAnyway: true };
updatedVideo.moderation.blurThumbnail = false;
updatedVideo.moderation.blockAutoplay = false;
const originalState = updatedVideo.moderation.original && typeof updatedVideo.moderation.original === "object"
? { ...updatedVideo.moderation.original }
: {};
if (typeof originalState.blurThumbnail === "undefined") {
originalState.blurThumbnail = true;
}
if (typeof originalState.blockAutoplay === "undefined") {
originalState.blockAutoplay = true;
}
updatedVideo.moderation.original = originalState;
app.videosMap.set(updatedVideo.id, updatedVideo);
const overrideContext = normalizeVideoModerationContext(updatedVideo.moderation);
const overrideText = buildModerationBadgeText(overrideContext, { variant: "card" });
assert.equal(overrideContext.overrideActive, true);
assert.ok(overrideText.startsWith("Showing despite"));
});
test("channel video cards update moderation state in place when summary changes", async (t) => {
const { document } = setupDom(t);
withMockedNostrTools(t);
nostrClient.allEvents = new Map();
nostrClient.rawEvents = new Set();
const app = await createModerationAppHarness();
app.loadedThumbnails = new Map();
app.videosMap = new Map();
app.buildShareUrlFromEventId = (id) => `https://example.invalid/watch/${id}`;
app.formatTimeAgo = () => "just now";
app.ensureGlobalMoreMenuHandlers = () => {};
app.closeAllMoreMenus = () => {};
app.normalizeHexPubkey = (value) =>
typeof value === "string" ? value.trim().toLowerCase() : "";
app.hasOlderVersion = () => false;
app.deriveVideoPointerInfo = () => null;
app.persistWatchHistoryMetadataForVideo = () => {};
app.canCurrentUserManageBlacklist = () => false;
app.isMagnetUriSupported = () => false;
app.mountVideoListView = () => {};
app.mediaLoader = { observe() {} };
app.attachMoreMenuHandlers = () => {};
app.getActiveModerationThresholds = () => ({
blurThreshold: 1,
autoplayBlockThreshold: Number.POSITIVE_INFINITY,
trustedMuteHideThreshold: Number.POSITIVE_INFINITY,
trustedSpamHideThreshold: Number.POSITIVE_INFINITY,
});
setApplication(app);
t.after(() => setApplication(null));
const video = createVideoFixture();
__setChannelProfileTestState({ pubkey: video.pubkey });
__ensureChannelModerationEventsForTests();
const container = document.getElementById("channelVideoList");
await renderChannelVideosFromList({
videos: [video],
container,
app,
loadToken: 0,
});
const cardEl = container.querySelector('[data-component="video-card"]');
assert.ok(cardEl, "video card rendered for channel");
const thumbnailEl = cardEl.querySelector('[data-video-thumbnail="true"]');
assert.ok(thumbnailEl, "card thumbnail present");
assert.equal(
thumbnailEl.dataset.thumbnailState || "",
"",
"thumbnail should not be blurred initially",
);
const existing = app.videosMap.get(video.id) || video;
const updatedVideo = {
...existing,
moderation: {
...(existing?.moderation || {}),
summary: {
types: {
nudity: { trusted: 3 },
},
},
trustedCount: 3,
blurThumbnail: true,
reportType: "nudity",
},
};
app.videosMap.set(video.id, updatedVideo);
const priorCard = container.querySelector('[data-component="video-card"]');
moderationService.emit("summary", { eventId: video.id });
const currentCard = container.querySelector('[data-component="video-card"]');
assert.strictEqual(currentCard, priorCard, "card should not rerender");
assert.equal(thumbnailEl.dataset.thumbnailState, "blurred");
assert.equal(cardEl.dataset.moderationReportType, "nudity");
assert.equal(cardEl.dataset.moderationReportCount, "3");
});