Files
bitvid/tests/subscriptions-manager.test.mjs
T
Claude 79c5814432 Fix slow Nostr login: unblock NIP-04 decryption, fix signer readiness, storage tab, and watch-history feed
Four issues caused login to be extremely slow and lists to fail syncing:

1. Subscription list decryption excluded NIP-04 when NIP-44 was available,
   preventing decryption of NIP-04-encrypted lists entirely. Removed the
   allowNip04 gate so NIP-04 is always a fallback (matching userBlocks.js
   and hashtagPreferencesService.js which already worked correctly).

2. When the NIP-07 extension permission pre-grant failed (common on page
   refresh when the extension injects slowly), all list services were called
   with allowPermissionPrompt:false and could never recover. Now list
   services are allowed to prompt for permissions themselves when the
   pre-grant fails, avoiding 6-10 second retry delays.

3. The profile modal storage tab was never refreshed on login or logout,
   so it stayed locked even after authentication succeeded. Added
   populateStoragePane() calls to handleAuthLogin and handleAuthLogout.

4. The watch-history feed engine.run() call was missing await, so rejected
   promises from unregistered feeds escaped the try/catch and propagated
   as unhandled errors instead of falling through to the service fallback.

https://claude.ai/code/session_013puYzbf1cC7a59HLcGQUdJ
2026-02-08 02:47:30 +00:00

1275 lines
39 KiB
JavaScript

import "./test-helpers/setup-localstorage.mjs";
import { test } from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import { nostrClient } from "../js/nostrClientFacade.js";
import { getActiveSigner, setActiveSigner, clearActiveSigner } from "../js/nostr/client.js";
import { subscriptions } from "../js/subscriptions.js";
import { setApplication } from "../js/applicationContext.js";
import nostrService from "../js/services/nostrService.js";
import moderationService from "../js/services/moderationService.js";
import { getNostrEventSchema, NOTE_TYPES } from "../js/nostrEventSchemas.js";
import { profileCache } from "../js/state/profileCache.js";
import { relayManager } from "../js/relayManager.js";
test("loadSubscriptions aggregates relay results when one rejects", async () => {
const SubscriptionsManager = subscriptions.constructor;
const manager = new SubscriptionsManager();
const originalRelays = Array.isArray(nostrClient.relays)
? [...nostrClient.relays]
: nostrClient.relays;
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
? [...nostrClient.writeRelays]
: nostrClient.writeRelays;
const originalPool = nostrClient.pool;
const originalEnsurePermissions = nostrClient.ensureExtensionPermissions;
const hadWindow = typeof globalThis.window !== "undefined";
if (!hadWindow) {
globalThis.window = {};
}
const originalWindowNostr = globalThis.window.nostr;
const relayUrls = [
"wss://relay-a.example",
"wss://relay-b.example",
"wss://relay-c.example",
];
nostrClient.relays = relayUrls;
nostrClient.writeRelays = relayUrls;
const originalRelayEntries = relayManager.getEntries();
relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
const eventsByRelay = {
"wss://relay-a.example": [
{
id: "event-old",
created_at: 100,
content: "cipher-old",
},
],
"wss://relay-c.example": [
{
id: "event-new",
created_at: 200,
content: "cipher-new",
},
],
};
const listCalls = [];
nostrClient.pool = {
list(urls, _filters) {
const url = Array.isArray(urls) ? urls[0] : urls;
listCalls.push(url);
if (url === "wss://relay-b.example") {
return Promise.reject(new Error("relay failed"));
}
return Promise.resolve(eventsByRelay[url] ?? []);
},
};
nostrClient.ensureExtensionPermissions = async () => ({ ok: true });
const decryptCalls = [];
globalThis.window.nostr = {
getPublicKey: async () => "0000000000000000000000000000000000000000000000000000000000000123",
signEvent: async () => ({}),
nip04: {
async decrypt(_pubkey, ciphertext) {
decryptCalls.push(ciphertext);
if (ciphertext === "cipher-new") {
return JSON.stringify([["p", "pub-new"]]);
}
return JSON.stringify({ subPubkeys: ["pub-old"] });
},
},
};
try {
await manager.loadSubscriptions("0000000000000000000000000000000000000000000000000000000000000123");
assert.deepEqual(
Array.from(manager.subscribedPubkeys),
["pub-new"],
"newest subscription set should be loaded from successful relays",
);
assert.equal(
manager.subsEventId,
"event-new",
"newest event id should be recorded despite relay failure",
);
assert.equal(
decryptCalls.length,
1,
"only the newest subscription event should be decrypted",
);
assert.equal(
decryptCalls[0],
"cipher-new",
"newest event content should be decrypted",
);
assert.equal(
listCalls.length,
relayUrls.length,
"each relay should be queried even if one rejects",
);
for (const url of relayUrls) {
assert.ok(
listCalls.includes(url),
`loadSubscriptions should query relay ${url}`,
);
}
} finally {
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
nostrClient.relays = originalRelays;
nostrClient.writeRelays = originalWriteRelays;
nostrClient.pool = originalPool;
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
if (typeof originalWindowNostr === "undefined") {
delete globalThis.window.nostr;
} else {
globalThis.window.nostr = originalWindowNostr;
}
if (!hadWindow) {
delete globalThis.window;
}
localStorage.clear();
profileCache.memoryCache.clear();
profileCache.activePubkey = null;
clearActiveSigner();
}
});
test("loadSubscriptions queries the correct subscription list kind", async () => {
const SubscriptionsManager = subscriptions.constructor;
const manager = new SubscriptionsManager();
const originalRelays = Array.isArray(nostrClient.relays)
? [...nostrClient.relays]
: nostrClient.relays;
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
? [...nostrClient.writeRelays]
: nostrClient.writeRelays;
const originalPool = nostrClient.pool;
const originalEnsurePermissions = nostrClient.ensureExtensionPermissions;
const hadWindow = typeof globalThis.window !== "undefined";
if (!hadWindow) {
globalThis.window = {};
}
const originalWindowNostr = globalThis.window.nostr;
const relayUrls = ["wss://relay-legacy.example"];
nostrClient.relays = relayUrls;
nostrClient.writeRelays = relayUrls;
const capturedFilters = [];
nostrClient.pool = {
list(urls, filters) {
const filterArray = Array.isArray(filters) ? filters : [];
if (filterArray.length) {
capturedFilters.push(filterArray[0]);
}
return Promise.resolve([]);
},
};
nostrClient.ensureExtensionPermissions = async () => ({ ok: true });
try {
await manager.loadSubscriptions("0000000000000000000000000000000000000000000000000000000000000002");
assert.ok(
capturedFilters.length > 0,
"loadSubscriptions should pass subscription filters to pool.list",
);
const schemaKind =
getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST)?.kind ?? 30000;
for (const filter of capturedFilters) {
const kinds = Array.isArray(filter?.kinds) ? filter.kinds : [];
assert.ok(
kinds.includes(schemaKind),
"filter should include the active follow-set kind",
);
assert.equal(
kinds.length,
1,
"filter should only query the active follow-set kind",
);
assert.ok(
!kinds.includes(30002),
"filter should not include the legacy kind",
);
}
} finally {
nostrClient.relays = originalRelays;
nostrClient.writeRelays = originalWriteRelays;
nostrClient.pool = originalPool;
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
if (typeof originalWindowNostr === "undefined") {
delete globalThis.window.nostr;
} else {
globalThis.window.nostr = originalWindowNostr;
}
if (!hadWindow) {
delete globalThis.window;
}
localStorage.clear();
profileCache.memoryCache.clear();
profileCache.activePubkey = null;
clearActiveSigner();
}
});
test("loadSubscriptions falls back to nip44 when hinted", async () => {
const SubscriptionsManager = subscriptions.constructor;
const manager = new SubscriptionsManager();
const originalRelays = Array.isArray(nostrClient.relays)
? [...nostrClient.relays]
: nostrClient.relays;
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
? [...nostrClient.writeRelays]
: nostrClient.writeRelays;
const originalPool = nostrClient.pool;
const originalEnsurePermissions = nostrClient.ensureExtensionPermissions;
const relayUrls = ["wss://relay-nip44.example"];
nostrClient.relays = relayUrls;
nostrClient.writeRelays = relayUrls;
const event = {
id: "event-nip44",
created_at: 400,
content: "cipher-nip44",
tags: [["encrypted", "nip44_v2"]],
};
nostrClient.pool = {
list() {
return Promise.resolve([event]);
},
};
nostrClient.ensureExtensionPermissions = async () => ({ ok: true });
const decryptCalls = { nip04: 0, nip44: 0 };
setActiveSigner({
pubkey: "0000000000000000000000000000000000000000000000000000000000000005",
async nip04Decrypt() {
decryptCalls.nip04 += 1;
throw new Error("nip04 unavailable");
},
async nip44Decrypt(_pubkey, ciphertext) {
decryptCalls.nip44 += 1;
return JSON.stringify([["p", "pub-nip44", ciphertext]]);
},
});
try {
await manager.loadSubscriptions("0000000000000000000000000000000000000000000000000000000000000005");
assert.deepEqual(
Array.from(manager.subscribedPubkeys),
["pub-nip44"],
"nip44 decrypted subscriptions should populate the set",
);
assert.equal(manager.subsEventId, "event-nip44");
assert.equal(manager.loaded, true);
assert.equal(
decryptCalls.nip44 >= 1,
true,
"nip44 decrypt should be invoked when hinted",
);
} finally {
nostrClient.relays = originalRelays;
nostrClient.writeRelays = originalWriteRelays;
nostrClient.pool = originalPool;
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
localStorage.clear();
profileCache.memoryCache.clear();
profileCache.activePubkey = null;
clearActiveSigner();
}
});
test("loadSubscriptions handles nip44.v2 decryptors", async () => {
const SubscriptionsManager = subscriptions.constructor;
const manager = new SubscriptionsManager();
const originalRelays = Array.isArray(nostrClient.relays)
? [...nostrClient.relays]
: nostrClient.relays;
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
? [...nostrClient.writeRelays]
: nostrClient.writeRelays;
const originalPool = nostrClient.pool;
const originalEnsurePermissions = nostrClient.ensureExtensionPermissions;
const relayUrls = ["wss://relay-nip44-v2.example"];
nostrClient.relays = relayUrls;
nostrClient.writeRelays = relayUrls;
const event = {
id: "event-nip44-v2",
created_at: 500,
content: "cipher-nip44-v2",
tags: [["encrypted", "nip44_v2"]],
};
nostrClient.pool = {
list() {
return Promise.resolve([event]);
},
};
nostrClient.ensureExtensionPermissions = async () => ({ ok: true });
const decryptCalls = { nip04: 0, nip44: 0, nip44_v2: 0 };
setActiveSigner({
pubkey: "0000000000000000000000000000000000000000000000000000000000000006",
async nip44Decrypt(_pubkey, ciphertext) {
decryptCalls.nip44_v2 += 1;
return JSON.stringify([["p", "pub-nip44-v2"], ["t", ciphertext]]);
},
});
try {
await manager.loadSubscriptions("0000000000000000000000000000000000000000000000000000000000000006");
assert.deepEqual(
Array.from(manager.subscribedPubkeys),
["pub-nip44-v2"],
"nip44.v2 decrypted subscriptions should populate the set",
);
assert.equal(manager.subsEventId, "event-nip44-v2");
assert.equal(manager.loaded, true);
assert.equal(
decryptCalls.nip44_v2,
1,
"nip44.v2 decrypt should be invoked once when provided",
);
assert.equal(
decryptCalls.nip44,
0,
"no legacy nip44 decryptors should be invoked when only v2 exists",
);
assert.equal(
decryptCalls.nip04,
0,
"nip04 decrypt should not be attempted when unavailable",
);
} finally {
nostrClient.relays = originalRelays;
nostrClient.writeRelays = originalWriteRelays;
nostrClient.pool = originalPool;
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
localStorage.clear();
profileCache.memoryCache.clear();
profileCache.activePubkey = null;
clearActiveSigner();
}
});
test("loadSubscriptions prefers nip44 decryptors when both are available", async () => {
const SubscriptionsManager = subscriptions.constructor;
const manager = new SubscriptionsManager();
const originalRelays = Array.isArray(nostrClient.relays)
? [...nostrClient.relays]
: nostrClient.relays;
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
? [...nostrClient.writeRelays]
: nostrClient.writeRelays;
const originalPool = nostrClient.pool;
const originalEnsurePermissions = nostrClient.ensureExtensionPermissions;
const relayUrls = ["wss://relay-nip44-pref.example"];
nostrClient.relays = relayUrls;
nostrClient.writeRelays = relayUrls;
const event = {
id: "event-nip44-pref",
created_at: 700,
content: "cipher-nip44-pref",
tags: [["encrypted", "nip44_v2"]],
};
nostrClient.pool = {
list() {
return Promise.resolve([event]);
},
};
nostrClient.ensureExtensionPermissions = async () => ({ ok: true });
const decryptCalls = { nip04: 0, nip44: 0 };
setActiveSigner({
pubkey: "0000000000000000000000000000000000000000000000000000000000000003",
async nip04Decrypt() {
decryptCalls.nip04 += 1;
return JSON.stringify([["p", "pub-nip04"]]);
},
async nip44Decrypt(_pubkey, ciphertext) {
decryptCalls.nip44 += 1;
return JSON.stringify([["p", "pub-nip44-pref", ciphertext]]);
},
});
try {
await manager.loadSubscriptions("0000000000000000000000000000000000000000000000000000000000000003");
assert.deepEqual(
Array.from(manager.subscribedPubkeys),
["pub-nip44-pref"],
"nip44 decrypted subscriptions should populate the set",
);
assert.equal(decryptCalls.nip44 >= 1, true, "nip44 decryptor should be used");
} finally {
nostrClient.relays = originalRelays;
nostrClient.writeRelays = originalWriteRelays;
nostrClient.pool = originalPool;
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
localStorage.clear();
profileCache.memoryCache.clear();
profileCache.activePubkey = null;
clearActiveSigner();
}
});
test(
"loadSubscriptions uses active signer decryptors without requesting extension permissions",
async () => {
const SubscriptionsManager = subscriptions.constructor;
const manager = new SubscriptionsManager();
const originalRelays = Array.isArray(nostrClient.relays)
? [...nostrClient.relays]
: nostrClient.relays;
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
? [...nostrClient.writeRelays]
: nostrClient.writeRelays;
const originalPool = nostrClient.pool;
const originalEnsurePermissions = nostrClient.ensureExtensionPermissions;
const originalSigner = getActiveSigner();
const hadWindow = typeof globalThis.window !== "undefined";
if (!hadWindow) {
globalThis.window = {};
}
const originalWindowNostr = globalThis.window.nostr;
delete globalThis.window.nostr;
const relayUrls = ["wss://relay-direct.example"];
nostrClient.relays = relayUrls;
nostrClient.writeRelays = relayUrls;
const event = {
id: "event-direct",
created_at: 600,
content: "cipher-direct",
};
nostrClient.pool = {
list() {
return Promise.resolve([event]);
},
};
let permissionCalls = 0;
nostrClient.ensureExtensionPermissions = async () => {
permissionCalls += 1;
return { ok: true };
};
const decryptCalls = [];
setActiveSigner({
pubkey: "0000000000000000000000000000000000000000000000000000000000000004",
async nip04Decrypt(pubkey, ciphertext) {
decryptCalls.push({ pubkey, ciphertext });
return JSON.stringify([["p", "pub-direct"]]);
},
});
try {
await manager.loadSubscriptions("0000000000000000000000000000000000000000000000000000000000000004");
assert.equal(permissionCalls, 0, "should not request extension permissions");
assert.equal(decryptCalls.length, 1, "signer decrypt should be invoked once");
assert.deepEqual(
decryptCalls[0],
{ pubkey: "0000000000000000000000000000000000000000000000000000000000000004", ciphertext: "cipher-direct" },
"signer decrypt should receive the expected arguments",
);
assert.deepEqual(
Array.from(manager.subscribedPubkeys),
["pub-direct"],
"direct signer decrypt should populate subscriptions",
);
} finally {
nostrClient.relays = originalRelays;
nostrClient.writeRelays = originalWriteRelays;
nostrClient.pool = originalPool;
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
setActiveSigner(originalSigner);
if (typeof originalWindowNostr === "undefined") {
delete globalThis.window.nostr;
} else {
globalThis.window.nostr = originalWindowNostr;
}
if (!hadWindow) {
delete globalThis.window;
}
localStorage.clear();
profileCache.memoryCache.clear();
profileCache.activePubkey = null;
clearActiveSigner();
}
},
);
test(
"loadSubscriptions retries permission-required decrypts only when enabled",
async () => {
const SubscriptionsManager = subscriptions.constructor;
const manager = new SubscriptionsManager();
const originalRelays = Array.isArray(nostrClient.relays)
? [...nostrClient.relays]
: nostrClient.relays;
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
? [...nostrClient.writeRelays]
: nostrClient.writeRelays;
const originalPool = nostrClient.pool;
const originalFetchIncremental = nostrClient.fetchListIncrementally;
const originalEnsurePermissions = nostrClient.ensureExtensionPermissions;
const originalSigner = getActiveSigner();
const originalEnsureSubscription = manager.ensureSubscriptionListSubscription;
const hadWindow = typeof globalThis.window !== "undefined";
if (!hadWindow) {
globalThis.window = {};
}
const originalWindowNostr = globalThis.window.nostr;
const relayUrls = ["wss://relay-permission.example"];
nostrClient.relays = relayUrls;
nostrClient.writeRelays = relayUrls;
const originalRelayEntries = relayManager.getEntries();
relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false });
const actor = "f".repeat(64);
const event = {
id: "event-permission",
created_at: 100,
pubkey: actor,
content: "cipher-permission",
tags: [["encrypted", "nip04"]],
};
nostrClient.pool = {};
nostrClient.fetchListIncrementally = async () => [event];
nostrClient.ensureExtensionPermissions = async () => ({ ok: true });
manager.ensureSubscriptionListSubscription = () => null;
clearActiveSigner();
const decryptCalls = [];
manager.decryptSubscriptionEvent = async (_event, _pubkey, options) => {
decryptCalls.push(options);
if (!options?.allowPermissionPrompt) {
const error = new Error("permissions required");
error.code = "subscriptions-permission-required";
return { ok: false, error };
}
return { ok: true, plaintext: JSON.stringify([["p", "pub-allowed"]]) };
};
try {
await manager.updateFromRelays(actor, { allowPermissionPrompt: false });
assert.equal(
decryptCalls.length,
1,
"should attempt decryption once without retry",
);
assert.equal(
manager.subscribedPubkeys.size,
0,
"subscriptions should remain empty when permissions are deferred",
);
await manager.updateFromRelays(actor, { allowPermissionPrompt: true });
assert.equal(
decryptCalls.length,
2,
"explicit permission prompts should trigger a retry",
);
assert.deepEqual(
Array.from(manager.subscribedPubkeys),
["pub-allowed"],
"retry with permission prompts should populate subscriptions",
);
} finally {
relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false });
nostrClient.relays = originalRelays;
nostrClient.writeRelays = originalWriteRelays;
nostrClient.pool = originalPool;
nostrClient.fetchListIncrementally = originalFetchIncremental;
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
manager.ensureSubscriptionListSubscription = originalEnsureSubscription;
setActiveSigner(originalSigner);
if (typeof originalWindowNostr === "undefined") {
delete globalThis.window.nostr;
} else {
globalThis.window.nostr = originalWindowNostr;
}
if (!hadWindow) {
delete globalThis.window;
}
localStorage.clear();
profileCache.memoryCache.clear();
profileCache.activePubkey = null;
clearActiveSigner();
}
},
);
test(
"showSubscriptionVideos waits for nostrService warm-up and refreshes after updates",
async () => {
const SubscriptionsManager = subscriptions.constructor;
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const originalNavigator = globalThis.navigator;
const dom = new JSDOM(
"<!doctype html><div id=\"subscriptionsVideoList\"></div>",
{ url: "https://example.test/" }
);
globalThis.window = dom.window;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", {
value: dom.window.navigator,
writable: true,
configurable: true,
});
const manager = new SubscriptionsManager();
const originalAwaitInitialLoad = nostrService.awaitInitialLoad;
const originalAwaitUserBlockRefresh = moderationService.awaitUserBlockRefresh;
let resolveInitialLoad;
const initialLoadPromise = new Promise((resolve) => {
resolveInitialLoad = resolve;
});
nostrService.awaitInitialLoad = () => initialLoadPromise;
moderationService.awaitUserBlockRefresh = async () => {};
const renderCalls = [];
manager.renderSameGridStyle = (result, containerId, options) => {
renderCalls.push({ result, containerId, options });
};
manager.loadSubscriptions = async () => {
manager.loaded = true;
manager.subscribedPubkeys = new Set(["pub-1"]);
};
const feedResults = [
{ items: [] },
{ items: [{ video: { id: "video-1", pubkey: "pub-1" } }] },
];
const runCalls = [];
const feedEngine = {
async run(name, options) {
runCalls.push({ name, options });
return feedResults.shift() ?? { items: [] };
},
};
setApplication({
feedEngine,
videosMap: new Map(),
registerSubscriptionsFeed: () => {},
isAuthorBlocked: () => false,
blacklistedEventIds: new Set(),
});
const showPromise = manager.showSubscriptionVideos(
"viewer-pub",
"subscriptionsVideoList"
);
await Promise.resolve();
assert.equal(
runCalls.length,
0,
"engine.run should not execute before nostrService initial load resolves",
);
resolveInitialLoad();
await showPromise;
assert.equal(runCalls.length, 1, "engine.run should execute after warm-up");
assert.equal(renderCalls.length, 1, "initial render should occur once");
assert.equal(
renderCalls[0].options.emptyMessage.includes("No playable subscription videos"),
true,
"empty state should provide a descriptive message",
);
nostrService.emit("videos:updated", {
reason: "subscription",
videos: [{ id: "video-1", pubkey: "pub-1" }],
});
if (manager.pendingRefreshPromise) {
await manager.pendingRefreshPromise;
} else {
await Promise.resolve();
}
assert.equal(
runCalls.length,
2,
"engine.run should execute again after nostrService update",
);
assert.equal(renderCalls.length, 2, "render should run again after update");
assert.equal(
renderCalls[1].result.items[0].video.id,
"video-1",
"updated render should receive fresh videos",
);
assert.equal(
renderCalls[1].options.emptyMessage.includes("No playable subscription videos"),
true,
"subsequent renders should retain the empty state message for metadata",
);
if (typeof manager.unsubscribeFromNostrUpdates === "function") {
manager.unsubscribeFromNostrUpdates();
}
nostrService.awaitInitialLoad = originalAwaitInitialLoad;
moderationService.awaitUserBlockRefresh = originalAwaitUserBlockRefresh;
setApplication(null);
dom.window.close();
if (typeof originalWindow === "undefined") {
delete globalThis.window;
} else {
globalThis.window = originalWindow;
}
if (typeof originalDocument === "undefined") {
delete globalThis.document;
} else {
globalThis.document = originalDocument;
}
if (typeof originalNavigator === "undefined") {
delete globalThis.navigator;
} else {
Object.defineProperty(globalThis, "navigator", {
value: originalNavigator,
writable: true,
configurable: true,
});
}
},
);
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 () => {
const SubscriptionsManager = subscriptions.constructor;
const manager = new SubscriptionsManager();
const originalRelays = Array.isArray(nostrClient.relays)
? [...nostrClient.relays]
: nostrClient.relays;
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
? [...nostrClient.writeRelays]
: nostrClient.writeRelays;
const originalPool = nostrClient.pool;
const originalEnsurePermissions = nostrClient.ensureExtensionPermissions;
const originalSigner = getActiveSigner();
const relayUrls = ["wss://relay-direct.example"];
nostrClient.relays = relayUrls;
nostrClient.writeRelays = relayUrls;
let permissionCalls = 0;
nostrClient.ensureExtensionPermissions = async () => {
permissionCalls += 1;
return { ok: true };
};
const publishCalls = [];
nostrClient.pool = {
publish(urls, event) {
publishCalls.push({ urls, event });
return {
on(eventName, handler) {
if (eventName === "ok") {
handler();
}
return true;
},
};
},
};
const encryptCalls = [];
const signCalls = [];
setActiveSigner({
type: "nsec",
pubkey: "user-pubkey-123",
async nip04Encrypt(pubkey, plaintext) {
encryptCalls.push({ pubkey, plaintext });
return "cipher-direct";
},
async signEvent(event) {
signCalls.push(event);
return { ...event, id: "signed-direct-event" };
},
});
manager.subscribedPubkeys = new Set(["pub-direct"]);
try {
await manager.publishSubscriptionList("user-pubkey-123");
assert.equal(permissionCalls, 0, "should not request extension permissions");
assert.equal(encryptCalls.length, 1, "nip04Encrypt should be called once");
assert.equal(signCalls.length, 1, "signEvent should be called once");
assert.deepEqual(
encryptCalls[0],
{
pubkey: "user-pubkey-123",
plaintext: JSON.stringify([["p", "pub-direct"]]),
},
"nip04Encrypt should receive the serialized subscription list",
);
assert.equal(
signCalls[0].content,
"cipher-direct",
"signEvent should receive the encrypted content",
);
assert.equal(publishCalls.length, relayUrls.length, "should publish to each relay");
assert.equal(manager.subsEventId, "signed-direct-event");
} finally {
nostrClient.relays = originalRelays;
nostrClient.writeRelays = originalWriteRelays;
nostrClient.pool = originalPool;
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
setActiveSigner(originalSigner);
}
},
);
test("publishSubscriptionList prefers nip44 encryption when available", async () => {
const SubscriptionsManager = subscriptions.constructor;
const manager = new SubscriptionsManager();
const originalRelays = Array.isArray(nostrClient.relays)
? [...nostrClient.relays]
: nostrClient.relays;
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
? [...nostrClient.writeRelays]
: nostrClient.writeRelays;
const originalPool = nostrClient.pool;
const originalEnsurePermissions = nostrClient.ensureExtensionPermissions;
const originalSigner = getActiveSigner();
const relayUrls = ["wss://relay-prefer-nip44.example"];
nostrClient.relays = relayUrls;
nostrClient.writeRelays = relayUrls;
let permissionCalls = 0;
nostrClient.ensureExtensionPermissions = async () => {
permissionCalls += 1;
return { ok: true };
};
const publishCalls = [];
nostrClient.pool = {
publish(urls, event) {
publishCalls.push({ urls, event });
return {
on(eventName, handler) {
if (eventName === "ok") {
handler();
}
return true;
},
};
},
};
const encryptCalls = { nip44: 0, nip04: 0 };
const signedEvents = [];
setActiveSigner({
type: "nsec",
pubkey: "user-pubkey-123",
async nip44Encrypt(pubkey, plaintext) {
encryptCalls.nip44 += 1;
assert.equal(pubkey, "user-pubkey-123");
assert.equal(
plaintext,
JSON.stringify([["p", "pub-one"], ["p", "pub-two"]]),
);
return "cipher-nip44";
},
async nip04Encrypt() {
encryptCalls.nip04 += 1;
throw new Error("nip04 should not be used when nip44 is available");
},
async signEvent(event) {
signedEvents.push(event);
return { ...event, id: "signed-nip44-event" };
},
});
manager.subscribedPubkeys = new Set(["pub-one", "pub-two"]);
try {
await manager.publishSubscriptionList("user-pubkey-123");
assert.equal(permissionCalls, 0, "should not request extension permissions");
assert.equal(encryptCalls.nip44, 1, "nip44Encrypt should be used once");
assert.equal(encryptCalls.nip04, 0, "nip04Encrypt should not be invoked");
assert.equal(signedEvents.length, 1, "signEvent should be called once");
const signedEvent = signedEvents[0];
const encryptedTags = signedEvent.tags.filter((tag) => tag[0] === "encrypted");
assert.deepEqual(
encryptedTags,
[["encrypted", "nip44_v2"]],
"signed event should advertise nip44_v2 encryption",
);
assert.equal(publishCalls.length, relayUrls.length, "event should publish to relays");
assert.equal(manager.subsEventId, "signed-nip44-event");
} finally {
nostrClient.relays = originalRelays;
nostrClient.writeRelays = originalWriteRelays;
nostrClient.pool = originalPool;
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
setActiveSigner(originalSigner);
}
});
test("publishSubscriptionList falls back to nip04 when nip44 fails", async () => {
const SubscriptionsManager = subscriptions.constructor;
const manager = new SubscriptionsManager();
const originalRelays = Array.isArray(nostrClient.relays)
? [...nostrClient.relays]
: nostrClient.relays;
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
? [...nostrClient.writeRelays]
: nostrClient.writeRelays;
const originalPool = nostrClient.pool;
const originalEnsurePermissions = nostrClient.ensureExtensionPermissions;
const originalSigner = getActiveSigner();
const relayUrls = ["wss://relay-fallback-nip04.example"];
nostrClient.relays = relayUrls;
nostrClient.writeRelays = relayUrls;
nostrClient.ensureExtensionPermissions = async () => ({ ok: true });
const publishCalls = [];
nostrClient.pool = {
publish(urls, event) {
publishCalls.push({ urls, event });
return {
on(eventName, handler) {
if (eventName === "ok") {
handler();
}
return true;
},
};
},
};
const encryptCalls = { nip44: 0, nip04: 0 };
const signedEvents = [];
setActiveSigner({
type: "nsec",
pubkey: "user-pubkey-123",
async nip44Encrypt() {
encryptCalls.nip44 += 1;
throw new Error("simulated nip44 failure");
},
async nip04Encrypt(pubkey, plaintext) {
encryptCalls.nip04 += 1;
assert.equal(pubkey, "user-pubkey-123");
assert.equal(
plaintext,
JSON.stringify([["p", "pub-three"]]),
);
return "cipher-nip04";
},
async signEvent(event) {
signedEvents.push(event);
return { ...event, id: "signed-nip04-event" };
},
});
manager.subscribedPubkeys = new Set(["pub-three"]);
try {
await manager.publishSubscriptionList("user-pubkey-123");
assert.ok(
encryptCalls.nip44 >= 1,
"nip44Encrypt should be attempted before falling back",
);
assert.equal(encryptCalls.nip04, 1, "nip04Encrypt should handle fallback");
const signedEvent = signedEvents[0];
const encryptedTags = signedEvent.tags.filter((tag) => tag[0] === "encrypted");
assert.deepEqual(
encryptedTags,
[["encrypted", "nip04"]],
"fallback nip04 encryption should advertise nip04",
);
assert.equal(publishCalls.length, relayUrls.length, "event should publish to relays");
assert.equal(manager.subsEventId, "signed-nip04-event");
} finally {
nostrClient.relays = originalRelays;
nostrClient.writeRelays = originalWriteRelays;
nostrClient.pool = originalPool;
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
setActiveSigner(originalSigner);
}
});
test("renderSameGridStyle shows empty state message", async () => {
const SubscriptionsManager = subscriptions.constructor;
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const dom = new JSDOM(
"<!doctype html><div id=\"subscriptionsVideoList\"></div>",
{ url: "https://example.test/" },
);
globalThis.window = dom.window;
globalThis.document = dom.window.document;
const manager = new SubscriptionsManager();
manager.renderSameGridStyle(
{ items: [] },
"subscriptionsVideoList",
{ emptyMessage: "Custom empty state copy." },
);
const container = dom.window.document.getElementById("subscriptionsVideoList");
assert.match(
container.textContent,
/Custom empty state copy\./,
"empty state should render the provided copy",
);
assert.ok(
!container.textContent.includes("Fetching subscriptions"),
"loading spinner should be removed when rendering the empty state",
);
dom.window.close();
if (typeof originalWindow === "undefined") {
delete globalThis.window;
} else {
globalThis.window = originalWindow;
}
if (typeof originalDocument === "undefined") {
delete globalThis.document;
} else {
globalThis.document = originalDocument;
}
});
test(
"renderSameGridStyle forwards moderation badge actions to the application",
async () => {
const SubscriptionsManager = subscriptions.constructor;
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const originalNavigator = globalThis.navigator;
const dom = new JSDOM(
"<!doctype html><div id=\"subscriptionsVideoList\"></div>",
{ url: "https://example.test/" },
);
globalThis.window = dom.window;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", {
value: dom.window.navigator,
writable: true,
configurable: true,
});
const manager = new SubscriptionsManager();
const overrideCalls = [];
const blockCalls = [];
const app = {
videosMap: new Map(),
handleModerationOverride(detail) {
overrideCalls.push(detail);
return true;
},
handleModerationBlock(detail) {
blockCalls.push(detail);
return true;
},
ensureGlobalMoreMenuHandlers() {},
closeAllMoreMenus() {},
handleMoreMenuAction() {},
};
setApplication(app);
const video = {
id: "video-moderated-1",
pubkey: "author-1",
title: "Moderated clip",
created_at: 1,
moderation: {
original: { hidden: true },
trustedMuted: true,
trustedMuteCount: 2,
summary: { types: { nudity: { trusted: 1 } } },
},
};
manager.renderSameGridStyle(
{ items: [{ video }] },
"subscriptionsVideoList",
);
const container = dom.window.document.getElementById(
"subscriptionsVideoList",
);
const overrideButton = container.querySelector(
'[data-moderation-action="override"]',
);
assert.ok(overrideButton, "override button should render for moderated video");
overrideButton.click();
await Promise.resolve();
assert.equal(
overrideCalls.length,
1,
"app.handleModerationOverride should receive one call",
);
const overrideDetail = overrideCalls[0];
assert.equal(overrideDetail.video, video);
assert.equal(overrideDetail.card?.video, video);
assert.equal(
overrideDetail.context,
"subscriptions",
"override payload should include the subscriptions context",
);
assert.equal(overrideDetail.trigger, overrideButton);
const blockButton = container.querySelector('[data-moderation-action="block"]');
assert.ok(blockButton, "block button should render for moderated video");
blockButton.click();
await Promise.resolve();
assert.equal(
blockCalls.length,
1,
"app.handleModerationBlock should receive one call",
);
const blockDetail = blockCalls[0];
assert.equal(blockDetail.video, video);
assert.equal(blockDetail.card?.video, video);
assert.equal(
blockDetail.context,
"subscriptions",
"block payload should include the subscriptions context",
);
assert.equal(blockDetail.trigger, blockButton);
if (manager.subscriptionListView?.destroy) {
manager.subscriptionListView.destroy();
manager.subscriptionListView = null;
}
dom.window.close();
if (typeof originalWindow === "undefined") {
delete globalThis.window;
} else {
globalThis.window = originalWindow;
}
if (typeof originalDocument === "undefined") {
delete globalThis.document;
} else {
globalThis.document = originalDocument;
}
if (typeof originalNavigator === "undefined") {
delete globalThis.navigator;
} else {
Object.defineProperty(globalThis, "navigator", {
value: originalNavigator,
writable: true,
configurable: true,
});
}
setApplication(null);
},
);