commit d8ad5e3bbf77649df87e7f9edbf497613ed7f271
parent a9d58022a41f36f2ec33fb53ecf9ed2e57360b80
Author: triesap <tyson@radroots.org>
Date: Mon, 15 Jun 2026 14:12:09 -0700
sdk: add sync outbox push runtime
Add the runtime sync API for publishing signed outbox events through relay adapters with product-level receipts. Cover accepted, retryable, terminal, invalid-limit, empty-queue, and unsigned-work skip behavior.
Diffstat:
5 files changed, 656 insertions(+), 3 deletions(-)
diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs
@@ -25,6 +25,7 @@ pub enum RadrootsSdkError {
TimestampOutOfRange { value: u64 },
Authority { message: String },
EventStore { message: String },
+ InvalidRequest { message: String },
ListingDraft { message: String },
ListingMutation { message: String },
Outbox { message: String },
@@ -64,6 +65,7 @@ impl fmt::Display for RadrootsSdkError {
}
Self::Authority { message } => write!(f, "sdk authority error: {message}"),
Self::EventStore { message } => write!(f, "sdk event store error: {message}"),
+ Self::InvalidRequest { message } => write!(f, "sdk invalid request: {message}"),
Self::ListingDraft { message } => write!(f, "sdk listing draft error: {message}"),
Self::ListingMutation { message } => {
write!(f, "sdk listing mutation error: {message}")
diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs
@@ -34,6 +34,8 @@ pub mod profile;
mod receipt;
#[cfg(feature = "runtime")]
mod runtime;
+#[cfg(feature = "runtime")]
+mod sync_runtime;
#[cfg(feature = "radrootsd-client")]
pub use crate::adapters::radrootsd::{
@@ -82,6 +84,11 @@ pub use crate::runtime::{
RadrootsSdk, RadrootsSdkBuilder, RadrootsSdkClock, RadrootsSdkStorageConfig,
RadrootsSdkStoragePaths, RadrootsSdkTimestamp,
};
+#[cfg(feature = "runtime")]
+pub use crate::sync_runtime::{
+ PUSH_OUTBOX_DEFAULT_LIMIT, PUSH_OUTBOX_MAX_LIMIT, PushOutboxEventReceipt, PushOutboxEventState,
+ PushOutboxReceipt, PushOutboxRelayOutcomeKind, PushOutboxRelayReceipt, PushOutboxRequest,
+};
pub use radroots_events::{
RadrootsNostrEvent, RadrootsNostrEventPtr, RadrootsNostrEventRef,
draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent},
diff --git a/crates/sdk/src/product_clients.rs b/crates/sdk/src/product_clients.rs
@@ -32,12 +32,12 @@ impl<'sdk> OrdersClient<'sdk> {
#[cfg(feature = "runtime")]
#[derive(Clone, Copy)]
pub struct SyncClient<'sdk> {
- _sdk: PhantomData<&'sdk RadrootsSdk>,
+ pub(crate) sdk: &'sdk RadrootsSdk,
}
#[cfg(feature = "runtime")]
impl<'sdk> SyncClient<'sdk> {
- pub(crate) fn new(_sdk: &'sdk RadrootsSdk) -> Self {
- Self { _sdk: PhantomData }
+ pub(crate) fn new(sdk: &'sdk RadrootsSdk) -> Self {
+ Self { sdk }
}
}
diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs
@@ -0,0 +1,277 @@
+#[cfg(feature = "runtime")]
+use crate::{RadrootsSdkError, SyncClient};
+#[cfg(feature = "runtime")]
+use radroots_outbox::RadrootsOutboxEventState;
+#[cfg(feature = "runtime")]
+use radroots_relay_transport::{
+ RadrootsOutboxPublishPolicy, RadrootsRelayOutcomeKind, RadrootsRelayPublishAdapter,
+ RadrootsRelayPublishReceipt, RadrootsRelayPublishRelayReceipt, publish_claimed_outbox_event,
+};
+
+#[cfg(feature = "runtime")]
+pub const PUSH_OUTBOX_DEFAULT_LIMIT: usize = 20;
+#[cfg(feature = "runtime")]
+pub const PUSH_OUTBOX_MAX_LIMIT: usize = 100;
+
+#[cfg(feature = "runtime")]
+const CLAIM_OWNER: &str = "radroots_sdk.sync.push_outbox";
+#[cfg(feature = "runtime")]
+const CLAIM_TTL_MS: i64 = 30_000;
+#[cfg(feature = "runtime")]
+const NEXT_ATTEMPT_DELAY_MS: i64 = 60_000;
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct PushOutboxRequest {
+ pub limit: usize,
+ pub republish_accepted_relays: bool,
+}
+
+#[cfg(feature = "runtime")]
+impl Default for PushOutboxRequest {
+ fn default() -> Self {
+ Self {
+ limit: PUSH_OUTBOX_DEFAULT_LIMIT,
+ republish_accepted_relays: false,
+ }
+ }
+}
+
+#[cfg(feature = "runtime")]
+impl PushOutboxRequest {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn with_limit(mut self, limit: usize) -> Self {
+ self.limit = limit;
+ self
+ }
+
+ pub fn republish_accepted_relays(mut self, enabled: bool) -> Self {
+ self.republish_accepted_relays = enabled;
+ self
+ }
+
+ fn validate(&self) -> Result<(), RadrootsSdkError> {
+ if self.limit == 0 || self.limit > PUSH_OUTBOX_MAX_LIMIT {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: format!("push_outbox limit must be between 1 and {PUSH_OUTBOX_MAX_LIMIT}"),
+ });
+ }
+ Ok(())
+ }
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct PushOutboxReceipt {
+ pub attempted_events: usize,
+ pub published_events: usize,
+ pub retryable_events: usize,
+ pub terminal_events: usize,
+ pub events: Vec<PushOutboxEventReceipt>,
+}
+
+#[cfg(feature = "runtime")]
+impl PushOutboxReceipt {
+ fn push_event(&mut self, event: PushOutboxEventReceipt) {
+ self.attempted_events += 1;
+ match event.final_state {
+ PushOutboxEventState::Published => self.published_events += 1,
+ PushOutboxEventState::PublishRetryable => self.retryable_events += 1,
+ PushOutboxEventState::FailedTerminal => self.terminal_events += 1,
+ _ => {}
+ }
+ self.events.push(event);
+ }
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct PushOutboxEventReceipt {
+ pub event_id: String,
+ pub outbox_event_id: i64,
+ pub final_state: PushOutboxEventState,
+ pub attempted_count: usize,
+ pub accepted_count: usize,
+ pub retryable_count: usize,
+ pub terminal_count: usize,
+ pub quorum: usize,
+ pub quorum_met: bool,
+ pub relays: Vec<PushOutboxRelayReceipt>,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct PushOutboxRelayReceipt {
+ pub relay_url: String,
+ pub outcome_kind: PushOutboxRelayOutcomeKind,
+ pub attempted: bool,
+ pub message: Option<String>,
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum PushOutboxEventState {
+ DraftQueued,
+ Signing,
+ Signed,
+ Publishing,
+ Published,
+ SignRetryable,
+ PublishRetryable,
+ FailedTerminal,
+ Cancelled,
+}
+
+#[cfg(feature = "runtime")]
+impl From<RadrootsOutboxEventState> for PushOutboxEventState {
+ fn from(state: RadrootsOutboxEventState) -> Self {
+ match state {
+ RadrootsOutboxEventState::DraftQueued => Self::DraftQueued,
+ RadrootsOutboxEventState::Signing => Self::Signing,
+ RadrootsOutboxEventState::Signed => Self::Signed,
+ RadrootsOutboxEventState::Publishing => Self::Publishing,
+ RadrootsOutboxEventState::Published => Self::Published,
+ RadrootsOutboxEventState::SignRetryable => Self::SignRetryable,
+ RadrootsOutboxEventState::PublishRetryable => Self::PublishRetryable,
+ RadrootsOutboxEventState::FailedTerminal => Self::FailedTerminal,
+ RadrootsOutboxEventState::Cancelled => Self::Cancelled,
+ }
+ }
+}
+
+#[cfg(feature = "runtime")]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum PushOutboxRelayOutcomeKind {
+ Accepted,
+ DuplicateAccepted,
+ Blocked,
+ RateLimited,
+ Invalid,
+ PowRequired,
+ Restricted,
+ AuthRequired,
+ Error,
+ Timeout,
+ ConnectionFailed,
+ Unknown,
+}
+
+#[cfg(feature = "runtime")]
+impl From<RadrootsRelayOutcomeKind> for PushOutboxRelayOutcomeKind {
+ fn from(kind: RadrootsRelayOutcomeKind) -> Self {
+ match kind {
+ RadrootsRelayOutcomeKind::Accepted => Self::Accepted,
+ RadrootsRelayOutcomeKind::DuplicateAccepted => Self::DuplicateAccepted,
+ RadrootsRelayOutcomeKind::Blocked => Self::Blocked,
+ RadrootsRelayOutcomeKind::RateLimited => Self::RateLimited,
+ RadrootsRelayOutcomeKind::Invalid => Self::Invalid,
+ RadrootsRelayOutcomeKind::PowRequired => Self::PowRequired,
+ RadrootsRelayOutcomeKind::Restricted => Self::Restricted,
+ RadrootsRelayOutcomeKind::AuthRequired => Self::AuthRequired,
+ RadrootsRelayOutcomeKind::Error => Self::Error,
+ RadrootsRelayOutcomeKind::Timeout => Self::Timeout,
+ RadrootsRelayOutcomeKind::ConnectionFailed => Self::ConnectionFailed,
+ RadrootsRelayOutcomeKind::Unknown => Self::Unknown,
+ }
+ }
+}
+
+#[cfg(feature = "runtime")]
+impl<'sdk> SyncClient<'sdk> {
+ pub async fn push_outbox<A>(
+ &self,
+ adapter: &A,
+ request: PushOutboxRequest,
+ ) -> Result<PushOutboxReceipt, RadrootsSdkError>
+ where
+ A: RadrootsRelayPublishAdapter,
+ {
+ request.validate()?;
+ let now_ms = sdk_now_ms(self.sdk)?;
+ let mut receipt = PushOutboxReceipt::default();
+ for index in 0..request.limit {
+ let claim_token = format!("radroots-sdk-sync-{now_ms}-{index}");
+ let Some(claimed) = self
+ .sdk
+ ._outbox
+ .claim_next_ready_signed_event(
+ CLAIM_OWNER,
+ claim_token.as_str(),
+ now_ms.saturating_add(CLAIM_TTL_MS),
+ now_ms,
+ )
+ .await?
+ else {
+ break;
+ };
+ let policy =
+ RadrootsOutboxPublishPolicy::new(now_ms.saturating_add(NEXT_ATTEMPT_DELAY_MS))
+ .republish_accepted_relays(request.republish_accepted_relays);
+ let publish = publish_claimed_outbox_event(
+ &self.sdk._outbox,
+ &self.sdk._event_store,
+ adapter,
+ &claimed,
+ policy,
+ now_ms,
+ )
+ .await?;
+ let outbox_event = self
+ .sdk
+ ._outbox
+ .get_event(claimed.outbox_event_id)
+ .await?
+ .ok_or_else(|| RadrootsSdkError::Outbox {
+ message: "published outbox event was not found after sync push".to_owned(),
+ })?;
+ receipt.push_event(push_event_receipt(
+ claimed.outbox_event_id,
+ outbox_event.state.into(),
+ publish.publish,
+ ));
+ }
+ Ok(receipt)
+ }
+}
+
+#[cfg(feature = "runtime")]
+fn sdk_now_ms(sdk: &crate::RadrootsSdk) -> Result<i64, RadrootsSdkError> {
+ let seconds = sdk.now()?.unix_seconds();
+ let millis = seconds
+ .checked_mul(1_000)
+ .ok_or(RadrootsSdkError::TimestampOutOfRange { value: seconds })?;
+ i64::try_from(millis).map_err(|_| RadrootsSdkError::TimestampOutOfRange { value: seconds })
+}
+
+#[cfg(feature = "runtime")]
+fn push_event_receipt(
+ outbox_event_id: i64,
+ final_state: PushOutboxEventState,
+ publish: RadrootsRelayPublishReceipt,
+) -> PushOutboxEventReceipt {
+ PushOutboxEventReceipt {
+ event_id: publish.event_id,
+ outbox_event_id,
+ final_state,
+ attempted_count: publish.attempted_count,
+ accepted_count: publish.accepted_count,
+ retryable_count: publish.retryable_count,
+ terminal_count: publish.terminal_count,
+ quorum: publish.quorum,
+ quorum_met: publish.quorum_met,
+ relays: publish.relays.into_iter().map(push_relay_receipt).collect(),
+ }
+}
+
+#[cfg(feature = "runtime")]
+fn push_relay_receipt(relay: RadrootsRelayPublishRelayReceipt) -> PushOutboxRelayReceipt {
+ PushOutboxRelayReceipt {
+ relay_url: relay.relay_url,
+ outcome_kind: relay.outcome.kind.into(),
+ attempted: relay.attempted,
+ message: relay.outcome.message,
+ }
+}
diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs
@@ -0,0 +1,367 @@
+#![cfg(feature = "runtime")]
+
+use radroots_authority::{
+ RadrootsActorContext, RadrootsEventSigner, RadrootsSignerError, RadrootsSignerIdentity,
+};
+use radroots_core::{
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
+ RadrootsCoreQuantityPrice, RadrootsCoreUnit,
+};
+use radroots_events::{
+ contract::RadrootsActorRole,
+ draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent, RadrootsSignedNostrEventParts},
+ farm::RadrootsFarmRef,
+ ids::{RadrootsDTag, RadrootsInventoryBinId},
+ listing::{RadrootsListing, RadrootsListingBin, RadrootsListingProduct},
+};
+use radroots_outbox::{RadrootsOutbox, RadrootsOutboxEventState, RadrootsOutboxOperationInput};
+use radroots_relay_transport::{RadrootsMockRelayPublishAdapter, RadrootsRelayOutcome};
+use radroots_sdk::{
+ ListingPublishRequest, PUSH_OUTBOX_DEFAULT_LIMIT, PUSH_OUTBOX_MAX_LIMIT, PushOutboxEventState,
+ PushOutboxRelayOutcomeKind, PushOutboxRequest, RadrootsSdk, RadrootsSdkError,
+ RadrootsSdkTimestamp,
+};
+
+const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+const FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA";
+const LISTING_A_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAQ";
+const LISTING_B_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAg";
+const LISTING_C_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAw";
+const RELAY_A: &str = "wss://relay-a.example.com";
+const RELAY_B: &str = "wss://relay-b.example.com";
+const RELAY_C: &str = "wss://relay-c.example.com";
+
+#[derive(Clone)]
+struct FixtureSigner {
+ identity: RadrootsSignerIdentity,
+}
+
+impl FixtureSigner {
+ fn new(pubkey: &str) -> Self {
+ Self {
+ identity: RadrootsSignerIdentity::new(pubkey).expect("identity"),
+ }
+ }
+}
+
+impl RadrootsEventSigner for FixtureSigner {
+ fn pubkey(&self) -> &radroots_events::ids::RadrootsPublicKey {
+ self.identity.pubkey()
+ }
+
+ fn sign_frozen_draft(
+ &self,
+ draft: &RadrootsFrozenEventDraft,
+ ) -> Result<RadrootsSignedNostrEvent, RadrootsSignerError> {
+ if self.pubkey().as_str() != draft.expected_pubkey.as_str() {
+ return Err(RadrootsSignerError::SigningFailed {
+ message: "wrong fixture signer".to_owned(),
+ });
+ }
+ let sig = "f".repeat(128);
+ let raw_json = serde_json::json!({
+ "id": draft.expected_event_id,
+ "pubkey": self.pubkey().as_str(),
+ "created_at": draft.created_at,
+ "kind": draft.kind,
+ "tags": draft.tags,
+ "content": draft.content,
+ "sig": sig,
+ })
+ .to_string();
+ RadrootsSignedNostrEvent::new(RadrootsSignedNostrEventParts {
+ id: draft.expected_event_id.clone(),
+ pubkey: self.pubkey().as_str().to_owned(),
+ created_at: draft.created_at,
+ kind: draft.kind,
+ tags: draft.tags.clone(),
+ content: draft.content.clone(),
+ sig,
+ raw_json,
+ })
+ .map_err(|error| RadrootsSignerError::SigningFailed {
+ message: error.to_string(),
+ })
+ }
+}
+
+fn actor() -> RadrootsActorContext {
+ RadrootsActorContext::test(SELLER, [RadrootsActorRole::Seller]).expect("actor")
+}
+
+fn listing(d_tag: &str, title: &str) -> RadrootsListing {
+ RadrootsListing {
+ d_tag: RadrootsDTag::parse(d_tag).expect("d tag"),
+ published_at: None,
+ farm: RadrootsFarmRef {
+ pubkey: SELLER.to_owned(),
+ d_tag: FARM_D_TAG.to_owned(),
+ },
+ product: RadrootsListingProduct {
+ key: "coffee".to_owned(),
+ title: title.to_owned(),
+ category: "coffee".to_owned(),
+ summary: Some("Single origin coffee".to_owned()),
+ process: None,
+ lot: None,
+ location: None,
+ profile: None,
+ year: None,
+ },
+ primary_bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"),
+ bins: vec![RadrootsListingBin {
+ bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(1000u32),
+ RadrootsCoreUnit::MassG,
+ ),
+ price_per_canonical_unit: RadrootsCoreQuantityPrice {
+ amount: RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from(20u32),
+ RadrootsCoreCurrency::USD,
+ ),
+ quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(1u32),
+ RadrootsCoreUnit::MassG,
+ ),
+ },
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ display_price: None,
+ display_price_unit: None,
+ }],
+ resource_area: None,
+ plot: None,
+ discounts: None,
+ inventory_available: None,
+ availability: None,
+ delivery_method: None,
+ location: None,
+ images: None,
+ }
+}
+
+async fn directory_sdk(relays: &[&str]) -> (tempfile::TempDir, RadrootsSdk) {
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ let mut builder = RadrootsSdk::builder()
+ .directory_storage(tempdir.path().join("sdk"))
+ .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000));
+ for relay in relays {
+ builder = builder.relay_url(*relay);
+ }
+ let sdk = builder.build().await.expect("sdk");
+ (tempdir, sdk)
+}
+
+async fn enqueue_listing(sdk: &RadrootsSdk, d_tag: &str, title: &str, relays: &[&str]) -> i64 {
+ sdk.listings()
+ .enqueue_publish(
+ &actor(),
+ &FixtureSigner::new(SELLER),
+ ListingPublishRequest::new(listing(d_tag, title)).with_target_relays(relays.to_vec()),
+ )
+ .await
+ .expect("enqueue")
+ .local
+ .outbox_event_id
+ .expect("outbox event")
+}
+
+#[tokio::test]
+async fn push_outbox_empty_queue_returns_zero_counts() {
+ let (_tempdir, sdk) = directory_sdk(&[]).await;
+ let adapter = RadrootsMockRelayPublishAdapter::new();
+ let request = PushOutboxRequest::new();
+
+ assert_eq!(request.limit, PUSH_OUTBOX_DEFAULT_LIMIT);
+
+ let receipt = sdk
+ .sync()
+ .push_outbox(&adapter, request)
+ .await
+ .expect("push");
+
+ assert_eq!(receipt.attempted_events, 0);
+ assert!(receipt.events.is_empty());
+ assert!(adapter.captured_raw_events().is_empty());
+}
+
+#[tokio::test]
+async fn push_outbox_rejects_invalid_limits_before_claiming() {
+ let (_tempdir, sdk) = directory_sdk(&[RELAY_A]).await;
+ let adapter = RadrootsMockRelayPublishAdapter::new();
+
+ let zero = sdk
+ .sync()
+ .push_outbox(&adapter, PushOutboxRequest::new().with_limit(0))
+ .await
+ .expect_err("zero limit");
+ let too_large = sdk
+ .sync()
+ .push_outbox(
+ &adapter,
+ PushOutboxRequest::new().with_limit(PUSH_OUTBOX_MAX_LIMIT + 1),
+ )
+ .await
+ .expect_err("too large");
+
+ assert!(matches!(zero, RadrootsSdkError::InvalidRequest { .. }));
+ assert!(matches!(too_large, RadrootsSdkError::InvalidRequest { .. }));
+ assert!(adapter.captured_raw_events().is_empty());
+}
+
+#[tokio::test]
+async fn push_outbox_publishes_signed_listing_and_marks_outbox_published() {
+ let (_tempdir, sdk) = directory_sdk(&[RELAY_A]).await;
+ let outbox_event_id = enqueue_listing(&sdk, LISTING_A_D_TAG, "Coffee", &[RELAY_A]).await;
+ let adapter = RadrootsMockRelayPublishAdapter::new();
+
+ let receipt = sdk
+ .sync()
+ .push_outbox(&adapter, PushOutboxRequest::new().with_limit(1))
+ .await
+ .expect("push");
+
+ assert_eq!(receipt.attempted_events, 1);
+ assert_eq!(receipt.published_events, 1);
+ assert_eq!(receipt.retryable_events, 0);
+ assert_eq!(receipt.terminal_events, 0);
+ assert_eq!(receipt.events.len(), 1);
+ let event = &receipt.events[0];
+ assert_eq!(event.outbox_event_id, outbox_event_id);
+ assert_eq!(event.final_state, PushOutboxEventState::Published);
+ assert_eq!(event.attempted_count, 1);
+ assert_eq!(event.accepted_count, 1);
+ assert_eq!(event.retryable_count, 0);
+ assert_eq!(event.terminal_count, 0);
+ assert_eq!(event.quorum, 1);
+ assert!(event.quorum_met);
+ assert_eq!(event.relays.len(), 1);
+ assert_eq!(
+ event.relays[0].outcome_kind,
+ PushOutboxRelayOutcomeKind::Accepted
+ );
+ assert_eq!(adapter.captured_raw_events().len(), 1);
+
+ let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path)
+ .await
+ .expect("outbox");
+ let stored = outbox
+ .get_event(outbox_event_id)
+ .await
+ .expect("stored")
+ .expect("stored");
+ assert_eq!(stored.state, RadrootsOutboxEventState::Published);
+}
+
+#[tokio::test]
+async fn push_outbox_preserves_retryable_and_terminal_relay_outcomes() {
+ let (_tempdir, sdk) = directory_sdk(&[RELAY_A, RELAY_B, RELAY_C]).await;
+ enqueue_listing(
+ &sdk,
+ LISTING_B_D_TAG,
+ "Coffee",
+ &[RELAY_A, RELAY_B, RELAY_C],
+ )
+ .await;
+ let adapter = RadrootsMockRelayPublishAdapter::new()
+ .with_outcome(
+ RELAY_A,
+ RadrootsRelayOutcome::duplicate_accepted("duplicate: already accepted"),
+ )
+ .with_outcome(
+ RELAY_B,
+ RadrootsRelayOutcome::classify("auth-required: login"),
+ )
+ .with_outcome(
+ RELAY_C,
+ RadrootsRelayOutcome::classify("restricted: denied"),
+ );
+
+ let receipt = sdk
+ .sync()
+ .push_outbox(&adapter, PushOutboxRequest::new().with_limit(1))
+ .await
+ .expect("push");
+
+ assert_eq!(receipt.attempted_events, 1);
+ assert_eq!(receipt.published_events, 0);
+ assert_eq!(receipt.retryable_events, 1);
+ assert_eq!(receipt.terminal_events, 0);
+ let event = &receipt.events[0];
+ assert_eq!(event.final_state, PushOutboxEventState::PublishRetryable);
+ assert_eq!(event.accepted_count, 1);
+ assert_eq!(event.retryable_count, 1);
+ assert_eq!(event.terminal_count, 1);
+ assert!(!event.quorum_met);
+
+ let relay_a = event
+ .relays
+ .iter()
+ .find(|relay| relay.relay_url == RELAY_A)
+ .expect("relay a");
+ let relay_b = event
+ .relays
+ .iter()
+ .find(|relay| relay.relay_url == RELAY_B)
+ .expect("relay b");
+ let relay_c = event
+ .relays
+ .iter()
+ .find(|relay| relay.relay_url == RELAY_C)
+ .expect("relay c");
+
+ assert_eq!(
+ relay_a.outcome_kind,
+ PushOutboxRelayOutcomeKind::DuplicateAccepted
+ );
+ assert_eq!(
+ relay_b.outcome_kind,
+ PushOutboxRelayOutcomeKind::AuthRequired
+ );
+ assert_eq!(relay_c.outcome_kind, PushOutboxRelayOutcomeKind::Restricted);
+ assert_eq!(relay_b.message.as_deref(), Some("auth-required: login"));
+}
+
+#[tokio::test]
+async fn push_outbox_does_not_claim_unsigned_outbox_work() {
+ let (_tempdir, sdk) = directory_sdk(&[RELAY_A]).await;
+ let prepared = sdk
+ .listings()
+ .prepare_publish(
+ &actor(),
+ ListingPublishRequest::new(listing(LISTING_C_D_TAG, "Unsigned")),
+ )
+ .expect("prepared");
+ let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path)
+ .await
+ .expect("outbox");
+ let unsigned = outbox
+ .enqueue_operation(RadrootsOutboxOperationInput::new(
+ "listing.publish.v1",
+ prepared.draft,
+ vec![RELAY_A.to_owned()],
+ 1_700_000_000_000,
+ ))
+ .await
+ .expect("unsigned enqueue");
+ let adapter = RadrootsMockRelayPublishAdapter::new();
+
+ let receipt = sdk
+ .sync()
+ .push_outbox(&adapter, PushOutboxRequest::new().with_limit(1))
+ .await
+ .expect("push");
+
+ assert_eq!(receipt.attempted_events, 0);
+ assert!(adapter.captured_raw_events().is_empty());
+
+ let stored = outbox
+ .get_event(unsigned.outbox_event_id)
+ .await
+ .expect("unsigned event")
+ .expect("unsigned event");
+ assert_eq!(stored.state, RadrootsOutboxEventState::DraftQueued);
+ assert!(stored.claim_token.is_none());
+}