sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

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:
Mcrates/sdk/src/error.rs | 2++
Mcrates/sdk/src/lib.rs | 7+++++++
Mcrates/sdk/src/product_clients.rs | 6+++---
Acrates/sdk/src/sync_runtime.rs | 277+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/sync_runtime.rs | 367+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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()); +}