Files
bitvid/tests/helpers/moderation-test-helpers.mjs
T

486 lines
13 KiB
JavaScript

import "../test-helpers/setup-localstorage.mjs";
import { ModerationService } from "../../js/services/moderationService.js";
import ModerationDecorator from "../../js/services/moderationDecorator.js";
import ModerationActionController from "../../js/services/moderationActionController.js";
import {
getDefaultModerationSettings,
setModerationOverride,
clearModerationOverride,
clearAuthorModerationOverride,
} from "../../js/state/cache.js";
import { userBlocks } from "../../js/userBlocks.js";
import { HEX64_REGEX } from "../../js/utils/hex.js";
const DEFAULT_CONTACT_OWNER = "f".repeat(64);
const DEFAULT_REPORT_TARGET = "e".repeat(64);
export 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;
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) };
},
};
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;
}
});
}
export function createModerationServiceHarness(t, { userBlocks, accessControl } = {}) {
const service = new ModerationService({
logger: () => {},
userBlocks:
userBlocks || {
async ensureLoaded() {},
isBlocked() {
return false;
},
},
accessControl: accessControl || null,
});
const tracked = new Map();
function capture(eventName) {
if (!tracked.has(eventName)) {
tracked.set(eventName, []);
}
const records = tracked.get(eventName);
const unsubscribe = service.on(eventName, (detail) => {
records.push(detail);
});
if (typeof unsubscribe === "function") {
t.after(() => {
try {
unsubscribe();
} catch {
// noop for tests
}
});
}
return records;
}
return { service, capture, trackedEvents: tracked };
}
export function createReportEvent({
id,
reporter,
eventId,
targetPubkey = DEFAULT_REPORT_TARGET,
relayHint = "",
createdAt = Math.floor(Date.now() / 1000),
type = "nudity",
} = {}) {
const reportId =
typeof id === "string" && id.length
? id
: `${Math.random().toString(16).slice(2)}${"0".repeat(64)}`.slice(0, 64);
const reporterPubkey = typeof reporter === "string" && reporter ? reporter : "";
const targetEventId = typeof eventId === "string" && eventId ? eventId : "";
const targetAuthor =
typeof targetPubkey === "string" && targetPubkey
? targetPubkey
: DEFAULT_REPORT_TARGET;
const normalizedType = typeof type === "string" && type ? type : "nudity";
const normalizedRelayHint =
typeof relayHint === "string" && relayHint ? relayHint : "";
const eventTag = ["e", targetEventId];
if (normalizedRelayHint) {
eventTag.push(normalizedRelayHint);
}
eventTag.push(normalizedType);
return {
kind: 1984,
id: reportId,
pubkey: reporterPubkey,
created_at: Number.isFinite(createdAt) ? Math.floor(createdAt) : Math.floor(Date.now() / 1000),
tags: [
eventTag,
["p", targetAuthor, normalizedType],
["t", normalizedType],
],
content: "fixture report",
};
}
export function createContactEvent({
owner = DEFAULT_CONTACT_OWNER,
contacts = [],
createdAt = Math.floor(Date.now() / 1000),
id,
} = {}) {
const eventId =
typeof id === "string" && id.length
? id
: `${Math.random().toString(16).slice(2)}${"1".repeat(64)}`.slice(0, 64);
const ownerHex = typeof owner === "string" && owner ? owner : DEFAULT_CONTACT_OWNER;
const tags = contacts
.filter((value) => typeof value === "string" && value)
.map((value) => ["p", value]);
return {
kind: 3,
id: eventId,
pubkey: ownerHex,
created_at: Number.isFinite(createdAt) ? Math.floor(createdAt) : Math.floor(Date.now() / 1000),
tags,
content: "",
};
}
export function applyTrustedContacts(service, contacts = []) {
const event = createContactEvent({ contacts });
service.applyContactEvent(event);
return event;
}
class MockApplication {
constructor() {
this.playbackService = {
_currentVideo: null,
get currentVideo() {
return this._currentVideo;
},
set currentVideo(value) {
this._currentVideo = value;
},
};
this.authService = {
_pubkey: null,
_currentUserNpub: null,
_activeProfilePubkey: null,
_savedProfiles: [],
get pubkey() {
return this._pubkey;
},
set pubkey(value) {
this._pubkey = value;
},
get currentUserNpub() {
return this._currentUserNpub;
},
set currentUserNpub(value) {
this._currentUserNpub = value;
},
get activeProfilePubkey() {
return this._activeProfilePubkey;
},
setActiveProfilePubkey(value) {
this._activeProfilePubkey = value;
},
get savedProfiles() {
return this._savedProfiles;
},
setSavedProfiles(value) {
this._savedProfiles = value;
},
};
Object.defineProperty(this, "__profiles", {
value: new Map(),
writable: true,
configurable: true,
});
this.defaultModerationSettings = getDefaultModerationSettings();
this.moderationSettings = { ...this.defaultModerationSettings };
this.videosMap = new Map();
this.currentVideo = null;
this.pendingModeratedPlayback = null;
}
get pubkey() {
return this.authService.pubkey;
}
set pubkey(value) {
this.authService.pubkey = value;
}
isUserLoggedIn() {
return !!this.pubkey;
}
getProfileCacheEntry(pubkey) {
if (!pubkey || typeof pubkey !== "string") {
return null;
}
return this.__profiles.get(pubkey) || null;
}
safeDecodeNpub(npub) {
if (typeof npub !== "string") {
return null;
}
const trimmed = npub.trim();
if (!trimmed) {
return null;
}
try {
const decoded = globalThis.NostrTools.nip19.decode(trimmed);
if (decoded.type === "npub" && typeof decoded.data === "string") {
return decoded.data;
}
} catch (err) {
return null;
}
return null;
}
normalizeHexPubkey(pubkey) {
if (typeof pubkey !== "string") {
return null;
}
const trimmed = pubkey.trim();
if (!trimmed) {
return null;
}
if (HEX64_REGEX.test(trimmed)) {
return trimmed.toLowerCase();
}
if (trimmed.startsWith("npub1")) {
const decoded = this.safeDecodeNpub(trimmed);
if (decoded && HEX64_REGEX.test(decoded)) {
return decoded.toLowerCase();
}
}
return null;
}
decorateVideoModeration(video, feedContext = {}) {
if (!this.moderationDecorator) return video;
const decorated = this.moderationDecorator.decorateVideo(video, feedContext);
if (
video &&
video.pubkey &&
this.isAuthorBlocked(video.pubkey) &&
decorated &&
decorated.moderation
) {
decorated.moderation.viewerMuted = true;
decorated.moderation.hidden = true;
decorated.moderation.hideReason = "viewer-block";
}
return decorated;
}
isAuthorBlocked(pubkey) {
const normalized = this.normalizeHexPubkey(pubkey);
if (normalized && this.userBlocks && this.userBlocks.isBlocked(normalized)) {
return true;
}
return false;
}
resumePendingModeratedPlayback(video) {
if (this.pendingModeratedPlayback) {
const { url, magnet } = this.pendingModeratedPlayback;
this.pendingModeratedPlayback = null;
if (typeof this.playVideoWithFallback === "function") {
this.playVideoWithFallback({ url, magnet });
}
}
}
describeUserBlockActionError(error) {
return error?.message || "Block action failed";
}
deriveModerationReportType(summary) {
if (!this.moderationDecorator) return null;
return this.moderationDecorator.deriveModerationReportType(summary);
}
deriveModerationTrustedCount(summary, reportType) {
if (!this.moderationDecorator) return 0;
return this.moderationDecorator.deriveModerationTrustedCount(summary, reportType);
}
getReporterDisplayName(pubkey) {
if (!this.moderationDecorator) return "";
return this.moderationDecorator.getReporterDisplayName(pubkey);
}
handleModerationOverride(payload) {
if (!this.moderationActionController) return false;
return this.moderationActionController.handleOverride(payload);
}
handleModerationBlock(payload) {
if (!this.moderationActionController) return false;
return this.moderationActionController.handleBlock(payload);
}
handleModerationHide(payload) {
if (!this.moderationActionController) return false;
return this.moderationActionController.handleHide(payload);
}
handleModerationSettingsChange({ settings, skipRefresh }) {
if (!this.moderationDecorator) return;
const normalized = this.normalizeModerationSettings(settings);
this.moderationSettings = normalized;
this.moderationDecorator.updateSettings(normalized);
if (this.videosMap instanceof Map) {
for (const video of this.videosMap.values()) {
if (video && typeof video === "object") {
this.decorateVideoModeration(video);
}
}
}
if (
this.videoListView &&
Array.isArray(this.videoListView.videoCardInstances)
) {
for (const card of this.videoListView.videoCardInstances) {
if (card?.video) {
this.decorateVideoModeration(card.video);
if (typeof card.refreshModerationUi === "function") {
card.refreshModerationUi();
}
}
}
}
if (this.videoListView && Array.isArray(this.videoListView.currentVideos)) {
for (const video of this.videoListView.currentVideos) {
this.decorateVideoModeration(video);
}
}
if (this.currentVideo) {
this.decorateVideoModeration(this.currentVideo);
}
if (!skipRefresh) {
this.onVideosShouldRefresh({ reason: "moderation-settings-change" });
}
return normalized;
}
normalizeModerationSettings(settings) {
if (!this.moderationDecorator) return settings;
return this.moderationDecorator.normalizeModerationSettings(settings);
}
getActiveModerationThresholds() {
if (!this.moderationSettings || typeof this.moderationSettings !== "object") {
this.moderationSettings = this.normalizeModerationSettings(this.moderationSettings);
}
return this.moderationSettings;
}
onVideosShouldRefresh(payload) {
// noop
}
showStatus(message, options) {}
showError(message) {}
refreshCardModerationUi(card, options) {
if (card && typeof card.refreshModerationUi === "function") {
card.refreshModerationUi();
}
}
dispatchModerationEvent(eventName, detail) {
if (typeof document !== "undefined") {
document.dispatchEvent(new CustomEvent(eventName, { detail }));
return true;
}
return false;
}
}
export async function createModerationAppHarness(options = {}) {
const app = new MockApplication();
app.userBlocks = options.userBlocks || userBlocks;
app.moderationDecorator = new ModerationDecorator({
getProfileCacheEntry: (pubkey) => app.getProfileCacheEntry(pubkey),
});
app.moderationActionController = new ModerationActionController({
services: {
userBlocks: app.userBlocks,
setModerationOverride,
clearModerationOverride,
clearAuthorModerationOverride,
},
auth: {
isLoggedIn: () => app.isUserLoggedIn(),
getViewerPubkey: () => app.pubkey,
normalizePubkey: (value) => app.normalizeHexPubkey(value),
},
actions: {
refreshVideos: (payload) => app.onVideosShouldRefresh(payload),
showStatus: (message, options) => app.showStatus(message, options),
showError: (message) => app.showError(message),
decorateVideoModeration: (video) => app.decorateVideoModeration(video),
resumePlayback: (video) => app.resumePendingModeratedPlayback(video),
describeBlockError: (error) => app.describeUserBlockActionError(error),
},
selectors: {
getVideoById: (id) => (app.videosMap ? app.videosMap.get(id) : null),
getCurrentVideo: () => app.currentVideo,
},
ui: {
refreshCardModerationUi: (card, options) =>
app.refreshCardModerationUi(card, options),
dispatchModerationEvent: (eventName, detail) =>
app.dispatchModerationEvent(eventName, detail),
},
});
return app;
}