From 92cc05f1f65fbcbaebfb6d75a39a141d7f15e9a3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 08:00:21 -0400 Subject: [PATCH] Refactor relay publish acknowledgements --- js/nostr.js | 316 +++++++++++++++---------- js/nostrPublish.js | 195 +++++++++++++++ js/relayManager.js | 66 ++++-- js/subscriptions.js | 117 +++++++-- js/userBlocks.js | 50 +++- tests/nostr-publish-rejection.test.mjs | 243 +++++++++++++++++++ 6 files changed, 815 insertions(+), 172 deletions(-) create mode 100644 js/nostrPublish.js create mode 100644 tests/nostr-publish-rejection.test.mjs diff --git a/js/nostr.js b/js/nostr.js index 9eeac4a1..70221267 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -28,6 +28,11 @@ import { buildWatchHistoryIndexEvent, buildWatchHistoryChunkEvent, } from "./nostrEventSchemas.js"; +import { + publishEventToRelay, + publishEventToRelays, + assertAnyRelayAccepted, +} from "./nostrPublish.js"; /** * The default relay set BitVid bootstraps with before loading a user's @@ -1139,92 +1144,6 @@ function decodeNpubToHex(npub) { return ""; } -const DM_PUBLISH_TIMEOUT_MS = 10_000; - -function publishEventToRelay(pool, url, event) { - return new Promise((resolve) => { - let settled = false; - const finalize = (success, error = null) => { - if (settled) { - return; - } - settled = true; - resolve({ url, success, error }); - }; - - const timeoutId = setTimeout(() => { - finalize(false, new Error("publish timeout")); - }, DM_PUBLISH_TIMEOUT_MS); - - try { - const pub = pool.publish([url], event); - - if (pub && typeof pub.on === "function") { - const registerHandler = (eventName, handler) => { - try { - pub.on(eventName, handler); - return true; - } catch (error) { - if (isDevMode) { - console.warn( - `[nostr] Relay publish handle rejected ${eventName} listener:`, - error - ); - } - return false; - } - }; - - const handleSuccess = () => { - clearTimeout(timeoutId); - finalize(true); - }; - - const handleFailure = (reason) => { - clearTimeout(timeoutId); - const err = - reason instanceof Error - ? reason - : new Error(String(reason || "publish failed")); - finalize(false, err); - }; - - let handlerRegistered = false; - handlerRegistered = - registerHandler("ok", handleSuccess) || handlerRegistered; - handlerRegistered = - registerHandler("seen", handleSuccess) || handlerRegistered; - handlerRegistered = - registerHandler("failed", handleFailure) || handlerRegistered; - - if (handlerRegistered) { - return; - } - } - - if (pub && typeof pub.then === "function") { - pub - .then(() => { - clearTimeout(timeoutId); - finalize(true); - }) - .catch((error) => { - clearTimeout(timeoutId); - finalize(false, error); - }); - return; - } - } catch (error) { - clearTimeout(timeoutId); - finalize(false, error); - return; - } - - clearTimeout(timeoutId); - finalize(true); - }); -} - const EXTENSION_MIME_MAP = { mp4: "video/mp4", m4v: "video/x-m4v", @@ -5130,17 +5049,54 @@ class NostrClient { const signedEvent = await window.nostr.signEvent(event); if (isDevMode) console.log("Signed event:", signedEvent); - await Promise.all( - this.relays.map(async (url) => { - try { - await this.pool.publish([url], signedEvent); - if (isDevMode) console.log(`Video published to ${url}`); - } catch (err) { - if (isDevMode) console.error(`Failed to publish: ${url}`, err); - } - }) + const publishResults = await publishEventToRelays( + this.pool, + this.relays, + signedEvent ); + let publishSummary; + try { + publishSummary = assertAnyRelayAccepted(publishResults, { + context: "video note", + }); + } catch (publishError) { + if (publishError?.relayFailures?.length) { + publishError.relayFailures.forEach( + ({ url, error: relayError, reason }) => { + console.error( + `[nostr] Video note rejected by ${url}: ${reason}`, + relayError || reason + ); + } + ); + } + throw publishError; + } + + const { accepted: acceptedRelays, failed: failedRelays } = publishSummary; + + if (isDevMode) { + acceptedRelays.forEach(({ url }) => + console.log(`Video published to ${url}`) + ); + } + + if (failedRelays.length) { + failedRelays.forEach(({ url, error: relayError }) => { + const reason = + relayError instanceof Error + ? relayError.message + : relayError + ? String(relayError) + : "publish failed"; + console.warn( + `[nostr] Video note not accepted by ${url}: ${reason}`, + relayError + ); + }); + } + if (finalUrl) { const inferredMimeType = inferMimeTypeFromUrl(finalUrl); const mimeType = @@ -5181,23 +5137,54 @@ class NostrClient { console.log("Signed NIP-94 mirror event:", signedMirrorEvent); } - await Promise.all( - this.relays.map(async (url) => { - try { - await this.pool.publish([url], signedMirrorEvent); - if (isDevMode) { - console.log(`NIP-94 mirror published to ${url}`); - } - } catch (mirrorErr) { - if (isDevMode) { - console.error( - `Failed to publish NIP-94 mirror to ${url}`, - mirrorErr + const mirrorResults = await publishEventToRelays( + this.pool, + this.relays, + signedMirrorEvent + ); + + try { + const mirrorSummary = assertAnyRelayAccepted(mirrorResults, { + context: "NIP-94 mirror", + }); + + if (isDevMode) { + mirrorSummary.accepted.forEach(({ url }) => + console.log(`NIP-94 mirror published to ${url}`) + ); + } + + if (mirrorSummary.failed.length) { + mirrorSummary.failed.forEach(({ url, error: relayError }) => { + const reason = + relayError instanceof Error + ? relayError.message + : relayError + ? String(relayError) + : "publish failed"; + console.warn( + `[nostr] NIP-94 mirror not accepted by ${url}: ${reason}`, + relayError + ); + }); + } + } catch (mirrorError) { + if (mirrorError?.relayFailures?.length) { + mirrorError.relayFailures.forEach( + ({ url, error: relayError, reason }) => { + console.warn( + `[nostr] NIP-94 mirror rejected by ${url}: ${reason}`, + relayError || reason ); } - } - }) - ); + ); + } else if (isDevMode) { + console.warn( + "[nostr] NIP-94 mirror rejected by all relays:", + mirrorError + ); + } + } if (isDevMode) { console.log( @@ -5356,21 +5343,52 @@ class NostrClient { console.log("Signed edited event:", signedEvent); } - await Promise.all( - this.relays.map(async (url) => { - try { - await this.pool.publish([url], signedEvent); - if (isDevMode) { - console.log(`Edited video published to ${url}`); - } - } catch (err) { - if (isDevMode) { - console.error(`Publish failed to ${url}`, err); - } - } - }) + const publishResults = await publishEventToRelays( + this.pool, + this.relays, + signedEvent ); + let publishSummary; + try { + publishSummary = assertAnyRelayAccepted(publishResults, { + context: "edited video note", + }); + } catch (publishError) { + if (publishError?.relayFailures?.length) { + publishError.relayFailures.forEach( + ({ url, error: relayError, reason }) => { + console.error( + `[nostr] Edited video rejected by ${url}: ${reason}`, + relayError || reason + ); + } + ); + } + throw publishError; + } + + if (isDevMode) { + publishSummary.accepted.forEach(({ url }) => + console.log(`Edited video published to ${url}`) + ); + } + + if (publishSummary.failed.length) { + publishSummary.failed.forEach(({ url, error: relayError }) => { + const reason = + relayError instanceof Error + ? relayError.message + : relayError + ? String(relayError) + : "publish failed"; + console.warn( + `[nostr] Edited video not accepted by ${url}: ${reason}`, + relayError + ); + }); + } + return signedEvent; } catch (err) { console.error("Edit failed:", err); @@ -5461,16 +5479,52 @@ class NostrClient { }; const signedEvent = await window.nostr.signEvent(event); - await Promise.all( - this.relays.map(async (url) => { - try { - await this.pool.publish([url], signedEvent); - } catch (err) { - if (isDevMode) console.error(`Failed to revert on ${url}`, err); - } - }) + const publishResults = await publishEventToRelays( + this.pool, + this.relays, + signedEvent ); + let publishSummary; + try { + publishSummary = assertAnyRelayAccepted(publishResults, { + context: "video revert", + }); + } catch (publishError) { + if (publishError?.relayFailures?.length) { + publishError.relayFailures.forEach( + ({ url, error: relayError, reason }) => { + console.error( + `[nostr] Video revert rejected by ${url}: ${reason}`, + relayError || reason + ); + } + ); + } + throw publishError; + } + + if (isDevMode) { + publishSummary.accepted.forEach(({ url }) => + console.log(`Revert event published to ${url}`) + ); + } + + if (publishSummary.failed.length) { + publishSummary.failed.forEach(({ url, error: relayError }) => { + const reason = + relayError instanceof Error + ? relayError.message + : relayError + ? String(relayError) + : "publish failed"; + console.warn( + `[nostr] Video revert not accepted by ${url}: ${reason}`, + relayError + ); + }); + } + return signedEvent; } diff --git a/js/nostrPublish.js b/js/nostrPublish.js new file mode 100644 index 00000000..b8a9d4cc --- /dev/null +++ b/js/nostrPublish.js @@ -0,0 +1,195 @@ +import { isDevMode } from "./config.js"; + +export const RELAY_PUBLISH_TIMEOUT_MS = 10_000; + +function normalizeReason(error) { + if (error instanceof Error) { + return error.message || "publish failed"; + } + if (typeof error === "string" && error.trim()) { + return error.trim(); + } + if (error === undefined || error === null) { + return "publish failed"; + } + try { + return JSON.stringify(error); + } catch (_) { + return String(error); + } +} + +export class RelayPublishError extends Error { + constructor(message, results = [], options = {}) { + const finalMessage = + typeof message === "string" && message.trim() + ? message.trim() + : "Failed to publish event to any relay."; + super(finalMessage); + this.name = "RelayPublishError"; + + const contextValue = + typeof options.context === "string" && options.context.trim() + ? options.context.trim() + : null; + if (contextValue) { + this.context = contextValue; + } + + this.relayResults = Array.isArray(results) + ? results.map((result) => ({ + url: result?.url || "", + success: !!result?.success, + error: result?.error || null, + reason: normalizeReason(result?.error), + })) + : []; + + this.relayFailures = this.relayResults + .filter((entry) => !entry.success) + .map((entry) => ({ + url: entry.url, + error: entry.error, + reason: entry.reason, + })); + + this.acceptedRelays = this.relayResults + .filter((entry) => entry.success) + .map((entry) => entry.url); + } +} + +export function publishEventToRelay(pool, url, event, options = {}) { + const timeoutMs = + typeof options.timeoutMs === "number" && options.timeoutMs > 0 + ? options.timeoutMs + : RELAY_PUBLISH_TIMEOUT_MS; + + return new Promise((resolve) => { + let settled = false; + const finalize = (success, error = null) => { + if (settled) { + return; + } + settled = true; + resolve({ url, success, error }); + }; + + const timeoutId = setTimeout(() => { + finalize(false, new Error("publish timeout")); + }, timeoutMs); + + try { + const pub = pool?.publish?.([url], event); + + if (pub && typeof pub.on === "function") { + const registerHandler = (eventName, handler) => { + try { + pub.on(eventName, handler); + return true; + } catch (error) { + if (isDevMode) { + console.warn( + `[nostrPublish] Relay publish handle rejected ${eventName} listener:`, + error, + ); + } + return false; + } + }; + + const handleSuccess = () => { + clearTimeout(timeoutId); + finalize(true); + }; + + const handleFailure = (reason) => { + clearTimeout(timeoutId); + const err = + reason instanceof Error + ? reason + : new Error(String(reason || "publish failed")); + finalize(false, err); + }; + + let handlerRegistered = false; + handlerRegistered = + registerHandler("ok", handleSuccess) || handlerRegistered; + handlerRegistered = + registerHandler("seen", handleSuccess) || handlerRegistered; + handlerRegistered = + registerHandler("failed", handleFailure) || handlerRegistered; + + if (handlerRegistered) { + return; + } + } + + if (pub && typeof pub.then === "function") { + pub + .then(() => { + clearTimeout(timeoutId); + finalize(true); + }) + .catch((error) => { + clearTimeout(timeoutId); + finalize(false, error); + }); + return; + } + } catch (error) { + clearTimeout(timeoutId); + finalize(false, error); + return; + } + + clearTimeout(timeoutId); + finalize(true); + }); +} + +export function publishEventToRelays(pool, urls, event, options = {}) { + const list = Array.isArray(urls) ? urls : []; + return Promise.all( + list.map((url) => publishEventToRelay(pool, url, event, options)), + ); +} + +export function summarizePublishResults(results = []) { + const accepted = []; + const failed = []; + + for (const result of results) { + if (result?.success) { + accepted.push(result); + } else { + failed.push(result); + } + } + + return { accepted, failed }; +} + +export function assertAnyRelayAccepted(results = [], options = {}) { + const summary = summarizePublishResults(results); + if (summary.accepted.length > 0) { + return summary; + } + + const contextValue = + typeof options.context === "string" && options.context.trim() + ? options.context.trim() + : ""; + const messageOverride = + typeof options.message === "string" && options.message.trim() + ? options.message.trim() + : ""; + + const message = + messageOverride || + (contextValue + ? `Failed to publish ${contextValue} to any relay.` + : "Failed to publish event to any relay."); + + throw new RelayPublishError(message, results, { context: contextValue }); +} diff --git a/js/relayManager.js b/js/relayManager.js index 43e93596..4c6fff37 100644 --- a/js/relayManager.js +++ b/js/relayManager.js @@ -1,6 +1,10 @@ import { isDevMode } from "./config.js"; import { DEFAULT_RELAY_URLS, nostrClient } from "./nostr.js"; import { buildRelayListEvent } from "./nostrEventSchemas.js"; +import { + publishEventToRelays, + assertAnyRelayAccepted, +} from "./nostrPublish.js"; const MODE_SEQUENCE = ["both", "read", "write"]; @@ -497,28 +501,56 @@ class RelayPreferencesManager { throw error; } - const publishResults = await Promise.allSettled( - targets.map((url) => nostrClient.pool.publish([url], signedEvent)) + const publishResults = await publishEventToRelays( + nostrClient.pool, + targets, + signedEvent ); - const accepted = []; - const failed = []; - - publishResults.forEach((result, index) => { - const url = targets[index]; - if (result.status === "fulfilled") { - accepted.push(url); - } else { - failed.push({ url, reason: result.reason || null }); + let publishSummary; + try { + publishSummary = assertAnyRelayAccepted(publishResults, { + context: "relay preferences update", + message: "No relays accepted the update.", + }); + } catch (publishError) { + if (publishError?.relayFailures?.length) { + publishError.relayFailures.forEach( + ({ url, error: relayError, reason }) => { + console.error( + `[RelayPreferencesManager] Relay ${url} rejected relay list: ${reason}`, + relayError || reason + ); + } + ); } - }); - - const ok = accepted.length > 0; - if (ok) { - this.lastEvent = signedEvent; + throw publishError; } - return { ok, event: signedEvent, accepted, failed, targets }; + if (publishSummary.failed.length) { + publishSummary.failed.forEach(({ url, error: relayError }) => { + const reason = + relayError instanceof Error + ? relayError.message + : relayError + ? String(relayError) + : "publish failed"; + console.warn( + `[RelayPreferencesManager] Relay ${url} did not acknowledge relay list: ${reason}`, + relayError + ); + }); + } + + const accepted = publishSummary.accepted.map(({ url }) => url); + const failed = publishSummary.failed.map(({ url, error: relayError }) => ({ + url, + reason: relayError || null, + })); + + this.lastEvent = signedEvent; + + return { ok: true, event: signedEvent, accepted, failed, targets }; } getLastLoadSource() { diff --git a/js/subscriptions.js b/js/subscriptions.js index 06d0519d..8b4b14f8 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -3,13 +3,61 @@ import { nostrClient, convertEventToVideo as sharedConvertEventToVideo, } from "./nostr.js"; -import { attachHealthBadges } from "./gridHealth.js"; import { attachUrlHealthBadges } from "./urlHealthObserver.js"; import { buildSubscriptionListEvent, SUBSCRIPTION_LIST_IDENTIFIER, } from "./nostrEventSchemas.js"; import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; +import { + publishEventToRelays, + assertAnyRelayAccepted, +} from "./nostrPublish.js"; + +let attachHealthBadgesLoader = null; +let attachHealthBadgesErrorLogged = false; + +function scheduleAttachHealthBadges(container) { + if (!container) { + return; + } + + if (!attachHealthBadgesLoader) { + attachHealthBadgesLoader = import("./gridHealth.js") + .then((module) => module?.attachHealthBadges || null) + .catch((error) => { + if (!attachHealthBadgesErrorLogged) { + attachHealthBadgesErrorLogged = true; + if (typeof console !== "undefined") { + console.warn( + "[SubscriptionsManager] Failed to load grid health helpers:", + error + ); + } + } + return null; + }); + } + + attachHealthBadgesLoader + .then((fn) => { + if (typeof fn === "function") { + try { + fn(container); + } catch (error) { + if (typeof console !== "undefined") { + console.warn( + "[SubscriptionsManager] attachHealthBadges threw:", + error + ); + } + } + } + }) + .catch(() => { + // Errors are already logged above; no-op here to keep Promise chains quiet. + }); +} function getAbsoluteShareUrl(nevent) { if (!nevent) { @@ -179,25 +227,62 @@ class SubscriptionsManager { content: cipherText, }); + let signedEvent; try { - const signedEvent = await window.nostr.signEvent(evt); - await Promise.all( - nostrClient.relays.map(async (relay) => { - try { - await nostrClient.pool.publish([relay], signedEvent); - } catch (e) { + signedEvent = await window.nostr.signEvent(evt); + } catch (signErr) { + console.error("Failed to sign subscription list:", signErr); + throw signErr; + } + + const publishResults = await publishEventToRelays( + nostrClient.pool, + nostrClient.relays, + signedEvent + ); + + let publishSummary; + try { + publishSummary = assertAnyRelayAccepted(publishResults, { + context: "subscription list", + }); + } catch (publishError) { + if (publishError?.relayFailures?.length) { + publishError.relayFailures.forEach( + ({ url, error: relayError, reason }) => { console.error( - `[SubscriptionsManager] Failed to publish to ${relay}`, - e + `[SubscriptionsManager] Subscription list rejected by ${url}: ${reason}`, + relayError || reason ); } - }) - ); - this.subsEventId = signedEvent.id; - console.log("Subscription list published, event id:", signedEvent.id); - } catch (signErr) { - console.error("Failed to sign/publish subscription list:", signErr); + ); + } + throw publishError; } + + if (publishSummary.failed.length) { + publishSummary.failed.forEach(({ url, error: relayError }) => { + const reason = + relayError instanceof Error + ? relayError.message + : relayError + ? String(relayError) + : "publish failed"; + console.warn( + `[SubscriptionsManager] Subscription list not accepted by ${url}: ${reason}`, + relayError + ); + }); + } + + this.subsEventId = signedEvent.id; + const acceptedUrls = publishSummary.accepted.map(({ url }) => url); + console.log( + "Subscription list published, event id:", + signedEvent.id, + "accepted relays:", + acceptedUrls + ); } /** @@ -609,7 +694,7 @@ class SubscriptionsManager { }); container.appendChild(fragment); - attachHealthBadges(container); + scheduleAttachHealthBadges(container); attachUrlHealthBadges(container, ({ badgeEl, url, eventId }) => { if (!window.app?.handleUrlHealthBadge) { return; diff --git a/js/userBlocks.js b/js/userBlocks.js index 56aaac8f..30228d81 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -1,6 +1,10 @@ // js/userBlocks.js import { nostrClient } from "./nostr.js"; import { buildBlockListEvent, BLOCK_LIST_IDENTIFIER } from "./nostrEventSchemas.js"; +import { + publishEventToRelays, + assertAnyRelayAccepted, +} from "./nostrPublish.js"; function normalizeHex(pubkey) { if (typeof pubkey !== "string") { @@ -257,16 +261,46 @@ class UserBlockListManager { const signedEvent = await window.nostr.signEvent(event); - await Promise.all( - nostrClient.relays.map(async (relay) => { - try { - await nostrClient.pool.publish([relay], signedEvent); - } catch (err) { - console.error(`[UserBlockList] Failed to publish to ${relay}:`, err); - } - }) + const publishResults = await publishEventToRelays( + nostrClient.pool, + nostrClient.relays, + signedEvent ); + let publishSummary; + try { + publishSummary = assertAnyRelayAccepted(publishResults, { + context: "block list", + }); + } catch (publishError) { + if (publishError?.relayFailures?.length) { + publishError.relayFailures.forEach( + ({ url, error: relayError, reason }) => { + console.error( + `[UserBlockList] Block list rejected by ${url}: ${reason}`, + relayError || reason + ); + } + ); + } + throw publishError; + } + + if (publishSummary.failed.length) { + publishSummary.failed.forEach(({ url, error: relayError }) => { + const reason = + relayError instanceof Error + ? relayError.message + : relayError + ? String(relayError) + : "publish failed"; + console.warn( + `[UserBlockList] Block list not accepted by ${url}: ${reason}`, + relayError + ); + }); + } + this.blockEventId = signedEvent.id; return signedEvent; } diff --git a/tests/nostr-publish-rejection.test.mjs b/tests/nostr-publish-rejection.test.mjs new file mode 100644 index 00000000..e02ba6a2 --- /dev/null +++ b/tests/nostr-publish-rejection.test.mjs @@ -0,0 +1,243 @@ +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; + +if (typeof globalThis.window === "undefined") { + globalThis.window = {}; +} + +const originalWindowNostr = window.nostr; + +window.nostr = { + ...(originalWindowNostr || {}), + signEvent: async (event) => ({ + ...event, + id: event?.id || `stub-${Math.random().toString(36).slice(2)}`, + sig: "stub-signature", + }), + nip04: { + ...(originalWindowNostr?.nip04 || {}), + encrypt: async () => "cipher-text", + }, +}; + +try { + const { RelayPublishError } = await import("../js/nostrPublish.js"); + const { nostrClient } = await import("../js/nostr.js"); + const { subscriptions } = await import("../js/subscriptions.js"); + const { userBlocks } = await import("../js/userBlocks.js"); + + function createRejectingPool(reason = "relay rejected") { + const calls = []; + return { + calls, + publish(urls, event) { + calls.push({ + urls: Array.isArray(urls) ? [...urls] : [], + event, + }); + return { + on(eventName, handler) { + if (eventName === "failed") { + handler(new Error(reason)); + } + return this; + }, + }; + }, + }; + } + + function snapshotRelayState(client) { + return { + relays: Array.isArray(client.relays) ? [...client.relays] : [], + readRelays: Array.isArray(client.readRelays) + ? [...client.readRelays] + : [], + writeRelays: Array.isArray(client.writeRelays) + ? [...client.writeRelays] + : [], + pool: client.pool, + }; + } + + function restoreRelayState(client, snapshot) { + client.relays = [...snapshot.relays]; + client.readRelays = [...snapshot.readRelays]; + client.writeRelays = [...snapshot.writeRelays]; + client.pool = snapshot.pool; + } + + function applyTestRelayState(client, relays, pool) { + client.relays = [...relays]; + client.readRelays = [...relays]; + client.writeRelays = [...relays]; + client.pool = pool; + } + + async function testPublishVideoRejectsWhenAllRelaysFail() { + const testRelays = [ + "wss://relay-a.example", + "wss://relay-b.example", + ]; + const pool = createRejectingPool("video rejected"); + const relaySnapshot = snapshotRelayState(nostrClient); + applyTestRelayState(nostrClient, testRelays, pool); + + try { + await assert.rejects( + nostrClient.publishVideo( + { + title: "Test video", + magnet: "magnet:?xt=urn:btih:TESTVIDEO1234567890", + mode: "live", + }, + "f".repeat(64) + ), + (error) => { + assert.ok( + error instanceof RelayPublishError, + "publishVideo should reject with RelayPublishError" + ); + assert.equal( + error.relayFailures.length, + testRelays.length, + "relayFailures should include every relay" + ); + const urls = error.relayFailures + .map((entry) => entry.url) + .sort(); + assert.deepEqual( + urls, + [...testRelays].sort(), + "relayFailures should include the rejecting relay URLs" + ); + return true; + } + ); + + assert.equal( + pool.calls.length, + testRelays.length, + "publishVideo should attempt each relay" + ); + } finally { + restoreRelayState(nostrClient, relaySnapshot); + } + } + + async function testPublishSubscriptionListRejectsWhenAllRelaysFail() { + const testRelays = [ + "wss://relay-a.example", + "wss://relay-b.example", + ]; + const pool = createRejectingPool("subscription rejected"); + const relaySnapshot = snapshotRelayState(nostrClient); + applyTestRelayState(nostrClient, testRelays, pool); + + const originalSubs = new Set(subscriptions.subscribedPubkeys); + const originalSubsEventId = subscriptions.subsEventId; + subscriptions.subscribedPubkeys = new Set(["abcdef1234567890"]); + subscriptions.subsEventId = null; + + try { + await assert.rejects( + subscriptions.publishSubscriptionList("f".repeat(64)), + (error) => { + assert.ok( + error instanceof RelayPublishError, + "publishSubscriptionList should reject with RelayPublishError" + ); + assert.equal( + error.relayFailures.length, + testRelays.length, + "relayFailures should include each relay for subscriptions" + ); + const urls = error.relayFailures + .map((entry) => entry.url) + .sort(); + assert.deepEqual(urls, [...testRelays].sort()); + return true; + } + ); + + assert.equal( + pool.calls.length, + testRelays.length, + "publishSubscriptionList should attempt each relay" + ); + assert.equal( + subscriptions.subsEventId, + null, + "subsEventId should remain unset on failure" + ); + } finally { + subscriptions.subscribedPubkeys = originalSubs; + subscriptions.subsEventId = originalSubsEventId; + restoreRelayState(nostrClient, relaySnapshot); + } + } + + async function testPublishBlockListRejectsWhenAllRelaysFail() { + const testRelays = [ + "wss://relay-a.example", + "wss://relay-b.example", + ]; + const pool = createRejectingPool("block list rejected"); + const relaySnapshot = snapshotRelayState(nostrClient); + applyTestRelayState(nostrClient, testRelays, pool); + + const originalBlocked = new Set(userBlocks.blockedPubkeys); + const originalBlockEventId = userBlocks.blockEventId; + userBlocks.blockedPubkeys = new Set(["abcd".repeat(16)]); + userBlocks.blockEventId = null; + + try { + await assert.rejects( + userBlocks.publishBlockList("f".repeat(64)), + (error) => { + assert.ok( + error instanceof RelayPublishError, + "publishBlockList should reject with RelayPublishError" + ); + assert.equal( + error.relayFailures.length, + testRelays.length, + "relayFailures should include each relay for block list" + ); + const urls = error.relayFailures + .map((entry) => entry.url) + .sort(); + assert.deepEqual(urls, [...testRelays].sort()); + return true; + } + ); + + assert.equal( + pool.calls.length, + testRelays.length, + "publishBlockList should attempt each relay" + ); + assert.equal( + userBlocks.blockEventId, + null, + "blockEventId should remain unset on failure" + ); + } finally { + userBlocks.blockedPubkeys = originalBlocked; + userBlocks.blockEventId = originalBlockEventId; + restoreRelayState(nostrClient, relaySnapshot); + } + } + + await testPublishVideoRejectsWhenAllRelaysFail(); + await testPublishSubscriptionListRejectsWhenAllRelaysFail(); + await testPublishBlockListRejectsWhenAllRelaysFail(); + + console.log("nostr publish rejection tests passed"); +} finally { + if (typeof originalWindowNostr === "undefined") { + delete window.nostr; + } else { + window.nostr = originalWindowNostr; + } +}