mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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]>
104 lines
4.5 KiB
JavaScript
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,
|
|
);
|
|
});
|