This commit is contained in:
thePR0M3TH3AN committed 2025-10-26 21:50:09 -04:00
1 parent 281a6511f1
commit 42edebcf6d
45 files changed
+8446 -1010

No files matched your search

+170
View File
@@ -0,0 +1,170 @@
import assert from "node:assert/strict";
import test from "node:test";
import { decryptDM } from "../js/dmDecryptor.js";
test("decryptDM handles kind 4 events with nip04 ciphertext", async () => {
const nostrTools = await import("nostr-tools");
const { generateSecretKey, getPublicKey, nip04 } = nostrTools;
const senderSecret = generateSecretKey();
const recipientSecret = generateSecretKey();
const senderPubkey = getPublicKey(senderSecret);
const recipientPubkey = getPublicKey(recipientSecret);
const message = "Hello from kind 4";
const ciphertext = await nip04.encrypt(senderSecret, recipientPubkey, message);
const event = {
id: "kind4-event",
kind: 4,
created_at: Math.floor(Date.now() / 1000),
pubkey: senderPubkey,
tags: [["p", recipientPubkey]],
content: ciphertext,
};
const context = {
actorPubkey: recipientPubkey,
decryptors: [
{
scheme: "nip04",
priority: 0,
decrypt: (remotePubkey, cipher) =>
nip04.decrypt(recipientSecret, remotePubkey, cipher),
},
],
};
const result = await decryptDM(event, context);
assert.equal(result.ok, true, "decryptDM should succeed for kind 4 event");
assert.equal(result.plaintext, message, "plaintext should match original message");
assert.equal(result.scheme, "nip04", "scheme should resolve to nip04");
assert.equal(result.sender.pubkey, senderPubkey, "sender pubkey should match event");
assert.equal(result.recipients.length, 1, "recipient metadata should be populated");
assert.equal(result.recipients[0].pubkey, recipientPubkey);
assert.equal(result.direction, "incoming", "direction should be incoming for recipient");
});
test("decryptDM prefers recipient pubkeys when actor is the sender", async () => {
const nostrTools = await import("nostr-tools");
const { generateSecretKey, getPublicKey, nip04 } = nostrTools;
const senderSecret = generateSecretKey();
const recipientSecret = generateSecretKey();
const senderPubkey = getPublicKey(senderSecret);
const recipientPubkey = getPublicKey(recipientSecret);
const message = "Hello from the author";
const ciphertext = await nip04.encrypt(senderSecret, recipientPubkey, message);
const event = {
id: "outgoing-kind4",
kind: 4,
created_at: Math.floor(Date.now() / 1000),
pubkey: senderPubkey,
tags: [["p", recipientPubkey]],
content: ciphertext,
};
const context = {
actorPubkey: senderPubkey,
decryptors: [
{
scheme: "nip04",
priority: 0,
decrypt: (remotePubkey, cipher) =>
nip04.decrypt(senderSecret, remotePubkey, cipher),
},
],
};
const result = await decryptDM(event, context);
assert.equal(result.ok, true, "decryptDM should succeed for outgoing event");
assert.equal(result.plaintext, message, "plaintext should match outgoing message");
assert.equal(result.recipients.length, 1, "recipient metadata should remain intact");
assert.equal(result.recipients[0].pubkey, recipientPubkey);
assert.equal(result.direction, "outgoing", "direction should be outgoing for the author");
});
test("decryptDM unwraps kind 1059 gift wraps with nip44", async () => {
const nostrTools = await import("nostr-tools");
const { generateSecretKey, getPublicKey, nip44, nip59 } = nostrTools;
const senderSecret = generateSecretKey();
const recipientSecret = generateSecretKey();
const senderPubkey = getPublicKey(senderSecret);
const recipientPubkey = getPublicKey(recipientSecret);
const baseEvent = {
kind: 14,
content: "Gift wrapped hello",
tags: [["p", recipientPubkey]],
created_at: Math.floor(Date.now() / 1000),
};
const wrap = nip59.wrapEvent(baseEvent, senderSecret, recipientPubkey);
const nip44Module = nip44.v2 ?? nip44;
const getConversationKey =
typeof nip44Module?.getConversationKey === "function"
? nip44Module.getConversationKey.bind(nip44Module)
: typeof nip44Module?.utils?.getConversationKey === "function"
? nip44Module.utils.getConversationKey.bind(nip44Module.utils)
: null;
const decryptFn =
typeof nip44Module?.decrypt === "function"
? nip44Module.decrypt.bind(nip44Module)
: typeof nip44?.v2?.decrypt === "function"
? nip44.v2.decrypt.bind(nip44.v2)
: null;
assert.ok(getConversationKey && decryptFn, "nip44 helpers should be available for tests");
const context = {
actorPubkey: recipientPubkey,
decryptors: [
{
scheme: "nip44_v2",
priority: 0,
supportsGiftWrap: true,
decrypt: (remotePubkey, cipher) => {
const conversationKey = getConversationKey(recipientSecret, remotePubkey);
return decryptFn(cipher, conversationKey);
},
},
],
};
const result = await decryptDM(wrap, context);
assert.equal(result.ok, true, "decryptDM should succeed for gift wrap event");
assert.equal(
result.plaintext,
baseEvent.content,
"plaintext should match unwrap payload",
);
assert.equal(result.sender.pubkey, senderPubkey, "sender should match rumor pubkey");
assert.equal(result.scheme, "nip44_v2", "scheme should report nip44 variant");
assert.equal(result.direction, "incoming", "direction should be incoming for recipient");
assert.ok(result.envelope && result.envelope.seal, "seal metadata should be provided");
assert.equal(
result.recipients[0].pubkey,
recipientPubkey,
"recipient metadata should reflect unwrap target",
);
});
test("decryptDM returns failure when decryptors are unavailable", async () => {
const event = { id: "unhandled", kind: 4, content: "", pubkey: "" };
const result = await decryptDM(event, { decryptors: [] });
assert.equal(result.ok, false, "decryptDM should report failure");
assert.ok(Array.isArray(result.errors), "failure payload should include errors");
});
+70
View File
@@ -496,6 +496,76 @@ test("moderation stage propagates whitelist, muters, and threshold updates", asy
assert.equal(updatedMuted.video.moderation.blurReason, undefined);
});
test("moderation stage blurs viewer-muted authors", async () => {
const mutedHex = "f".repeat(64);
const service = {
async refreshViewerFromClient() {},
async setActiveEventIds() {},
getAdminListSnapshot() {
return { whitelist: new Set(), whitelistHex: new Set(), blacklist: new Set(), blacklistHex: new Set() };
},
getAccessControlStatus(identifier) {
return { hex: identifier, whitelisted: false, blacklisted: false };
},
getTrustedReportSummary() {
return null;
},
trustedReportCount() {
return 0;
},
getTrustedReporters() {
return [];
},
isAuthorMutedByTrusted() {
return false;
},
isAuthorMutedByViewer(pubkey) {
return pubkey === mutedHex;
},
};
const stage = createModerationStage({
service,
autoplayThreshold: Number.POSITIVE_INFINITY,
blurThreshold: Number.POSITIVE_INFINITY,
trustedMuteHideThreshold: Number.POSITIVE_INFINITY,
trustedReportHideThreshold: Number.POSITIVE_INFINITY,
});
const items = [{ video: { id: "viewer-muted", pubkey: mutedHex }, metadata: {} }];
const reasons = [];
const context = {
addWhy(detail) {
reasons.push(detail);
return detail;
},
log() {},
};
const result = await stage(items, context);
assert.equal(result.length, 1);
const entry = result[0];
assert.equal(entry.video.moderation.viewerMuted, true);
assert.equal(entry.metadata.moderation.viewerMuted, true);
assert.equal(entry.video.moderation.blockAutoplay, true);
assert.equal(entry.video.moderation.blurThumbnail, true);
assert.equal(entry.video.moderation.blurReason, "viewer-mute");
assert.equal(entry.metadata.moderation.blurReason, "viewer-mute");
assert.equal(entry.video.moderation.hidden, false);
assert.equal(entry.metadata.moderation.hidden, false);
assert.equal(entry.video.moderation.hideReason, undefined);
assert.equal(entry.metadata.moderation.hideReason, undefined);
assert.equal(entry.metadata.moderation.hideCounts, undefined);
const reasonSet = new Set(reasons.map((detail) => detail.reason));
assert(reasonSet.has("blur"));
assert(reasonSet.has("viewer-mute"));
});
test("moderation stage clears cached reporters and muters after service signals", async () => {
const reporterHex = "d".repeat(64);
const muterHex = "e".repeat(64);
+52
View File
@@ -271,6 +271,58 @@ test(
},
);
test("VideoCard blurs viewer-muted creators", async (t) => {
const { document } = setupDom(t);
withMockedNostrTools(t);
const app = await createModerationAppHarness();
const videoId = "d".repeat(64);
const video = {
id: videoId,
title: "Muted Creator Clip",
pubkey: "e".repeat(64),
moderation: {
viewerMuted: true,
blockAutoplay: true,
blurThumbnail: true,
blurReason: "viewer-mute",
trustedCount: 0,
trustedMuted: false,
trustedReporters: [],
},
};
app.videosMap.set(video.id, video);
app.currentVideo = video;
app.decorateVideoModeration(video);
const card = new VideoCard({
document,
video,
formatters: {
formatTimeAgo: () => "moments ago",
},
helpers: {
isMagnetSupported: () => false,
},
});
document.body.appendChild(card.getRoot());
assert.equal(card.getRoot().dataset.autoplayPolicy, "blocked");
assert.equal(card.thumbnailEl.dataset.thumbnailState, "blurred");
const context = normalizeVideoModerationContext(card.video?.moderation);
assert.equal(context.blurReason, "viewer-mute");
assert.equal(context.activeBlur, true);
assert.equal(context.activeBlockAutoplay, true);
assert.equal(context.trustedCount, 0);
assert.equal(card.getRoot().dataset.moderationReportCount, undefined);
assert.ok(card.moderationBadgeEl);
assert.equal(card.moderationBadgeEl.dataset.moderationState, "blocked");
});
test("VideoCard blurs thumbnails when trusted mute triggers without reports", async (t) => {
const { document } = setupDom(t);
withMockedNostrTools(t);
+163
View File
@@ -51,6 +51,8 @@ try {
const { nostrClient, setActiveSigner, clearActiveSigner } = await import("../js/nostr.js");
const { subscriptions } = await import("../js/subscriptions.js");
const { userBlocks } = await import("../js/userBlocks.js");
const { Application } = await import("../js/app.js");
const { isDevMode } = await import("../js/config.js");
setActiveSigner({
type: "session",
@@ -283,9 +285,170 @@ try {
}
}
async function testPublishVideoNoteDefaultsToLiveModeInDev() {
assert.equal(isDevMode, true, "Test expects isDevMode to be true");
let capturedPayload = null;
let capturedPubkey = null;
const fakeApp = {
pubkey: "f".repeat(64),
nostrService: {
publishVideoNote: async (payload, pubkey) => {
capturedPayload = payload;
capturedPubkey = pubkey;
},
},
showError(message) {
throw new Error(`showError invoked: ${message}`);
},
showSuccess() {},
showStatus() {},
loadVideos: async () => {},
uploadModal: null,
};
const publishResult = await Application.prototype.publishVideoNote.call(
fakeApp,
{
legacyFormData: {
title: "Dev Mode Default",
url: "https://cdn.example/dev.mp4",
},
},
);
assert.equal(publishResult, true, "publishVideoNote should resolve truthy");
assert.equal(
capturedPubkey,
fakeApp.pubkey,
"publishVideoNote should forward the viewer pubkey",
);
assert.equal(
capturedPayload?.legacyFormData?.mode,
"live",
"publishVideoNote should default mode to live even in dev mode",
);
}
async function testPublishVideoNoteRespectsProvidedMode() {
let capturedPayload = null;
const fakeApp = {
pubkey: "f".repeat(64),
nostrService: {
publishVideoNote: async (payload) => {
capturedPayload = payload;
},
},
showError(message) {
throw new Error(`showError invoked: ${message}`);
},
showSuccess() {},
showStatus() {},
loadVideos: async () => {},
uploadModal: null,
};
await Application.prototype.publishVideoNote.call(
fakeApp,
{
legacyFormData: {
title: "Dev Mode Override",
url: "https://cdn.example/dev.mp4",
mode: "dev",
},
},
);
assert.equal(
capturedPayload?.legacyFormData?.mode,
"dev",
"publishVideoNote should respect an explicit dev mode value",
);
}
async function testPublishVideoNormalizesPubkeyForNip71Metadata() {
const uppercasePubkey = "AB".repeat(32);
const originalSignAndPublish = nostrClient.signAndPublishEvent;
const originalPublishNip71 = nostrClient.publishNip71Video;
const publishedEvents = [];
const nip71Calls = [];
nostrClient.signAndPublishEvent = async (event, options = {}) => {
publishedEvents.push({ event, options });
const contextLabel =
typeof options?.context === "string" && options.context
? options.context
: "event";
return {
signedEvent: {
...event,
id: event?.id || `signed-${contextLabel}`,
},
};
};
nostrClient.publishNip71Video = async (payload, pubkey, pointerOptions) => {
nip71Calls.push({ payload, pubkey, pointerOptions });
return null;
};
try {
const result = await nostrClient.publishVideo(
{
legacyFormData: {
title: "Metadata Video",
description: "Video with metadata",
url: "https://cdn.example/video.mp4",
mode: "live",
},
nip71: {
title: "Metadata Video",
imeta: [{ url: "https://cdn.example/video.mp4", m: "video/mp4" }],
},
},
uppercasePubkey,
);
assert.equal(typeof result?.id, "string", "publishVideo should resolve with a signed event");
assert.ok(
publishedEvents.some((entry) => entry.options?.context === "video note"),
"publishVideo should sign the primary video note",
);
assert.equal(
nip71Calls.length,
1,
"publishVideo should invoke publishNip71Video when metadata is provided",
);
assert.equal(
nip71Calls[0]?.pubkey,
uppercasePubkey.toLowerCase(),
"publishVideo should normalize the pubkey passed to publishNip71Video",
);
assert.ok(
typeof nip71Calls[0]?.pointerOptions?.videoRootId === "string" &&
nip71Calls[0]?.pointerOptions?.videoRootId.length > 0,
"publishVideo should supply pointer metadata for the new video",
);
assert.ok(
typeof nip71Calls[0]?.pointerOptions?.dTag === "string" &&
nip71Calls[0]?.pointerOptions?.dTag.length > 0,
"publishVideo should provide the new d tag as a pointer",
);
} finally {
nostrClient.signAndPublishEvent = originalSignAndPublish;
nostrClient.publishNip71Video = originalPublishNip71;
}
}
await testPublishVideoRejectsWhenAllRelaysFail();
await testPublishSubscriptionListRejectsWhenAllRelaysFail();
await testPublishBlockListRejectsWhenAllRelaysFail();
await testPublishVideoNoteDefaultsToLiveModeInDev();
await testPublishVideoNoteRespectsProvidedMode();
await testPublishVideoNormalizesPubkeyForNip71Metadata();
console.log("nostr publish rejection tests passed");
} finally {
+160
View File
@@ -0,0 +1,160 @@
import assert from "node:assert/strict";
import test from "node:test";
import "./test-helpers/setup-localstorage.mjs";
import { AccessControl } from "../js/accessControl.js";
import { NostrService } from "../js/services/nostrService.js";
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
const whitelistHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const whitelistNpub = "npub1whitelistcandidate";
const blacklistHex = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
const blacklistNpub = "npub1blacklistcandidate";
const toolkit = {
nip19: {
decode(value) {
if (typeof value !== "string") {
throw new Error("invalid value");
}
const trimmed = value.trim();
if (trimmed === whitelistNpub) {
return { type: "npub", data: whitelistHex };
}
if (trimmed === blacklistNpub) {
return { type: "npub", data: blacklistHex };
}
throw new Error("unsupported npub");
},
npubEncode(value) {
if (value === whitelistHex) {
return whitelistNpub;
}
if (value === blacklistHex) {
return blacklistNpub;
}
throw new Error("unsupported hex");
},
},
};
const originalWindowNostrTools = globalThis.window.NostrTools;
const originalGlobalNostrTools = globalThis.NostrTools;
globalThis.window.NostrTools = toolkit;
globalThis.NostrTools = toolkit;
test.after(() => {
if (originalWindowNostrTools === undefined) {
delete globalThis.window.NostrTools;
} else {
globalThis.window.NostrTools = originalWindowNostrTools;
}
if (originalGlobalNostrTools === undefined) {
delete globalThis.NostrTools;
} else {
globalThis.NostrTools = originalGlobalNostrTools;
}
});
function createServiceWithAccessControl() {
const service = new NostrService();
const accessControl = new AccessControl();
accessControl._hydrateFromCache = () => {};
accessControl._scheduleHydrateFromCache = () => {};
accessControl.whitelist = new Set([whitelistNpub]);
accessControl.whitelistPubkeys = new Set([whitelistHex]);
accessControl.blacklist = new Set([blacklistNpub]);
accessControl.blacklistPubkeys = new Set([blacklistHex]);
accessControl.whitelistEnabled = true;
service.accessControl = accessControl;
return service;
}
test("shouldIncludeVideo returns true for whitelist author when npubEncode throws", () => {
const service = createServiceWithAccessControl();
const originalEncode = globalThis.window.NostrTools.nip19.npubEncode;
globalThis.window.NostrTools.nip19.npubEncode = () => {
throw new Error("encode failure");
};
try {
const video = {
id: "video-whitelist",
kind: 30078,
pubkey: whitelistHex,
};
assert.equal(service.shouldIncludeVideo(video), true);
} finally {
globalThis.window.NostrTools.nip19.npubEncode = originalEncode;
}
});
test("shouldIncludeVideo rejects blacklisted authors provided as npub", () => {
const service = createServiceWithAccessControl();
const video = {
id: "video-blacklist-npub",
kind: 30078,
pubkey: blacklistHex,
npub: blacklistNpub,
};
assert.equal(service.shouldIncludeVideo(video), false);
});
test("shouldIncludeVideo rejects blacklisted authors provided as hex when npubEncode throws", () => {
const service = createServiceWithAccessControl();
const originalEncode = globalThis.window.NostrTools.nip19.npubEncode;
globalThis.window.NostrTools.nip19.npubEncode = () => {
throw new Error("encode failure");
};
try {
const video = {
id: "video-blacklist-hex",
kind: 30078,
pubkey: blacklistHex,
};
assert.equal(service.shouldIncludeVideo(video), false);
} finally {
globalThis.window.NostrTools.nip19.npubEncode = originalEncode;
}
});
test("shouldIncludeVideo always returns true for the viewer's own video", () => {
const service = createServiceWithAccessControl();
const video = {
id: "viewer-owned-video",
kind: 30078,
pubkey: whitelistHex,
};
service.nostrClient = { pubkey: whitelistHex };
const blacklistedEventIds = new Set([video.id]);
assert.equal(
service.shouldIncludeVideo(video, { blacklistedEventIds }),
true,
);
});
test("shouldIncludeVideo allows access when access control would deny the author", () => {
const service = new NostrService();
service.accessControl = { canAccess: () => false };
service.nostrClient = { pubkey: whitelistHex };
const video = {
id: "access-control-denied-video",
kind: 30078,
pubkey: whitelistHex,
};
assert.equal(service.shouldIncludeVideo(video), true);
});
+87
View File
@@ -29,6 +29,7 @@ const {
getCachedNostrTools,
ensureNostrTools,
resolveSimplePoolConstructor,
shimLegacySimplePoolMethods,
} = toolkitModule;
const baselineToolkit = getCachedNostrTools() || bootstrapToolkit;
@@ -165,3 +166,89 @@ test("resolveSimplePoolConstructor handles supported permutations", () => {
"returns null when no candidates are available"
);
});
test("shimLegacySimplePoolMethods adds legacy sub/list wrappers", async () => {
let capturedRequests = null;
let capturedParams = null;
const closeReasons = [];
const mockPool = {
subscribeMap(requests, params) {
capturedRequests = requests;
capturedParams = params;
return {
close(reason) {
closeReasons.push(reason);
},
};
},
};
shimLegacySimplePoolMethods(mockPool);
assert.strictEqual(typeof mockPool.sub, "function");
assert.strictEqual(typeof mockPool.list, "function");
const relays = ["wss://relay.one", "wss://relay.two"];
const filters = [{ kinds: [1] }, { authors: ["abc"] }];
const sub = mockPool.sub(relays, filters, {
alreadyHaveEvent(id) {
return id === "skip";
},
});
assert.ok(Array.isArray(capturedRequests));
assert.strictEqual(capturedRequests.length, relays.length * filters.length);
assert.ok(capturedRequests.every((entry) => entry && entry.url && entry.filter));
assert.ok(capturedParams);
assert.strictEqual(typeof capturedParams.onevent, "function");
assert.strictEqual(typeof capturedParams.oneose, "function");
assert.strictEqual(typeof capturedParams.onclose, "function");
assert.strictEqual(typeof capturedParams.alreadyHaveEvent, "function");
const receivedEvents = [];
const eosePromise = new Promise((resolve) => sub.on("eose", resolve));
const closePromise = new Promise((resolve) => sub.on("close", resolve));
sub.on("event", (event) => {
receivedEvents.push(event.id);
});
assert.strictEqual(capturedParams.alreadyHaveEvent("skip"), true);
assert.strictEqual(capturedParams.alreadyHaveEvent("id-1"), false);
assert.strictEqual(capturedParams.alreadyHaveEvent("id-1"), true);
capturedParams.onevent({ id: "id-1" });
capturedParams.onevent({ id: "id-2" });
capturedParams.oneose();
capturedParams.onclose(["done"]);
await eosePromise;
const closePayload = await closePromise;
assert.deepStrictEqual(receivedEvents, ["id-1", "id-2"]);
assert.deepStrictEqual(closePayload, ["done"]);
sub.unsub();
closeReasons.length = 0;
const immediateUnsub = mockPool.sub(["wss://relay.extra"], [{ kinds: [2] }]);
immediateUnsub.unsub();
assert.ok(closeReasons.includes("closed by caller"));
capturedParams = null;
capturedRequests = null;
closeReasons.length = 0;
const listPromise = mockPool.list(["wss://relay.example"], [{ kinds: [1] }]);
assert.ok(capturedParams);
capturedParams.onevent({ id: "evt-1" });
capturedParams.onevent({ id: "evt-2" });
capturedParams.oneose();
const listed = await listPromise;
assert.deepStrictEqual(
listed.map((event) => event.id),
["evt-1", "evt-2"]
);
});
+22
View File
@@ -540,6 +540,28 @@ test(
},
);
test("ensureLoaded memoizes concurrent loads", async () => {
const SubscriptionsManager = subscriptions.constructor;
const manager = new SubscriptionsManager();
let loadCount = 0;
manager.loadSubscriptions = async () => {
loadCount += 1;
await new Promise((resolve) => setTimeout(resolve, 0));
manager.loaded = true;
};
const actorHex = "f".repeat(64);
await Promise.all([
manager.ensureLoaded(actorHex),
manager.ensureLoaded(actorHex),
]);
assert.equal(loadCount, 1, "loadSubscriptions should only run once");
assert.equal(manager.loaded, true, "manager should be marked as loaded");
});
test(
"publishSubscriptionList succeeds with direct signer without requesting extension permissions",
async () => {
+79
View File
@@ -0,0 +1,79 @@
import test from "node:test";
import assert from "node:assert/strict";
import { setupModal } from "./video-modal-accessibility.test.mjs";
test("CommentsController lifecycle manages comment references", async (t) => {
const { modal, cleanup } = await setupModal();
t.after(cleanup);
assert.ok(modal.commentsRoot, "comments should be initialized after hydrate");
modal.commentsController.destroy();
assert.equal(modal.commentsRoot, null, "destroy should clear comment root");
modal.commentsController.initialize({ playerModal: modal.getRoot() });
assert.ok(modal.commentsRoot, "initialize should restore comment root");
});
test("ReactionsController delegates reaction updates", async (t) => {
const { modal, cleanup } = await setupModal();
t.after(cleanup);
modal.reactionsController.update({ type: "set-user-reaction", reaction: "+" });
assert.equal(
modal.reactionState.userReaction,
"+",
"user reaction should be updated via controller",
);
modal.reactionsController.destroy();
assert.equal(
modal.reactionButtons["+"],
null,
"destroy should clear reaction button references",
);
});
test("SimilarContentController toggles section visibility", async (t) => {
const { modal, cleanup } = await setupModal();
t.after(cleanup);
const container = modal.similarContentContainer;
assert.ok(container, "similar content container should exist");
modal.similarContentController.update({ type: "set-visibility", visible: false });
assert.equal(
container.hasAttribute("hidden"),
true,
"controller should hide similar content when requested",
);
modal.similarContentController.update({ type: "set-visibility", visible: true });
assert.equal(
container.hasAttribute("hidden"),
false,
"controller should reveal similar content",
);
});
test("ModerationController reset clears moderation overlay references", async (t) => {
const { modal, cleanup } = await setupModal();
t.after(cleanup);
assert.ok(modal.moderationOverlay, "moderation overlay should be present");
modal.moderationController.destroy();
assert.equal(
modal.moderationOverlay,
null,
"destroy should clear moderation overlay references",
);
modal.moderationController.initialize({ playerModal: modal.getRoot() });
assert.ok(
modal.moderationOverlay,
"initialize should restore moderation overlay references",
);
});
+190
View File
@@ -0,0 +1,190 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
getVideoNoteErrorMessage,
normalizeVideoNotePayload,
VIDEO_NOTE_ERROR_CODES,
} from "../js/services/videoNotePayload.js";
test("normalizes minimal payload with hosted URL", () => {
const { payload, errors } = normalizeVideoNotePayload({
title: " Example Title ",
url: " https://cdn.example.com/video.mp4 ",
});
assert.deepEqual(errors, []);
assert.ok(payload, "Expected payload result");
assert.equal(payload.legacyFormData.version, 3);
assert.equal(payload.legacyFormData.title, "Example Title");
assert.equal(
payload.legacyFormData.url,
"https://cdn.example.com/video.mp4",
);
assert.equal(payload.legacyFormData.magnet, "");
assert.equal(payload.legacyFormData.ws, "");
assert.equal(payload.legacyFormData.xs, "");
assert.equal(payload.legacyFormData.mode, "live");
assert.equal(payload.legacyFormData.enableComments, true);
assert.equal(payload.legacyFormData.isNsfw, false);
assert.equal(payload.legacyFormData.isForKids, false);
});
test("normalizes mode and boolean flags", () => {
const { payload, errors } = normalizeVideoNotePayload({
title: "Flag Demo",
url: "https://videos.example.com/watch.mp4",
mode: "DEV",
enableComments: false,
isNsfw: true,
isForKids: true,
isPrivate: true,
});
assert.deepEqual(errors, []);
assert.equal(payload.legacyFormData.mode, "dev");
assert.equal(payload.legacyFormData.enableComments, false);
assert.equal(payload.legacyFormData.isNsfw, true);
assert.equal(payload.legacyFormData.isForKids, false);
assert.equal(payload.legacyFormData.isPrivate, true);
});
test("augments magnets with ws/xs hints", () => {
const { payload, errors } = normalizeVideoNotePayload({
title: "Magnet Upload",
magnet: "magnet:?xt=urn:btih:abcdef0123456789abcdef0123456789abcdef01",
ws: "https://cdn.example.com/video.mp4",
xs: "https://cdn.example.com/video.torrent",
});
assert.deepEqual(errors, []);
const { magnet, ws, xs } = payload.legacyFormData;
assert.ok(magnet.startsWith("magnet:?xt=urn:btih:"));
assert.ok(
magnet.includes("ws=https%3A%2F%2Fcdn.example.com%2Fvideo.mp4") ||
magnet.includes("ws=https://cdn.example.com/video.mp4"),
"Normalized magnet should include ws hint",
);
assert.ok(
magnet.includes("xs=https://cdn.example.com/video.torrent") ||
magnet.includes("xs=https%3A%2F%2Fcdn.example.com%2Fvideo.torrent"),
"Normalized magnet should include xs hint",
);
assert.equal(ws, "https://cdn.example.com/video.mp4");
assert.equal(xs, "https://cdn.example.com/video.torrent");
});
test("normalizes nip71 metadata collections", () => {
const { payload, errors } = normalizeVideoNotePayload({
title: "NIP-71 Rich Metadata",
nip71: {
imeta: [
{
url: " https://cdn.example.com/hls/playlist.m3u8 ",
service: [" wss://relay.example.com "],
},
],
segments: [
{
start: " 00:30 ",
end: 90000,
title: " Intro ",
thumbnail: " https://cdn.example.com/thumb.jpg ",
},
{
start: "bad",
end: null,
title: "",
},
],
textTracks: [
{ url: " https://cdn.example.com/captions.vtt ", type: " text/vtt ", language: " en " },
null,
],
participants: [
{ pubkey: " npub123 ", relay: " wss://relay.example.com " },
{},
],
hashtags: [" bitvid ", ""],
references: [" nostr:note1abc "],
duration: "900",
publishedAt: "2023-01-01T00:00:00Z",
summary: " Summary copy ",
},
});
assert.deepEqual(errors, []);
const nip71 = payload.nip71;
assert.ok(nip71, "Expected nip71 payload");
assert.equal(nip71.summary, "Summary copy");
assert.equal(nip71.duration, 900);
assert.equal(nip71.publishedAt, 1672531200);
assert.deepEqual(nip71.hashtags, ["bitvid"]);
assert.deepEqual(nip71.references, ["nostr:note1abc"]);
assert.equal(nip71.segments.length, 2);
assert.deepEqual(nip71.segments[0], {
start: "00:30",
end: 90000,
title: "Intro",
thumbnail: "https://cdn.example.com/thumb.jpg",
});
assert.deepEqual(nip71.segments[1], { start: "bad" });
assert.deepEqual(nip71.textTracks, [
{
url: "https://cdn.example.com/captions.vtt",
type: "text/vtt",
language: "en",
},
]);
assert.deepEqual(nip71.participants, [
{
pubkey: "npub123",
relay: "wss://relay.example.com",
},
]);
assert.equal(nip71.imeta[0].url, "https://cdn.example.com/hls/playlist.m3u8");
assert.deepEqual(nip71.imeta[0].service, ["wss://relay.example.com"]);
});
test("reports validation errors for missing fields", () => {
const { errors } = normalizeVideoNotePayload({
title: " ",
url: "",
});
assert.deepEqual(errors, [
VIDEO_NOTE_ERROR_CODES.MISSING_TITLE,
VIDEO_NOTE_ERROR_CODES.MISSING_SOURCE,
]);
assert.equal(
getVideoNoteErrorMessage(errors[0]),
"Title is required.",
);
});
test("rejects insecure hosted URLs", () => {
const { errors } = normalizeVideoNotePayload({
title: "Sample",
url: "http://example.com/video.mp4",
});
assert.deepEqual(errors, [VIDEO_NOTE_ERROR_CODES.INVALID_URL_PROTOCOL]);
});
test("allows publishing with only imeta variants", () => {
const { payload, errors } = normalizeVideoNotePayload({
title: "Imeta Only",
nip71: {
imeta: [
{
url: "https://cdn.example.com/video.m3u8",
image: ["https://cdn.example.com/poster.jpg"],
},
],
},
});
assert.deepEqual(errors, []);
assert.ok(payload.nip71);
assert.equal(payload.legacyFormData.url, "");
});
+113
View File
@@ -1325,6 +1325,118 @@ async function testWatchHistoryPartialRelayRetry() {
}
}
async function testWatchHistorySnapshotRetainsNewQueueEntriesDuringPublish() {
console.log("Running watch history snapshot inflight queue retention test...");
const actor = "npub-snapshot-inflight";
const originalPublishSnapshot = nostrClient.publishWatchHistorySnapshot;
const originalRecordView = nostrClient.recordVideoView;
const originalEnsure = nostrClient.ensureSessionActor;
const originalSession = nostrClient.sessionActor;
const originalPub = nostrClient.pubkey;
try {
localStorage.clear();
watchHistoryService.resetProgress();
nostrClient.pubkey = actor;
nostrClient.sessionActor = { pubkey: actor, privateKey: "snapshot-priv" };
nostrClient.ensureSessionActor = async () => actor;
nostrClient.watchHistoryLastCreatedAt = 0;
let createdAt = 1_700_200_000;
nostrClient.recordVideoView = async (_pointer, options = {}) => ({
ok: true,
event: {
id: `view-${options.created_at || createdAt}`,
pubkey: actor,
created_at: options.created_at || createdAt,
},
});
await watchHistoryService.publishView(
{ type: "e", value: "inflight-initial" },
createdAt,
{ actor },
);
const queuedBefore = watchHistoryService.getQueuedPointers(actor);
assert.equal(
queuedBefore.length,
1,
"queue should contain the initial pointer before snapshot",
);
let publishCalled = false;
let publishItems = [];
let releasePublish;
const publishGate = new Promise((resolve) => {
releasePublish = resolve;
});
nostrClient.publishWatchHistorySnapshot = async (items, options = {}) => {
publishCalled = true;
publishItems = Array.isArray(items) ? items.map((item) => ({ ...item })) : [];
await publishGate;
return { ok: true, items, snapshotId: "inflight-snapshot" };
};
const snapshotPromise = watchHistoryService.snapshot(null, {
actor,
reason: "inflight-test",
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(
publishCalled,
true,
"snapshot should invoke publish before resolving",
);
createdAt += 60;
await watchHistoryService.publishView(
{ type: "e", value: "inflight-new" },
createdAt,
{ actor },
);
const queuedDuring = watchHistoryService.getQueuedPointers(actor);
assert.equal(
queuedDuring.some((entry) => entry?.value === "inflight-new"),
true,
"queue should include the new pointer while snapshot is pending",
);
assert.equal(
publishItems.some((entry) => entry?.value === "inflight-new"),
false,
"inflight publish should not include pointers queued after the snapshot started",
);
releasePublish();
const snapshotResult = await snapshotPromise;
assert.ok(snapshotResult?.ok, "snapshot should resolve successfully");
const queuedAfter = watchHistoryService.getQueuedPointers(actor);
assert.equal(
queuedAfter.length,
1,
"queue should retain only the pointer added during the inflight snapshot",
);
assert.equal(
queuedAfter[0]?.value,
"inflight-new",
"new pointer should remain queued for the next snapshot",
);
} finally {
nostrClient.publishWatchHistorySnapshot = originalPublishSnapshot;
nostrClient.recordVideoView = originalRecordView;
nostrClient.ensureSessionActor = originalEnsure;
nostrClient.sessionActor = originalSession;
nostrClient.pubkey = originalPub;
watchHistoryService.resetProgress();
}
}
async function testResolveWatchHistoryBatchingWindow() {
const actor = "b".repeat(64);
const originalEnsure = nostrClient.ensureSessionActor;
@@ -2345,6 +2457,7 @@ await testEnsureExtensionPermissionCaching();
await testFetchWatchHistoryExtensionDecryptsHexAndNpub();
await testPublishSnapshotFailureRetry();
await testWatchHistoryPartialRelayRetry();
await testWatchHistorySnapshotRetainsNewQueueEntriesDuringPublish();
await testResolveWatchHistoryBatchingWindow();
await testWatchHistoryServiceIntegration();
await testWatchHistorySnapshotMergesQueuedWithCachedItems();