Files
bitvid/tests/admin-event-blacklist.test.mjs
T
thePR0M3TH3ANandClaude Opus 4.8 3e57ab2ea9 [moderation] #25: per-video admin block list (hide a single video, not the author)
Admin moderation was author/community-level only. Admins (any editor — same gate as
the author blacklist) can now hide a SINGLE offending video.

- Record: a published kind-30000 NIP-51 list under d-tag bitvid:admin:event-blacklist,
  carrying blocked event ids as `e` tags (NOTE_TYPES.ADMIN_EVENT_BLACKLIST schema).
  Loaded/persisted through the existing admin-list machinery (adminListStore) and cached
  like whitelist/blacklist.
- accessControl: eventBlacklist Set + getEventBlacklist/isEventBlacklisted/
  addToEventBlacklist/removeFromEventBlacklist (editor-gated; normalizes hex or
  nevent/note) + onEventBlacklistChange.
- Render filter already existed (app.blacklistedEventIds, honored by every feed/grid/
  search/playback path) but was fed only by the static ADMIN_INITIAL_EVENT_BLACKLIST
  config. app._rebuildBlacklistedEventIds() now merges that static base with the dynamic
  admin list, and an onEventBlacklistChange listener rebuilds + refreshes grids on change.
- UI: a "Block this video" item in the per-video ⋯ menu (editor-only), handled by
  handleBlacklistEventAction (js/ui/moreMenu/blacklistEventAction.js).

Helpers extracted to js/adminEventBlacklistHelpers.js to keep adminListStore /
accessControl / moreMenuController under their size caps. Tests:
tests/admin-event-blacklist.test.mjs (9). Build + lint clean; admin/access/moderation
suites 56/56.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 15:19:34 -04:00

150 lines
4.9 KiB
JavaScript

// #25 per-event (per-video) admin block list: an admin can hide a SINGLE video without
// blocking its author. The published list is a kind-30000 NIP-51 event carrying the
// blocked event ids as `e` tags. These tests cover the pure record helpers and the
// ⋯-menu action handler (editor-gated add + grid refresh).
import test, { afterEach } from "node:test";
import { strict as assert } from "node:assert";
import {
sanitizeEventIdList,
extractEventIdsFromEvent,
normalizeEventId,
buildEventBlacklistEvent,
} from "../js/adminEventBlacklistHelpers.js";
import { handleBlacklistEventAction } from "../js/ui/moreMenu/blacklistEventAction.js";
const HEX_A = "a".repeat(64);
const HEX_B = "b".repeat(64);
afterEach(() => {
delete globalThis.window;
delete globalThis.NostrTools;
});
// --- record helpers ---
test("sanitizeEventIdList keeps valid hex ids, lowercased + deduped", () => {
assert.deepEqual(
sanitizeEventIdList([HEX_A.toUpperCase(), HEX_A, HEX_B, "nope", "", 123]),
[HEX_A, HEX_B],
);
});
test("extractEventIdsFromEvent reads `e` tags only", () => {
const event = {
tags: [
["d", "bitvid:admin:event-blacklist"],
["e", HEX_A],
["p", HEX_B], // a pubkey tag must NOT be treated as a blocked event
["e", HEX_B],
],
};
assert.deepEqual(extractEventIdsFromEvent(event), [HEX_A, HEX_B]);
});
test("normalizeEventId accepts hex and decodes nevent/note pointers", () => {
assert.equal(normalizeEventId(HEX_A.toUpperCase()), HEX_A);
assert.equal(normalizeEventId("not-an-id"), "");
globalThis.NostrTools = {
nip19: {
decode: (v) =>
v === "nevent1xyz"
? { type: "nevent", data: { id: HEX_B } }
: { type: "note", data: HEX_A },
},
};
assert.equal(normalizeEventId("nevent1xyz"), HEX_B);
assert.equal(normalizeEventId("note1abc"), HEX_A);
});
test("buildEventBlacklistEvent builds a kind-30000 list with the event d-tag + `e` tags", () => {
const event = buildEventBlacklistEvent(HEX_A, [HEX_A, HEX_B, "junk"]);
assert.equal(event.kind, 30000);
const dTag = event.tags.find((t) => t[0] === "d");
assert.equal(dTag[1], "bitvid:admin:event-blacklist");
const eTags = event.tags.filter((t) => t[0] === "e").map((t) => t[1]);
assert.deepEqual(eTags, [HEX_A, HEX_B]); // junk dropped
assert.ok(!event.tags.some((t) => t[0] === "p"), "no pubkey tags");
});
// --- ⋯-menu action handler ---
function makeDeps({ canEdit = true, npub = "npub1mod", addResult = { ok: true } } = {}) {
const calls = { added: [], success: [], error: [], refresh: 0 };
const accessControl = {
ensureReady: async () => {},
canEditAdminLists: () => canEdit,
addToEventBlacklist: async (actor, id) => {
calls.added.push({ actor, id });
return addResult;
},
};
const callbacks = {
getCurrentUserNpub: () => npub,
showError: (m) => calls.error.push(m),
showSuccess: (m) => calls.success.push(m),
refreshAllVideoGrids: async () => {
calls.refresh += 1;
},
};
return { accessControl, callbacks, calls };
}
test("blocks the video's event id (editor) and refreshes the grids", async () => {
const { accessControl, callbacks, calls } = makeDeps();
await handleBlacklistEventAction({
accessControl,
callbacks,
dataset: { eventId: HEX_A },
currentVideo: { id: HEX_B },
});
assert.deepEqual(calls.added, [{ actor: "npub1mod", id: HEX_A }], "uses dataset eventId");
assert.equal(calls.success.length, 1);
assert.equal(calls.refresh, 1);
assert.equal(calls.error.length, 0);
});
test("falls back to the current video's id when the dataset has none", async () => {
const { accessControl, callbacks, calls } = makeDeps();
await handleBlacklistEventAction({
accessControl,
callbacks,
dataset: {},
currentVideo: { id: HEX_B },
});
assert.deepEqual(calls.added, [{ actor: "npub1mod", id: HEX_B }]);
});
test("a non-editor is refused and nothing is published", async () => {
const { accessControl, callbacks, calls } = makeDeps({ canEdit: false });
await handleBlacklistEventAction({
accessControl,
callbacks,
dataset: { eventId: HEX_A },
});
assert.equal(calls.added.length, 0);
assert.equal(calls.refresh, 0);
assert.match(calls.error[0], /moderators/i);
});
test("a logged-out user is prompted to login, no publish", async () => {
const { accessControl, callbacks, calls } = makeDeps({ npub: null });
await handleBlacklistEventAction({ accessControl, callbacks, dataset: { eventId: HEX_A } });
assert.equal(calls.added.length, 0);
assert.match(calls.error[0], /login/i);
});
test("a publish failure surfaces an error and does not claim success", async () => {
const { accessControl, callbacks, calls } = makeDeps({
addResult: { ok: false, error: "storage-error" },
});
await handleBlacklistEventAction({
accessControl,
callbacks,
dataset: { eventId: HEX_A },
});
assert.equal(calls.success.length, 0);
assert.equal(calls.refresh, 0);
assert.equal(calls.error.length, 1);
});