mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-07 13:22:54 +00:00
update
This commit is contained in:
1 parent
281a6511f1
commit
42edebcf6d
45 files changed
+8446
-1010
No files matched your search
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"]
|
||||
);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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, "");
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user