Files
bitvid/tests/subscription-publish-timeout.test.mjs
thePR0M3TH3ANandClaude Opus 4.8 47f2a9a865 [nostr] Fix spurious "failed" errors on unconfirmed list publishes across the board
Same class of bug as the subscription fix, applied everywhere an idempotent,
replaceable list is published with an optimistic UI: when no relay ACKs within the
10s window (slow/flaky relays), the event was still sent and almost always
persists (it shows up after a refresh), but assertAnyRelayAccepted threw -> the UI
showed a scary error / reverted, even though it worked.

- nostrPublish.js: add shared helpers relayPublishFailuresAreAllTimeouts() and
  assertAnyRelayAcceptedOrUnconfirmed() — the latter returns a summary flagged
  { unconfirmed: true } on all-timeout instead of throwing; explicit relay
  rejections (and pre-publish signer/encryption errors) still throw.
- Applied to the replaceable-list publishes: subscriptions (kind 30000, refactored
  to the shared helper), user mute/block list (10000), relay list (10002), DM
  relay hints (10050), hashtag preferences (30015).

Left as-is for now (higher-stakes, separate follow-up): the generic content
publish path (video/edit/delete/revert/rebroadcast) in publishHelpers/client.js —
same latent issue but the video outcome-describer needs careful handling.

Tests: tests/subscription-publish-timeout.test.mjs (8) now cover both the
classifier and assertAnyRelayAcceptedOrUnconfirmed (confirmed->summary,
all-timeout->soft, rejection->throws). hashtag-preferences suite green (14).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 15:20:58 -04:00

104 lines
4.5 KiB
JavaScript

// Subscribe/unsubscribe intermittently showed "Failed to update your
// subscriptions" but worked after a refresh — because the kind-30000 list event
// was SENT and persisted, but no relay ACKed it within the 10s window, so
// assertAnyRelayAccepted threw and the optimistic UI was reverted with a scary
// error. Fix: an all-timeout (unconfirmed) publish of the idempotent replaceable
// list is treated as a SOFT SUCCESS (reconciles on next load); only an explicit
// relay rejection (or a pre-publish signer/encryption error) fails loudly. This
// covers the classifier that makes that call.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-subscription-publish-timeout
// given: "a RelayPublishError from publishing the subscription list"
// when: "its per-relay failures are classified"
// then: "all-timeout => soft success; any explicit rejection => hard failure"
// observable_outcomes:
// - "every-relay-timeout classifies as all-timeouts (soft success)"
// - "an explicit rejection (e.g. 'blocked'/'auth-required') does NOT"
// - "no failures / empty does NOT (nothing to soft-succeed)"
// determinism_controls:
// - "constructs real RelayPublishError objects; no network/clock"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import test from "node:test";
import { strict as assert } from "node:assert";
import {
RelayPublishError,
relayPublishFailuresAreAllTimeouts as subscriptionPublishFailuresAreAllTimeouts,
assertAnyRelayAcceptedOrUnconfirmed,
} from "../js/nostrPublish.js";
// Build a RelayPublishError the same way assertAnyRelayAccepted does — from the
// per-relay result objects (success:false + an Error whose message is the reason).
const errFrom = (results) =>
new RelayPublishError("Failed to publish subscription list to any relay.", results, {
context: "subscription list",
});
const timeout = (url) => ({ url, success: false, error: new Error("publish timeout") });
const rejected = (url, reason) => ({ url, success: false, error: new Error(reason) });
test("every relay timing out is classified as all-timeouts (soft success)", () => {
const e = errFrom([timeout("wss://a"), timeout("wss://b"), timeout("wss://c")]);
assert.equal(subscriptionPublishFailuresAreAllTimeouts(e), true);
});
test("an explicit relay rejection is NOT all-timeouts (hard failure)", () => {
const e = errFrom([timeout("wss://a"), rejected("wss://b", "blocked: spam")]);
assert.equal(subscriptionPublishFailuresAreAllTimeouts(e), false);
});
test("only-rejections are NOT all-timeouts", () => {
const e = errFrom([rejected("wss://a", "auth-required"), rejected("wss://b", "restricted")]);
assert.equal(subscriptionPublishFailuresAreAllTimeouts(e), false);
});
test("no failures / empty does not soft-succeed", () => {
assert.equal(subscriptionPublishFailuresAreAllTimeouts(errFrom([])), false);
assert.equal(subscriptionPublishFailuresAreAllTimeouts(undefined), false);
assert.equal(subscriptionPublishFailuresAreAllTimeouts({}), false);
});
test("case-insensitive: 'Publish Timeout' still counts as a timeout", () => {
const e = errFrom([rejected("wss://a", "Publish Timeout")]);
assert.equal(subscriptionPublishFailuresAreAllTimeouts(e), true);
});
// assertAnyRelayAcceptedOrUnconfirmed is the shared helper every list-publish
// caller now uses (subscriptions/blocks/relay-list/dm-hints/hashtags).
const okResult = (url) => ({ url, success: true, error: null });
test("shared helper: a confirmed ack returns unconfirmed:false with accepted", () => {
const summary = assertAnyRelayAcceptedOrUnconfirmed(
[okResult("wss://a"), timeout("wss://b")],
{ context: "list" },
);
assert.equal(summary.unconfirmed, false);
assert.equal(summary.accepted.length, 1);
});
test("shared helper: all-timeout returns unconfirmed:true and does NOT throw", () => {
const summary = assertAnyRelayAcceptedOrUnconfirmed(
[timeout("wss://a"), timeout("wss://b")],
{ context: "list" },
);
assert.equal(summary.unconfirmed, true);
assert.equal(summary.accepted.length, 0);
assert.equal(summary.failed.length, 2);
});
test("shared helper: an explicit rejection still throws RelayPublishError", () => {
assert.throws(
() =>
assertAnyRelayAcceptedOrUnconfirmed(
[timeout("wss://a"), rejected("wss://b", "blocked: spam")],
{ context: "list" },
),
RelayPublishError,
);
});