commit 176f6899095595cb1413188df8a135a327f19b9c
parent aff65bf83ae026dde491e378cc6b5b3d86bae3d9
Author: triesap <tyson@radroots.org>
Date: Sun, 22 Mar 2026 15:54:14 +0000
discovery: track targeted repair outcomes
- preserve per-relay publish results through the transport and discovery output models
- report targeted refresh repair_results and remaining_repair_relays with per-relay repair audit records
- add relay-backed proof for mixed-success targeted refresh while updating existing refresh expectations
- validate with cargo metadata --format-version 1 --no-deps, cargo fmt --all --check, cargo check --locked, and cargo test --locked on an isolated target dir
Diffstat:
9 files changed, 490 insertions(+), 41 deletions(-)
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
@@ -238,6 +238,7 @@ impl MycSignerContext {
tracing::error!(
operation = ?record.operation,
outcome = ?record.outcome,
+ relay_url = record.relay_url.as_deref().unwrap_or(""),
connection_id = record.connection_id.as_deref().unwrap_or(""),
request_id = record.request_id.as_deref().unwrap_or(""),
relay_count = record.relay_count,
@@ -301,6 +302,7 @@ fn emit_operation_audit_trace(record: &MycOperationAuditRecord) {
| crate::audit::MycOperationAuditOutcome::Skipped => tracing::info!(
operation = ?record.operation,
outcome = ?record.outcome,
+ relay_url = record.relay_url.as_deref().unwrap_or(""),
connection_id = record.connection_id.as_deref().unwrap_or(""),
request_id = record.request_id.as_deref().unwrap_or(""),
relay_count = record.relay_count,
@@ -315,6 +317,7 @@ fn emit_operation_audit_trace(record: &MycOperationAuditRecord) {
| crate::audit::MycOperationAuditOutcome::Conflicted => tracing::warn!(
operation = ?record.operation,
outcome = ?record.outcome,
+ relay_url = record.relay_url.as_deref().unwrap_or(""),
connection_id = record.connection_id.as_deref().unwrap_or(""),
request_id = record.request_id.as_deref().unwrap_or(""),
relay_count = record.relay_count,
diff --git a/src/audit.rs b/src/audit.rs
@@ -24,6 +24,7 @@ pub enum MycOperationAuditKind {
DiscoveryHandlerPublish,
DiscoveryHandlerCompare,
DiscoveryHandlerRefresh,
+ DiscoveryHandlerRepair,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -46,6 +47,8 @@ pub struct MycOperationAuditRecord {
pub operation: MycOperationAuditKind,
pub outcome: MycOperationAuditOutcome,
#[serde(default, skip_serializing_if = "Option::is_none")]
+ pub relay_url: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
pub connection_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
@@ -74,6 +77,7 @@ impl MycOperationAuditRecord {
recorded_at_unix: now_unix_secs(),
operation,
outcome,
+ relay_url: None,
connection_id: connection_id.map(ToString::to_string),
request_id: request_id.map(ToOwned::to_owned),
relay_count,
@@ -81,6 +85,11 @@ impl MycOperationAuditRecord {
relay_outcome_summary: relay_outcome_summary.into(),
}
}
+
+ pub fn with_relay_url(mut self, relay_url: impl Into<String>) -> Self {
+ self.relay_url = Some(relay_url.into());
+ self
+ }
}
impl MycOperationAuditStore {
diff --git a/src/cli.rs b/src/cli.rs
@@ -522,6 +522,7 @@ fn operation_kind_label(kind: MycOperationAuditKind) -> String {
MycOperationAuditKind::DiscoveryHandlerPublish => "discovery_handler_publish".to_owned(),
MycOperationAuditKind::DiscoveryHandlerCompare => "discovery_handler_compare".to_owned(),
MycOperationAuditKind::DiscoveryHandlerRefresh => "discovery_handler_refresh".to_owned(),
+ MycOperationAuditKind::DiscoveryHandlerRepair => "discovery_handler_repair".to_owned(),
}
}
diff --git a/src/discovery.rs b/src/discovery.rs
@@ -1,4 +1,4 @@
-use std::collections::BTreeMap;
+use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -18,7 +18,7 @@ use crate::app::MycRuntime;
use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord};
use crate::config::MycDiscoveryMetadataConfig;
use crate::error::MycError;
-use crate::transport::MycNostrTransport;
+use crate::transport::{MycNostrTransport, MycRelayPublishResult};
const NIP46_RPC_KIND: u32 = 24_133;
const DISCOVERY_BUNDLE_VERSION: u32 = 1;
@@ -78,9 +78,27 @@ pub struct MycPublishedNip89Output {
pub relay_count: usize,
pub acknowledged_relay_count: usize,
pub relay_outcome_summary: String,
+ pub relay_results: Vec<MycRelayPublishResult>,
pub event: RadrootsNostrEvent,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum MycDiscoveryRepairOutcome {
+ Repaired,
+ Failed,
+ Unchanged,
+ Skipped,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct MycDiscoveryRelayRepairResult {
+ pub relay_url: String,
+ pub outcome: MycDiscoveryRepairOutcome,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub detail: Option<String>,
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MycDiscoveryLiveStatus {
@@ -182,6 +200,8 @@ pub struct MycRefreshedNip89Output {
pub live_groups: Vec<MycLiveNip89Group>,
pub relay_states: Vec<MycDiscoveryRelayState>,
pub relay_summary: MycDiscoveryRelaySummary,
+ pub repair_results: Vec<MycDiscoveryRelayRepairResult>,
+ pub remaining_repair_relays: Vec<String>,
pub published: Option<MycPublishedNip89Output>,
}
@@ -526,6 +546,7 @@ async fn publish_nip89_event_to_relays(
relay_count: publish_outcome.relay_count,
acknowledged_relay_count: publish_outcome.acknowledged_relay_count,
relay_outcome_summary: publish_outcome.relay_outcome_summary,
+ relay_results: publish_outcome.relay_results,
event,
})
}
@@ -629,6 +650,7 @@ pub async fn refresh_nip89(
let refresh_relays = select_refresh_relays(&context, &relay_states, force)?;
if refresh_relays.is_empty() {
+ let repair_results = build_repair_results(&context, &relay_states, &[], None, None);
runtime.record_operation_audit(&MycOperationAuditRecord::new(
MycOperationAuditKind::DiscoveryHandlerRefresh,
MycOperationAuditOutcome::Skipped,
@@ -645,20 +667,46 @@ pub async fn refresh_nip89(
live_groups,
relay_states,
relay_summary,
+ repair_results,
+ remaining_repair_relays: Vec::new(),
published: None,
});
}
- let published = publish_nip89_event_to_relays(runtime, &context, &refresh_relays).await?;
- Ok(MycRefreshedNip89Output {
- status,
- force,
- differing_fields,
- live_groups,
- relay_states,
- relay_summary,
- published: Some(published),
- })
+ match publish_nip89_event_to_relays(runtime, &context, &refresh_relays).await {
+ Ok(published) => {
+ let repair_results = build_repair_results(
+ &context,
+ &relay_states,
+ &refresh_relays,
+ Some(published.relay_results.as_slice()),
+ None,
+ );
+ record_refresh_repair_audit(
+ runtime,
+ Some(published.event.id.to_hex()),
+ &repair_results,
+ );
+ let remaining_repair_relays = remaining_repair_relays(&repair_results);
+ return Ok(MycRefreshedNip89Output {
+ status,
+ force,
+ differing_fields,
+ live_groups,
+ relay_states,
+ relay_summary,
+ repair_results,
+ remaining_repair_relays,
+ published: Some(published),
+ });
+ }
+ Err(error) => {
+ let repair_results =
+ build_repair_results(&context, &relay_states, &refresh_relays, None, Some(&error));
+ record_refresh_repair_audit(runtime, None, &repair_results);
+ return Err(error);
+ }
+ }
}
fn select_refresh_relays(
@@ -703,6 +751,132 @@ fn select_refresh_relays(
}
}
+fn build_repair_results(
+ context: &MycDiscoveryContext,
+ relay_states: &[MycDiscoveryRelayState],
+ refresh_relays: &[RadrootsNostrRelayUrl],
+ publish_results: Option<&[MycRelayPublishResult]>,
+ publish_error: Option<&MycError>,
+) -> Vec<MycDiscoveryRelayRepairResult> {
+ let selected_relays = refresh_relays
+ .iter()
+ .map(ToString::to_string)
+ .collect::<BTreeSet<_>>();
+ let publish_results_by_relay = publish_results
+ .unwrap_or_default()
+ .iter()
+ .map(|result| (result.relay_url.clone(), result))
+ .collect::<BTreeMap<_, _>>();
+ let rejected_relays = publish_error
+ .and_then(MycError::publish_rejected_relays)
+ .unwrap_or_default()
+ .iter()
+ .cloned()
+ .collect::<BTreeSet<_>>();
+
+ context
+ .publish_relays()
+ .iter()
+ .zip(relay_states.iter())
+ .map(|(relay, relay_state)| {
+ let relay_url = relay.to_string();
+ if selected_relays.contains(&relay_url) {
+ if let Some(result) = publish_results_by_relay.get(&relay_url) {
+ return MycDiscoveryRelayRepairResult {
+ relay_url,
+ outcome: if result.acknowledged {
+ MycDiscoveryRepairOutcome::Repaired
+ } else {
+ MycDiscoveryRepairOutcome::Failed
+ },
+ detail: result.detail.clone(),
+ };
+ }
+
+ if rejected_relays.contains(&relay_url) {
+ return MycDiscoveryRelayRepairResult {
+ relay_url,
+ outcome: MycDiscoveryRepairOutcome::Failed,
+ detail: Some(
+ publish_error
+ .and_then(MycError::publish_rejection_details)
+ .map(ToOwned::to_owned)
+ .unwrap_or_else(|| "targeted refresh publish failed".to_owned()),
+ ),
+ };
+ }
+
+ return MycDiscoveryRelayRepairResult {
+ relay_url,
+ outcome: MycDiscoveryRepairOutcome::Failed,
+ detail: Some("no relay publish result was reported".to_owned()),
+ };
+ }
+
+ if relay_state.fetch_status == MycDiscoveryRelayFetchStatus::Unavailable {
+ return MycDiscoveryRelayRepairResult {
+ relay_url,
+ outcome: MycDiscoveryRepairOutcome::Skipped,
+ detail: relay_state.fetch_error.clone(),
+ };
+ }
+
+ match relay_state.live_status {
+ Some(MycDiscoveryLiveStatus::Matched) => MycDiscoveryRelayRepairResult {
+ relay_url,
+ outcome: MycDiscoveryRepairOutcome::Unchanged,
+ detail: None,
+ },
+ _ => MycDiscoveryRelayRepairResult {
+ relay_url,
+ outcome: MycDiscoveryRepairOutcome::Skipped,
+ detail: None,
+ },
+ }
+ })
+ .collect()
+}
+
+fn remaining_repair_relays(repair_results: &[MycDiscoveryRelayRepairResult]) -> Vec<String> {
+ repair_results
+ .iter()
+ .filter(|result| result.outcome == MycDiscoveryRepairOutcome::Failed)
+ .map(|result| result.relay_url.clone())
+ .collect()
+}
+
+fn record_refresh_repair_audit(
+ runtime: &MycRuntime,
+ request_id: Option<String>,
+ repair_results: &[MycDiscoveryRelayRepairResult],
+) {
+ for result in repair_results {
+ let Some((outcome, acknowledged_relay_count)) = (match result.outcome {
+ MycDiscoveryRepairOutcome::Repaired => Some((MycOperationAuditOutcome::Succeeded, 1)),
+ MycDiscoveryRepairOutcome::Failed => Some((MycOperationAuditOutcome::Rejected, 0)),
+ MycDiscoveryRepairOutcome::Unchanged | MycDiscoveryRepairOutcome::Skipped => None,
+ }) else {
+ continue;
+ };
+
+ runtime.record_operation_audit(
+ &MycOperationAuditRecord::new(
+ MycOperationAuditKind::DiscoveryHandlerRepair,
+ outcome,
+ None,
+ request_id.as_deref(),
+ 1,
+ acknowledged_relay_count,
+ result
+ .detail
+ .clone()
+ .unwrap_or_else(|| result.relay_url.clone()),
+ )
+ .with_relay_url(result.relay_url.clone()),
+ );
+ }
+}
+
async fn fetch_live_nip89_state_for_runtime(
runtime: &MycRuntime,
context: &MycDiscoveryContext,
diff --git a/src/error.rs b/src/error.rs
@@ -99,6 +99,7 @@ pub enum MycError {
relay_count: usize,
acknowledged_relay_count: usize,
details: String,
+ rejected_relays: Vec<String>,
},
#[error(
"configured signer identity `{configured_identity_id}` at {identity_path} does not match persisted signer identity `{persisted_identity_id}` in {state_path}"
@@ -129,4 +130,13 @@ impl MycError {
_ => None,
}
}
+
+ pub fn publish_rejected_relays(&self) -> Option<&[String]> {
+ match self {
+ Self::PublishRejected {
+ rejected_relays, ..
+ } => Some(rejected_relays.as_slice()),
+ _ => None,
+ }
+ }
}
diff --git a/src/lib.rs b/src/lib.rs
@@ -24,14 +24,15 @@ pub use control::{MycAcceptedConnectionOutput, MycAuthorizedReplayOutput};
pub use discovery::{
MycDiscoveryBundleManifest, MycDiscoveryBundleOutput, MycDiscoveryContext,
MycDiscoveryDiffOutput, MycDiscoveryLiveStatus, MycDiscoveryRelayFetchStatus,
- MycDiscoveryRelayState, MycDiscoveryRelaySummary, MycFetchedLiveNip89Output, MycLiveNip89Event,
- MycLiveNip89Group, MycLiveNip89RelayState, MycNip05Document, MycNip05DocumentSection,
- MycNip89HandlerDocument, MycNormalizedNip89Handler, MycPublishedNip89Output,
- MycRefreshedNip89Output, MycRenderedNip05Output, MycRenderedNip89Output, diff_live_nip89,
- fetch_live_nip89, publish_nip89_event, refresh_nip89, render_nip05_output, verify_bundle,
+ MycDiscoveryRelayRepairResult, MycDiscoveryRelayState, MycDiscoveryRelaySummary,
+ MycDiscoveryRepairOutcome, MycFetchedLiveNip89Output, MycLiveNip89Event, MycLiveNip89Group,
+ MycLiveNip89RelayState, MycNip05Document, MycNip05DocumentSection, MycNip89HandlerDocument,
+ MycNormalizedNip89Handler, MycPublishedNip89Output, MycRefreshedNip89Output,
+ MycRenderedNip05Output, MycRenderedNip89Output, diff_live_nip89, fetch_live_nip89,
+ publish_nip89_event, refresh_nip89, render_nip05_output, verify_bundle,
};
pub use error::MycError;
-pub use transport::{MycNostrTransport, MycTransportSnapshot};
+pub use transport::{MycNostrTransport, MycRelayPublishResult, MycTransportSnapshot};
pub async fn run() -> Result<(), MycError> {
let config = MycConfig::load_from_default_path_if_exists()?;
diff --git a/src/transport.rs b/src/transport.rs
@@ -1,5 +1,6 @@
pub mod nip46;
+use std::collections::{BTreeMap, BTreeSet};
use std::time::Duration;
use radroots_identity::RadrootsIdentity;
@@ -7,6 +8,7 @@ use radroots_nostr::prelude::{
RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrOutput,
RadrootsNostrRelayUrl,
};
+use serde::Serialize;
use crate::config::MycTransportConfig;
use crate::error::MycError;
@@ -32,6 +34,15 @@ pub struct MycPublishOutcome {
pub relay_count: usize,
pub acknowledged_relay_count: usize,
pub relay_outcome_summary: String,
+ pub relay_results: Vec<MycRelayPublishResult>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct MycRelayPublishResult {
+ pub relay_url: String,
+ pub acknowledged: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub detail: Option<String>,
}
impl MycNostrTransport {
@@ -94,7 +105,7 @@ impl MycNostrTransport {
.wait_for_connection(Duration::from_secs(connect_timeout_secs))
.await;
let output = client.send_event_builder(event).await?;
- ensure_publish_confirmed(output, "one-shot Nostr publish")
+ ensure_publish_confirmed(relays, output, "one-shot Nostr publish")
}
pub async fn publish_event_once(
@@ -118,7 +129,7 @@ impl MycNostrTransport {
.wait_for_connection(Duration::from_secs(connect_timeout_secs))
.await;
let output = client.send_event(event).await?;
- ensure_publish_confirmed(output, "one-shot Nostr publish")
+ ensure_publish_confirmed(relays, output, "one-shot Nostr publish")
}
pub fn snapshot(&self) -> MycTransportSnapshot {
@@ -131,21 +142,27 @@ impl MycNostrTransport {
}
pub(crate) fn ensure_publish_confirmed<T>(
+ relays: &[RadrootsNostrRelayUrl],
output: RadrootsNostrOutput<T>,
operation: &str,
) -> Result<MycPublishOutcome, MycError>
where
T: std::fmt::Debug,
{
- let relay_count = output.success.len() + output.failed.len();
- let acknowledged_relay_count = output.success.len();
- let relay_outcome_summary = summarize_publish_output(&output);
+ let relay_results = build_publish_relay_results(relays, &output);
+ let relay_count = relay_results.len();
+ let acknowledged_relay_count = relay_results
+ .iter()
+ .filter(|result| result.acknowledged)
+ .count();
+ let relay_outcome_summary = summarize_publish_results(&relay_results);
if !output.success.is_empty() {
return Ok(MycPublishOutcome {
relay_count,
acknowledged_relay_count,
relay_outcome_summary,
+ relay_results,
});
}
@@ -154,30 +171,81 @@ where
relay_count,
acknowledged_relay_count,
details: relay_outcome_summary,
+ rejected_relays: relay_results
+ .iter()
+ .filter(|result| !result.acknowledged)
+ .map(|result| result.relay_url.clone())
+ .collect(),
})
}
-fn summarize_publish_output<T>(output: &RadrootsNostrOutput<T>) -> String
+fn build_publish_relay_results<T>(
+ relays: &[RadrootsNostrRelayUrl],
+ output: &RadrootsNostrOutput<T>,
+) -> Vec<MycRelayPublishResult>
where
T: std::fmt::Debug,
{
- let relay_count = output.success.len() + output.failed.len();
- let acknowledged_relay_count = output.success.len();
+ let acknowledged_relays = output
+ .success
+ .iter()
+ .map(ToString::to_string)
+ .collect::<BTreeSet<_>>();
+ let failed_relays = output
+ .failed
+ .iter()
+ .map(|(relay, error)| (relay.to_string(), error.to_string()))
+ .collect::<BTreeMap<_, _>>();
+
+ relays
+ .iter()
+ .map(|relay| {
+ let relay_url = relay.to_string();
+ if acknowledged_relays.contains(&relay_url) {
+ MycRelayPublishResult {
+ relay_url,
+ acknowledged: true,
+ detail: None,
+ }
+ } else {
+ MycRelayPublishResult {
+ relay_url: relay_url.clone(),
+ acknowledged: false,
+ detail: Some(
+ failed_relays
+ .get(&relay_url)
+ .cloned()
+ .unwrap_or_else(|| "no relay acknowledgement reported".to_owned()),
+ ),
+ }
+ }
+ })
+ .collect()
+}
+
+fn summarize_publish_results(relay_results: &[MycRelayPublishResult]) -> String {
+ let relay_count = relay_results.len();
+ let acknowledged_relay_count = relay_results
+ .iter()
+ .filter(|result| result.acknowledged)
+ .count();
if relay_count == 0 {
return "no relay acknowledged the publish".to_owned();
}
let mut summary =
format!("{acknowledged_relay_count}/{relay_count} relays acknowledged publish");
- if !output.failed.is_empty() {
- let failures = output
- .failed
- .iter()
- .map(|(relay, error)| format!("{relay}: {error}"))
- .collect::<Vec<_>>()
- .join("; ");
+ let failures = relay_results
+ .iter()
+ .filter(|result| !result.acknowledged)
+ .map(|result| match result.detail.as_deref() {
+ Some(detail) => format!("{}: {detail}", result.relay_url),
+ None => result.relay_url.clone(),
+ })
+ .collect::<Vec<_>>();
+ if !failures.is_empty() {
summary.push_str("; failures: ");
- summary.push_str(&failures);
+ summary.push_str(&failures.join("; "));
}
summary
}
diff --git a/src/transport/nip46.rs b/src/transport/nip46.rs
@@ -518,8 +518,11 @@ impl MycNip46Service {
continue;
}
};
- if let Err(error) = ensure_publish_confirmed(publish_output, "NIP-46 response publish")
- {
+ if let Err(error) = ensure_publish_confirmed(
+ self.transport.relays(),
+ publish_output,
+ "NIP-46 response publish",
+ ) {
self.handler
.signer
.record_operation_audit(&MycOperationAuditRecord::new(
diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs
@@ -7,9 +7,9 @@ use futures_util::{SinkExt, StreamExt};
use myc::control;
use myc::{
MycConfig, MycConnectionApproval, MycDiscoveryContext, MycDiscoveryLiveStatus,
- MycDiscoveryRelayFetchStatus, MycOperationAuditKind, MycOperationAuditOutcome,
- MycOperationAuditRecord, MycRuntime, diff_live_nip89, fetch_live_nip89, publish_nip89_event,
- refresh_nip89,
+ MycDiscoveryRelayFetchStatus, MycDiscoveryRepairOutcome, MycOperationAuditKind,
+ MycOperationAuditOutcome, MycOperationAuditRecord, MycRuntime, diff_live_nip89,
+ fetch_live_nip89, publish_nip89_event, refresh_nip89,
};
use nostr::filter::MatchEventOptions;
use nostr::nips::nip44;
@@ -1391,8 +1391,14 @@ async fn refresh_nip89_publishes_when_live_handler_is_missing() -> TestResult<()
assert_eq!(refreshed.differing_fields, vec!["live_groups".to_owned()]);
assert!(refreshed.live_groups.is_empty());
assert!(refreshed.published.is_some());
+ assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new());
+ assert_eq!(refreshed.repair_results.len(), 1);
+ assert_eq!(
+ refreshed.repair_results[0].outcome,
+ MycDiscoveryRepairOutcome::Repaired
+ );
- let audit = wait_for_operation_audit_count(&runtime, 2).await?;
+ let audit = wait_for_operation_audit_count(&runtime, 3).await?;
assert_eq!(
audit[0].operation,
MycOperationAuditKind::DiscoveryHandlerCompare
@@ -1403,6 +1409,11 @@ async fn refresh_nip89_publishes_when_live_handler_is_missing() -> TestResult<()
MycOperationAuditKind::DiscoveryHandlerPublish
);
assert_eq!(audit[1].outcome, MycOperationAuditOutcome::Succeeded);
+ assert_eq!(
+ audit[2].operation,
+ MycOperationAuditKind::DiscoveryHandlerRepair
+ );
+ assert_eq!(audit[2].outcome, MycOperationAuditOutcome::Succeeded);
Ok(())
}
@@ -1444,6 +1455,26 @@ async fn refresh_nip89_repairs_missing_relays_without_republishing_matched_relay
assert_eq!(published.publish_relays, vec![relay_b.url().to_owned()]);
assert_eq!(published.relay_count, 1);
assert_eq!(published.acknowledged_relay_count, 1);
+ assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new());
+ assert_eq!(refreshed.repair_results.len(), 2);
+ assert_eq!(
+ refreshed
+ .repair_results
+ .iter()
+ .find(|result| result.relay_url == relay_a.url())
+ .expect("matched relay repair result")
+ .outcome,
+ MycDiscoveryRepairOutcome::Unchanged
+ );
+ assert_eq!(
+ refreshed
+ .repair_results
+ .iter()
+ .find(|result| result.relay_url == relay_b.url())
+ .expect("repaired relay result")
+ .outcome,
+ MycDiscoveryRepairOutcome::Repaired
+ );
relay_b
.wait_for_published_events_by_author(app_identity.public_key(), 1)
@@ -1500,6 +1531,12 @@ async fn refresh_nip89_skips_when_live_handler_matches() -> TestResult<()> {
assert!(refreshed.differing_fields.is_empty());
assert_eq!(refreshed.live_groups.len(), 1);
assert!(refreshed.published.is_none());
+ assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new());
+ assert_eq!(refreshed.repair_results.len(), 1);
+ assert_eq!(
+ refreshed.repair_results[0].outcome,
+ MycDiscoveryRepairOutcome::Unchanged
+ );
let audit = wait_for_operation_audit_count(&runtime, 3).await?;
assert_eq!(
@@ -1552,6 +1589,12 @@ async fn refresh_nip89_republishes_when_live_handler_drifted() -> TestResult<()>
assert_eq!(refreshed.status, MycDiscoveryLiveStatus::Drifted);
assert_eq!(refreshed.live_groups.len(), 1);
assert!(refreshed.published.is_some());
+ assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new());
+ assert_eq!(refreshed.repair_results.len(), 1);
+ assert_eq!(
+ refreshed.repair_results[0].outcome,
+ MycDiscoveryRepairOutcome::Repaired
+ );
assert!(
refreshed
.differing_fields
@@ -1559,7 +1602,7 @@ async fn refresh_nip89_republishes_when_live_handler_drifted() -> TestResult<()>
.any(|field| field == "relays" || field == "nostrconnect_url" || field == "metadata")
);
- let audit = wait_for_operation_audit_count(&runtime, 2).await?;
+ let audit = wait_for_operation_audit_count(&runtime, 3).await?;
assert_eq!(
audit[0].operation,
MycOperationAuditKind::DiscoveryHandlerCompare
@@ -1570,6 +1613,11 @@ async fn refresh_nip89_republishes_when_live_handler_drifted() -> TestResult<()>
MycOperationAuditKind::DiscoveryHandlerPublish
);
assert_eq!(audit[1].outcome, MycOperationAuditOutcome::Succeeded);
+ assert_eq!(
+ audit[2].operation,
+ MycOperationAuditKind::DiscoveryHandlerRepair
+ );
+ assert_eq!(audit[2].outcome, MycOperationAuditOutcome::Succeeded);
Ok(())
}
@@ -1620,6 +1668,26 @@ async fn refresh_nip89_repairs_drifted_relays_without_force_when_other_relays_ma
assert_eq!(published.publish_relays, vec![relay_b.url().to_owned()]);
assert_eq!(published.relay_count, 1);
assert_eq!(published.acknowledged_relay_count, 1);
+ assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new());
+ assert_eq!(refreshed.repair_results.len(), 2);
+ assert_eq!(
+ refreshed
+ .repair_results
+ .iter()
+ .find(|result| result.relay_url == relay_a.url())
+ .expect("matched relay result")
+ .outcome,
+ MycDiscoveryRepairOutcome::Unchanged
+ );
+ assert_eq!(
+ refreshed
+ .repair_results
+ .iter()
+ .find(|result| result.relay_url == relay_b.url())
+ .expect("repaired relay result")
+ .outcome,
+ MycDiscoveryRepairOutcome::Repaired
+ );
relay_b
.wait_for_published_events_by_author(app_identity.public_key(), 2)
@@ -1643,6 +1711,118 @@ async fn refresh_nip89_repairs_drifted_relays_without_force_when_other_relays_ma
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn refresh_nip89_reports_remaining_relays_after_mixed_targeted_repair() -> TestResult<()> {
+ let relay_a = TestRelay::spawn().await?;
+ let relay_b = TestRelay::spawn().await?;
+ let test_runtime = MycTestRuntime::new_with_discovery_relays(
+ &[relay_a.url(), relay_b.url()],
+ MycConnectionApproval::ExplicitUser,
+ );
+ let runtime = test_runtime.runtime;
+ let app_identity = RadrootsIdentity::load_from_path_auto(
+ runtime
+ .config()
+ .discovery
+ .app_identity_path
+ .as_ref()
+ .expect("app identity path"),
+ )?;
+
+ relay_a
+ .queue_publish_outcomes(app_identity.public_key(), &[true])
+ .await;
+ relay_b
+ .queue_publish_outcomes(app_identity.public_key(), &[false])
+ .await;
+
+ let refreshed = refresh_nip89(&runtime, false).await?;
+ let published = refreshed.published.expect("published output");
+
+ assert_eq!(refreshed.status, MycDiscoveryLiveStatus::Missing);
+ assert_eq!(
+ published.publish_relays,
+ vec![relay_a.url().to_owned(), relay_b.url().to_owned()]
+ );
+ assert_eq!(published.relay_count, 2);
+ assert_eq!(published.acknowledged_relay_count, 1);
+ assert_eq!(published.relay_results.len(), 2);
+ assert_eq!(refreshed.repair_results.len(), 2);
+ assert_eq!(
+ refreshed.remaining_repair_relays,
+ vec![relay_b.url().to_owned()]
+ );
+
+ let repaired = refreshed
+ .repair_results
+ .iter()
+ .find(|result| result.relay_url == relay_a.url())
+ .expect("repaired relay result");
+ assert_eq!(repaired.outcome, MycDiscoveryRepairOutcome::Repaired);
+
+ let failed = refreshed
+ .repair_results
+ .iter()
+ .find(|result| result.relay_url == relay_b.url())
+ .expect("failed relay result");
+ assert_eq!(failed.outcome, MycDiscoveryRepairOutcome::Failed);
+ assert!(
+ failed
+ .detail
+ .as_deref()
+ .unwrap_or_default()
+ .contains("blocked by test relay")
+ );
+
+ relay_a
+ .wait_for_published_events_by_author(app_identity.public_key(), 1)
+ .await?;
+ assert_eq!(
+ relay_b
+ .published_events_by_author(app_identity.public_key())
+ .await
+ .len(),
+ 0
+ );
+
+ let diff = diff_live_nip89(&runtime).await?;
+ assert_eq!(diff.status, MycDiscoveryLiveStatus::Matched);
+ assert_eq!(
+ diff.relay_summary.matched_relays,
+ vec![relay_a.url().to_owned()]
+ );
+ assert_eq!(
+ diff.relay_summary.missing_relays,
+ vec![relay_b.url().to_owned()]
+ );
+
+ let audit = wait_for_operation_audit_count(&runtime, 4).await?;
+ assert_eq!(
+ audit[0].operation,
+ MycOperationAuditKind::DiscoveryHandlerCompare
+ );
+ assert_eq!(audit[0].outcome, MycOperationAuditOutcome::Missing);
+ assert_eq!(
+ audit[1].operation,
+ MycOperationAuditKind::DiscoveryHandlerPublish
+ );
+ assert_eq!(audit[1].outcome, MycOperationAuditOutcome::Succeeded);
+ assert_eq!(
+ audit[2].operation,
+ MycOperationAuditKind::DiscoveryHandlerRepair
+ );
+ assert_eq!(audit[2].outcome, MycOperationAuditOutcome::Succeeded);
+ assert_eq!(audit[2].relay_url.as_deref(), Some(relay_a.url()));
+ assert_eq!(
+ audit[3].operation,
+ MycOperationAuditKind::DiscoveryHandlerRepair
+ );
+ assert_eq!(audit[3].outcome, MycOperationAuditOutcome::Rejected);
+ assert_eq!(audit[3].relay_url.as_deref(), Some(relay_b.url()));
+
+ Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn diff_live_nip89_reports_conflicted_when_live_groups_disagree() -> TestResult<()> {
let relay = TestRelay::spawn().await?;
let test_runtime =