mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
signAndPublishEvent (video publish/edit/repost/mirror), rebroadcastEvent, and
revertVideo now use assertAnyRelayAcceptedOrUnconfirmed: when every relay
times out without rejecting, the event was sent and almost always persisted,
so it no longer surfaces as "Failed to share video". Explicit rejections
still throw.
Messaging stays honest at every consumer:
- relay tally carries `unconfirmed` (now attached on edits too);
describePublishOutcome maps 0-accepted+unconfirmed to a WARNING ("sent,
no relay confirmed yet") — never a fake relay count, never a hard error
- edit/revert controllers say "sent, not confirmed yet" when unconfirmed
- deletes stay CONSERVATIVE: "deleted successfully" only when >=1 relay
ACKed a tombstone or kind-5 (anyRelayAcceptedInSummaries); an unconfirmed
revert no longer aborts deleteAllVersions midway, so the kind-5 always runs
Tests: tests/relay-ack-unconfirmed-content.test.mjs (6).
Co-Authored-By: Claude Fable 5 <[email protected]>
169 lines
6.7 KiB
JavaScript
169 lines
6.7 KiB
JavaScript
// #49: relay-ack false-negatives on the FLAGSHIP content flows. When no relay
|
|
// ACKs within the publish window but nothing rejected (all-timeout), the event
|
|
// was sent and almost always persisted — it must surface as an UNCONFIRMED soft
|
|
// success, not "Failed to share video". Equally important: the outcome messaging
|
|
// must stay honest — unconfirmed is never reported as "published to N relays",
|
|
// and a delete is only "deleted" when at least one relay ACKed something.
|
|
//
|
|
// test_integrity_note:
|
|
// change_type: ["new_tests"]
|
|
// scenarios:
|
|
// - id: SCN-content-publish-all-timeout
|
|
// given: "a relay pool where every relay times out (reason 'publish timeout') vs one that rejects"
|
|
// when: "signAndPublishEvent (video publish/edit path) runs"
|
|
// then: "all-timeout resolves with summary.unconfirmed=true; an explicit rejection still throws RelayPublishError"
|
|
// - id: SCN-honest-outcome-messaging
|
|
// given: "relay tallies including the new unconfirmed flag"
|
|
// when: "describePublishOutcome / attachRelayPublishSummary / anyRelayAcceptedInSummaries run"
|
|
// then: "unconfirmed -> warning ('sent, not confirmed'), never a relay-count success and never a hard error; deletes only 'confirmed' with >=1 ACK"
|
|
// observable_outcomes:
|
|
// - "signAndPublishEvent resolves (no throw) on all-timeout with unconfirmed summary"
|
|
// - "signAndPublishEvent still throws on explicit rejection"
|
|
// - "unconfirmed tally round-trips through attach/readRelayPublishSummary"
|
|
// - "describePublishOutcome(unconfirmed) is a warning with honest wording"
|
|
// determinism_controls:
|
|
// - "fake pool fires ok/failed handlers synchronously; no real sockets or timers race the 10s window"
|
|
// anti_cheat_rationale:
|
|
// prevents: ["hard-coded return value", "over-mocking internal logic", "snapshot rubber-stamping"]
|
|
// relaxation:
|
|
// did_relax_any_assertion: false
|
|
|
|
import test from "node:test";
|
|
import { strict as assert } from "node:assert";
|
|
import {
|
|
RelayPublishError,
|
|
describePublishOutcome,
|
|
attachRelayPublishSummary,
|
|
readRelayPublishSummary,
|
|
anyRelayAcceptedInSummaries,
|
|
} from "../js/nostrPublish.js";
|
|
import { signAndPublishEvent } from "../js/nostr/publishHelpers.js";
|
|
|
|
const PUB = "a".repeat(64);
|
|
|
|
// Fake nostr-tools pool: publish([url]) returns a handle whose "failed" (or
|
|
// "ok") handler fires immediately with the scripted outcome for that relay.
|
|
function makePool(outcomes) {
|
|
let call = 0;
|
|
return {
|
|
publish(urls, _event) {
|
|
const outcome = outcomes[call % outcomes.length];
|
|
call += 1;
|
|
return {
|
|
on(eventName, handler) {
|
|
if (outcome === "ok" && eventName === "ok") {
|
|
handler();
|
|
} else if (outcome !== "ok" && eventName === "failed") {
|
|
handler(new Error(outcome));
|
|
}
|
|
return this;
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
const signer = {
|
|
pubkey: PUB,
|
|
signEvent: async (event) => ({ ...event, id: "signed-1", sig: "sig" }),
|
|
};
|
|
|
|
function publishWith(pool) {
|
|
return signAndPublishEvent({
|
|
client: {
|
|
pubkey: PUB,
|
|
pool,
|
|
relays: ["wss://a.example", "wss://b.example"],
|
|
writeRelays: ["wss://a.example", "wss://b.example"],
|
|
},
|
|
event: { kind: 30078, pubkey: PUB, created_at: 100, tags: [], content: "{}" },
|
|
resolveActiveSigner: () => signer,
|
|
shouldRequestExtensionPermissions: () => false,
|
|
options: { context: "video note", logName: "Video note" },
|
|
});
|
|
}
|
|
|
|
test("all-timeout video publish resolves as an unconfirmed soft success", async () => {
|
|
const result = await publishWith(makePool(["publish timeout", "publish timeout"]));
|
|
assert.equal(result.signedEvent.id, "signed-1");
|
|
assert.equal(result.summary.unconfirmed, true, "flagged unconfirmed");
|
|
assert.equal(result.summary.accepted.length, 0, "no fake acceptances");
|
|
assert.equal(result.summary.failed.length, 2, "both timeouts recorded");
|
|
});
|
|
|
|
test("an explicit relay rejection still hard-fails the publish", async () => {
|
|
await assert.rejects(
|
|
publishWith(makePool(["publish timeout", "blocked: spam"])),
|
|
RelayPublishError,
|
|
);
|
|
});
|
|
|
|
test("a confirmed ack stays a confirmed (not unconfirmed) success", async () => {
|
|
const result = await publishWith(makePool(["ok", "publish timeout"]));
|
|
assert.equal(result.summary.unconfirmed, false);
|
|
assert.equal(result.summary.accepted.length, 1);
|
|
});
|
|
|
|
test("the attached tally carries the unconfirmed flag to the UI", () => {
|
|
const event = { id: "e1", kind: 30078 };
|
|
attachRelayPublishSummary(event, {
|
|
accepted: [],
|
|
failed: [{ url: "wss://a" }, { url: "wss://b" }],
|
|
unconfirmed: true,
|
|
});
|
|
assert.deepEqual(readRelayPublishSummary(event), {
|
|
accepted: 0,
|
|
total: 2,
|
|
unconfirmed: true,
|
|
});
|
|
|
|
// Confirmed publishes keep the exact legacy tally shape (no stray flag).
|
|
const confirmed = { id: "e2" };
|
|
attachRelayPublishSummary(confirmed, {
|
|
accepted: [{ url: "wss://a" }],
|
|
failed: [],
|
|
});
|
|
assert.deepEqual(readRelayPublishSummary(confirmed), { accepted: 1, total: 1 });
|
|
});
|
|
|
|
test("describePublishOutcome: unconfirmed is an honest warning, not success or error", () => {
|
|
const out = describePublishOutcome({ accepted: 0, total: 3, unconfirmed: true });
|
|
assert.equal(out.tone, "warning");
|
|
assert.match(out.message, /no relay has confirmed/i);
|
|
assert.doesNotMatch(out.message, /\d+ relays?/, "must not fake a relay count");
|
|
assert.doesNotMatch(out.message, /could not be published/i);
|
|
|
|
// Without the flag, zero acceptance is still the hard error.
|
|
assert.equal(describePublishOutcome({ accepted: 0, total: 3 }).tone, "error");
|
|
// A real acceptance is unaffected by a stray flag.
|
|
assert.equal(
|
|
describePublishOutcome({ accepted: 2, total: 3, unconfirmed: false }).tone,
|
|
"warning",
|
|
);
|
|
assert.equal(describePublishOutcome({ accepted: 3, total: 3 }).tone, "success");
|
|
});
|
|
|
|
test("delete confirmation: only >=1 relay ACK counts as a confirmed delete", () => {
|
|
const unconfirmedSummary = { accepted: [], failed: [{ url: "wss://a" }], unconfirmed: true };
|
|
const confirmedSummary = { accepted: [{ url: "wss://b" }], failed: [] };
|
|
|
|
assert.equal(
|
|
anyRelayAcceptedInSummaries([unconfirmedSummary, unconfirmedSummary]),
|
|
false,
|
|
"all-timeout delete must NOT be reported as deleted",
|
|
);
|
|
assert.equal(
|
|
anyRelayAcceptedInSummaries([unconfirmedSummary, confirmedSummary]),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
anyRelayAcceptedInSummaries([]),
|
|
true,
|
|
"nothing needed publishing counts as confirmed",
|
|
);
|
|
assert.equal(anyRelayAcceptedInSummaries([null, undefined]), true);
|
|
// Numeric tally shape (from readRelayPublishSummary) also works.
|
|
assert.equal(anyRelayAcceptedInSummaries([{ accepted: 0, total: 2 }]), false);
|
|
assert.equal(anyRelayAcceptedInSummaries([{ accepted: 1, total: 2 }]), true);
|
|
});
|