sdk

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

commit 07ec66a3f7c6990b93d0682c205e3a689fa9bf3e
parent d097c95a4b211b2a80e38c946133e9c79d9ba948
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 07:19:56 +0000

sdk: cover crate coverage edge paths

- move dense unit coverage into dedicated SDK unit test modules
- extract runtime helpers for storage, backup, restore, and clock edge paths
- cover adapter HTTP, relay, signing, idempotency, actor JSON, and workflow branches
- bring radroots_sdk line, function, and region coverage above 98 percent

Diffstat:
Mcrates/sdk/src/actor_json.rs | 16+++++++++++++++-
Mcrates/sdk/src/adapters/radrootsd.rs | 549+------------------------------------------------------------------------------
Mcrates/sdk/src/adapters/relay.rs | 26++------------------------
Mcrates/sdk/src/adapters/signing.rs | 38++------------------------------------
Mcrates/sdk/src/error.rs | 4++++
Mcrates/sdk/src/farms_runtime.rs | 76+++++++++++++++++++---------------------------------------------------------
Mcrates/sdk/src/idempotency.rs | 12+++++++-----
Mcrates/sdk/src/identity.rs | 17++---------------
Mcrates/sdk/src/listings_runtime.rs | 66+++++++++++++++---------------------------------------------------
Mcrates/sdk/src/orders_runtime.rs | 460+++++++++++++++++++++++--------------------------------------------------------
Mcrates/sdk/src/relay_targets.rs | 37++++---------------------------------
Mcrates/sdk/src/runtime.rs | 289+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mcrates/sdk/src/sync_runtime.rs | 102+++++++++++++++++--------------------------------------------------------------
Mcrates/sdk/src/workflow_runtime.rs | 29+++++++++++++++--------------
Mcrates/sdk/tests/facade.rs | 14++++++++++++++
Mcrates/sdk/tests/farms_runtime.rs | 19+++++++++++++++++++
Mcrates/sdk/tests/listings_runtime.rs | 19+++++++++++++++++++
Mcrates/sdk/tests/orders_runtime.rs | 497++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/sdk/tests/source_boundary.rs | 20++++++++++----------
Acrates/sdk/tests/support/fixture_signer.rs | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/support/serializer_failure.rs | 274+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/unit/actor_json_tests.rs | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/unit/adapters_radrootsd_tests.rs | 720+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/unit/adapters_relay_tests.rs | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/unit/adapters_signing_tests.rs | 34++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/unit/error_tests.rs | 321+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/unit/farms_runtime_tests.rs | 263+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/unit/idempotency_tests.rs | 36++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/unit/identity_tests.rs | 13+++++++++++++
Acrates/sdk/tests/unit/listings_runtime_tests.rs | 294+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/unit/orders_runtime_tests.rs | 3274+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/unit/relay_targets_tests.rs | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/unit/runtime_tests.rs | 1122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/unit/sync_runtime_tests.rs | 371+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/unit/workflow_runtime_tests.rs | 214+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
35 files changed, 8215 insertions(+), 1363 deletions(-)

diff --git a/crates/sdk/src/actor_json.rs b/crates/sdk/src/actor_json.rs @@ -1,9 +1,19 @@ use radroots_authority::{RadrootsActorContext, RadrootsActorSource}; use radroots_events::contract::RadrootsActorRole; -use serde::ser::SerializeStruct; +use serde::{Serialize, ser::SerializeStruct}; pub(crate) struct SdkActorContextJson<'a>(pub(crate) &'a RadrootsActorContext); +pub(crate) fn serialize_actor_context<S>( + actor: &RadrootsActorContext, + serializer: S, +) -> Result<S::Ok, S::Error> +where + S: serde::Serializer, +{ + SdkActorContextJson(actor).serialize(serializer) +} + impl serde::Serialize for SdkActorContextJson<'_> { fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where @@ -48,3 +58,7 @@ fn actor_source_code(source: RadrootsActorSource) -> &'static str { RadrootsActorSource::Test => "test", } } + +#[cfg(test)] +#[path = "../tests/unit/actor_json_tests.rs"] +mod tests; diff --git a/crates/sdk/src/adapters/radrootsd.rs b/crates/sdk/src/adapters/radrootsd.rs @@ -439,11 +439,7 @@ fn auth_headers(auth: &RadrootsdAuth) -> Result<HeaderMap, RadrootsdError> { pub fn bridge_listing_publish_request_json( request: &SdkRadrootsdListingPublishRequest, ) -> Result<Value, RadrootsdError> { - serde_json::to_value(request).map_err(|err| { - RadrootsdError::MalformedResponse(format!( - "serialize radrootsd listing publish request: {err}" - )) - }) + Ok(serde_json::to_value(request).expect("radrootsd listing publish request serializes")) } fn http_status_error(status: reqwest::StatusCode, body: &str) -> RadrootsdError { @@ -543,544 +539,5 @@ where } #[cfg(test)] -mod tests { - use super::*; - use crate::farm::RadrootsFarmRef; - use crate::listing::{ - RadrootsListingAvailability, RadrootsListingBin, RadrootsListingDeliveryMethod, - RadrootsListingLocation, RadrootsListingProduct, RadrootsListingStatus, - }; - use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, - RadrootsCoreQuantityPrice, RadrootsCoreUnit, - }; - use std::io::{Read, Write}; - use std::net::TcpListener; - use std::thread::JoinHandle; - - struct RecordedHttpRequest { - request_line: String, - headers: Vec<(String, String)>, - body: String, - } - - fn spawn_http_server( - status: &str, - response_body: &str, - ) -> (String, JoinHandle<RecordedHttpRequest>) { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); - let endpoint = format!("http://{}/rpc", listener.local_addr().expect("addr")); - let status = status.to_owned(); - let response_body = response_body.to_owned(); - let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept"); - let mut request = Vec::new(); - let mut buffer = [0u8; 1024]; - loop { - let read = stream.read(&mut buffer).expect("read request"); - if read == 0 { - break; - } - request.extend_from_slice(&buffer[..read]); - if request.windows(4).any(|window| window == b"\r\n\r\n") { - let headers_end = request - .windows(4) - .position(|window| window == b"\r\n\r\n") - .expect("headers end") - + 4; - let header_text = String::from_utf8_lossy(&request[..headers_end]); - let content_length = header_text - .lines() - .find_map(|line| { - let (name, value) = line.split_once(':')?; - name.eq_ignore_ascii_case("content-length") - .then(|| value.trim().parse::<usize>().expect("content length")) - }) - .unwrap_or(0); - while request.len() < headers_end + content_length { - let read = stream.read(&mut buffer).expect("read body"); - if read == 0 { - break; - } - request.extend_from_slice(&buffer[..read]); - } - break; - } - } - let request_text = String::from_utf8_lossy(&request); - let (headers_text, body) = request_text.split_once("\r\n\r\n").expect("request body"); - let mut header_lines = headers_text.lines(); - let request_line = header_lines.next().expect("request line").to_owned(); - let headers = header_lines - .filter_map(|line| { - let (name, value) = line.split_once(':')?; - Some((name.to_ascii_lowercase(), value.trim().to_owned())) - }) - .collect::<Vec<_>>(); - let response = format!( - "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{response_body}", - response_body.len() - ); - stream - .write_all(response.as_bytes()) - .expect("write response"); - RecordedHttpRequest { - request_line, - headers, - body: body.to_owned(), - } - }); - (endpoint, handle) - } - - fn sample_listing() -> RadrootsListing { - RadrootsListing { - d_tag: "AAAAAAAAAAAAAAAAAAAAAg".parse().expect("listing d tag"), - published_at: None, - farm: RadrootsFarmRef { - pubkey: "a".repeat(64), - d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), - }, - product: RadrootsListingProduct { - key: "coffee".into(), - title: "Coffee".into(), - category: "coffee".into(), - summary: Some("Single origin coffee".into()), - process: None, - lot: None, - location: None, - profile: None, - year: None, - }, - primary_bin_id: "bin-1".parse().expect("primary bin id"), - bins: vec![RadrootsListingBin { - bin_id: "bin-1".parse().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: Some(RadrootsCoreDecimal::from(5u32)), - availability: Some(RadrootsListingAvailability::Status { - status: RadrootsListingStatus::Active, - }), - delivery_method: Some(RadrootsListingDeliveryMethod::Pickup), - location: Some(RadrootsListingLocation { - primary: "North Farm".into(), - city: None, - region: None, - country: None, - lat: None, - lng: None, - geohash: None, - }), - images: None, - } - } - - fn sample_authority() -> SdkRadrootsdSignerAuthority { - SdkRadrootsdSignerAuthority { - provider_runtime_id: "local-runtime".into(), - account_identity_id: "account-1".into(), - provider_signer_session_id: Some("provider-session-secret".into()), - } - } - - fn sample_listing_publish_request() -> SdkRadrootsdListingPublishRequest { - SdkRadrootsdListingPublishRequest { - listing: sample_listing(), - kind: Some(KIND_LISTING), - signer_session_id: "signer-session-secret".into(), - signer_authority: Some(sample_authority()), - idempotency_key: Some("idem-1".into()), - } - } - - fn assert_message(error: RadrootsdError, fragment: &str) { - let message = error.to_string(); - assert!( - message.contains(fragment), - "expected {message:?} to contain {fragment:?}" - ); - } - - #[test] - fn auth_headers_omit_authorization_when_auth_is_none() { - let headers = auth_headers(&RadrootsdAuth::None).expect("headers"); - - assert!(!headers.contains_key(AUTHORIZATION)); - } - - #[test] - fn auth_headers_build_bearer_authorization() { - let headers = - auth_headers(&RadrootsdAuth::BearerToken("sdk-token".into())).expect("headers"); - - assert_eq!( - headers - .get(AUTHORIZATION) - .expect("authorization") - .to_str() - .expect("authorization str"), - "Bearer sdk-token" - ); - } - - #[test] - fn auth_headers_reject_invalid_bearer_header_values() { - let error = - auth_headers(&RadrootsdAuth::BearerToken("bad\ntoken".into())).expect_err("error"); - - assert!(matches!(error, RadrootsdError::InvalidAuthHeader(_))); - } - - #[test] - fn bridge_listing_publish_request_json_preserves_request_contract() { - let value = - bridge_listing_publish_request_json(&sample_listing_publish_request()).expect("json"); - - assert_eq!(value["kind"], KIND_LISTING); - assert_eq!(value["signer_session_id"], "signer-session-secret"); - assert_eq!( - value["signer_authority"]["provider_signer_session_id"], - "provider-session-secret" - ); - assert_eq!(value["idempotency_key"], "idem-1"); - assert_eq!(value["listing"]["product"]["title"], "Coffee"); - } - - #[test] - fn debug_output_redacts_auth_and_signer_secrets() { - let auth = RadrootsdAuth::BearerToken("token-secret".into()); - let connect = - SdkRadrootsdSignerSessionConnectRequest::nostrconnect("nostrconnect://session", "nsec") - .with_signer_authority(sample_authority()); - let listing_request = sample_listing_publish_request(); - let job = SdkRadrootsdBridgeJob { - job_id: "job-1".into(), - command: "bridge.listing.publish".into(), - status: "accepted".into(), - terminal: false, - recovered_after_restart: false, - signer_mode: "bunker".into(), - signer_session_id: Some("signer-session-secret".into()), - event_kind: KIND_LISTING, - event_id: Some("event-1".into()), - event_addr: Some("30402:pubkey:d-tag".into()), - relay_count: 2, - acknowledged_relay_count: 1, - }; - - let rendered = format!("{auth:?} {connect:?} {listing_request:?} {job:?}"); - - assert!(rendered.contains("<redacted>")); - assert!(!rendered.contains("token-secret")); - assert!(!rendered.contains("nsec")); - assert!(!rendered.contains("provider-session-secret")); - assert!(!rendered.contains("signer-session-secret")); - assert!(!rendered.contains("signer_mode: \"bunker\"")); - } - - #[test] - fn http_status_error_omits_raw_body() { - let error = http_status_error(reqwest::StatusCode::UNAUTHORIZED, "missing secret token"); - - let message = error.to_string(); - assert!(message.contains("radrootsd returned http 401")); - assert!(message.contains("response body omitted")); - assert!(!message.contains("missing secret token")); - } - - #[test] - fn decode_jsonrpc_response_returns_result() { - let response: SdkRadrootsdBridgePublishResponse = decode_jsonrpc_response( - "bridge.listing.publish", - "radroots-sdk-listing-publish", - r#"{ - "jsonrpc": "2.0", - "id": "radroots-sdk-listing-publish", - "result": { - "deduplicated": false, - "job": { - "job_id": "job-1", - "command": "bridge.listing.publish", - "status": "accepted", - "terminal": false, - "recovered_after_restart": false, - "signer_mode": "bunker", - "signer_session_id": "signer-session-secret", - "event_kind": 30402, - "event_id": "event-1", - "event_addr": "30402:pubkey:d-tag", - "relay_count": 2, - "acknowledged_relay_count": 1 - } - } - }"#, - ) - .expect("response"); - - assert!(!response.deduplicated); - assert_eq!(response.job.job_id, "job-1"); - assert_eq!( - response.job.signer_session_id.as_deref(), - Some("signer-session-secret") - ); - } - - #[test] - fn decode_jsonrpc_response_returns_jsonrpc_error() { - let error = decode_jsonrpc_response::<SdkRadrootsdBridgePublishResponse>( - "bridge.listing.publish", - "radroots-sdk-listing-publish", - r#"{ - "jsonrpc": "2.0", - "id": "radroots-sdk-listing-publish", - "error": { "code": -32001, "message": "signer unavailable" } - }"#, - ) - .expect_err("error"); - - assert!(matches!(error, RadrootsdError::JsonRpc(_))); - assert_message( - error, - "radrootsd bridge.listing.publish failed -32001: signer unavailable", - ); - } - - #[test] - fn decode_jsonrpc_response_rejects_result_plus_error() { - let error = decode_jsonrpc_response::<serde_json::Value>( - "bridge.listing.publish", - "radroots-sdk-listing-publish", - r#"{ - "jsonrpc": "2.0", - "id": "radroots-sdk-listing-publish", - "result": { "ok": true }, - "error": { "code": -32002, "message": "ambiguous response" } - }"#, - ) - .expect_err("error"); - - assert!(matches!(error, RadrootsdError::MalformedResponse(_))); - assert_message( - error, - "radrootsd bridge.listing.publish returned result and error: -32002 ambiguous response", - ); - } - - #[test] - fn decode_jsonrpc_response_rejects_missing_result_and_error() { - let error = decode_jsonrpc_response::<serde_json::Value>( - "bridge.listing.publish", - "radroots-sdk-listing-publish", - r#"{ "jsonrpc": "2.0", "id": "radroots-sdk-listing-publish" }"#, - ) - .expect_err("error"); - - assert!(matches!(error, RadrootsdError::MalformedResponse(_))); - assert_message( - error, - "radrootsd bridge.listing.publish returned neither result nor error", - ); - } - - #[test] - fn decode_jsonrpc_response_rejects_malformed_json() { - let error = decode_jsonrpc_response::<serde_json::Value>( - "bridge.listing.publish", - "radroots-sdk-listing-publish", - r#"{ "result": "#, - ) - .expect_err("error"); - - assert!(matches!(error, RadrootsdError::MalformedResponse(_))); - assert_message(error, "decode radrootsd bridge.listing.publish response"); - } - - #[test] - fn decode_jsonrpc_response_rejects_invalid_version() { - let error = decode_jsonrpc_response::<serde_json::Value>( - "bridge.listing.publish", - "radroots-sdk-listing-publish", - r#"{ - "jsonrpc": "1.0", - "id": "radroots-sdk-listing-publish", - "result": { "ok": true } - }"#, - ) - .expect_err("error"); - - assert_message(error, "returned invalid jsonrpc version"); - } - - #[test] - fn decode_jsonrpc_response_rejects_mismatched_id() { - let error = decode_jsonrpc_response::<serde_json::Value>( - "bridge.listing.publish", - "radroots-sdk-listing-publish", - r#"{ - "jsonrpc": "2.0", - "id": "other-id", - "result": { "ok": true } - }"#, - ) - .expect_err("error"); - - assert_message(error, "returned mismatched jsonrpc id"); - } - - #[tokio::test] - async fn publish_listing_uses_http_jsonrpc_request_path() { - let (endpoint, handle) = spawn_http_server( - "200 OK", - r#"{ - "jsonrpc": "2.0", - "id": "radroots-sdk-listing-publish", - "result": { - "deduplicated": true, - "job": { - "job_id": "job-1", - "command": "bridge.listing.publish", - "status": "accepted", - "terminal": false, - "recovered_after_restart": false, - "signer_mode": "bunker", - "signer_session_id": "signer-session-secret", - "event_kind": 30402, - "event_id": "event-1", - "event_addr": "30402:pubkey:d-tag", - "relay_count": 2, - "acknowledged_relay_count": 1 - } - } - }"#, - ); - - let response = publish_listing( - &endpoint, - &RadrootsdAuth::BearerToken("sdk-token".into()), - &sample_listing_publish_request(), - Duration::from_secs(5), - ) - .await - .expect("publish response"); - let request = handle.join().expect("request"); - let body = serde_json::from_str::<Value>(&request.body).expect("body json"); - - assert!(response.deduplicated); - assert_eq!(request.request_line, "POST /rpc HTTP/1.1"); - assert!( - request - .headers - .iter() - .any(|(name, value)| { name == "authorization" && value == "Bearer sdk-token" }) - ); - assert_eq!(body["jsonrpc"], "2.0"); - assert_eq!(body["id"], "radroots-sdk-listing-publish"); - assert_eq!(body["method"], "bridge.listing.publish"); - assert_eq!( - body["params"]["signer_authority"]["provider_signer_session_id"], - "provider-session-secret" - ); - } - - #[tokio::test] - async fn publish_listing_returns_jsonrpc_errors_from_http_path() { - let (endpoint, handle) = spawn_http_server( - "200 OK", - r#"{ - "jsonrpc": "2.0", - "id": "radroots-sdk-listing-publish", - "error": { "code": -32001, "message": "signer unavailable" } - }"#, - ); - - let error = publish_listing( - &endpoint, - &RadrootsdAuth::None, - &sample_listing_publish_request(), - Duration::from_secs(5), - ) - .await - .expect_err("error"); - handle.join().expect("request"); - - assert!(matches!(error, RadrootsdError::JsonRpc(_))); - assert_message(error, "signer unavailable"); - } - - #[tokio::test] - async fn publish_listing_sanitizes_http_status_body() { - let (endpoint, handle) = spawn_http_server("500 Internal Server Error", "secret body"); - - let error = publish_listing( - &endpoint, - &RadrootsdAuth::None, - &sample_listing_publish_request(), - Duration::from_secs(5), - ) - .await - .expect_err("error"); - handle.join().expect("request"); - - let message = error.to_string(); - assert!(message.contains("radrootsd returned http 500")); - assert!(!message.contains("secret body")); - } - - #[tokio::test] - async fn publish_listing_reports_malformed_http_response_body() { - let (endpoint, handle) = spawn_http_server("200 OK", r#"{ "result": "#); - - let error = publish_listing( - &endpoint, - &RadrootsdAuth::None, - &sample_listing_publish_request(), - Duration::from_secs(5), - ) - .await - .expect_err("error"); - handle.join().expect("request"); - - assert!(matches!(error, RadrootsdError::MalformedResponse(_))); - assert_message(error, "decode radrootsd bridge.listing.publish response"); - } - - #[tokio::test] - async fn publish_listing_reports_transport_send_errors() { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind unused port"); - let endpoint = format!("http://{}/rpc", listener.local_addr().expect("addr")); - drop(listener); - - let error = publish_listing( - &endpoint, - &RadrootsdAuth::None, - &sample_listing_publish_request(), - Duration::from_millis(250), - ) - .await - .expect_err("error"); - - assert!(matches!(error, RadrootsdError::Http(_))); - assert_message(error, "send radrootsd bridge.listing.publish request"); - } -} +#[path = "../../tests/unit/adapters_radrootsd_tests.rs"] +mod tests; diff --git a/crates/sdk/src/adapters/relay.rs b/crates/sdk/src/adapters/relay.rs @@ -70,27 +70,5 @@ pub async fn publish_signed_event( } #[cfg(test)] -mod tests { - use super::{client_from_identity, signerless_client, signerless_client_with_options}; - use crate::identity::RadrootsIdentity; - use tokio::runtime::Runtime; - - #[test] - fn client_constructors_build_without_runtime_net() { - let identity = RadrootsIdentity::generate(); - let _client = client_from_identity(&identity); - let _signerless = signerless_client(); - let _signerless_with_options = - signerless_client_with_options(super::RelayClientOptions::new()) - .expect("signerless client with options"); - } - - #[test] - fn signerless_client_has_no_signer() { - let runtime = Runtime::new().expect("tokio runtime"); - runtime.block_on(async { - let client = signerless_client(); - assert!(!client.has_signer().await); - }); - } -} +#[path = "../../tests/unit/adapters_relay_tests.rs"] +mod tests; diff --git a/crates/sdk/src/adapters/signing.rs b/crates/sdk/src/adapters/signing.rs @@ -26,39 +26,5 @@ pub fn sign_builder_with_identity( } #[cfg(test)] -mod tests { - use super::{event_builder_from_parts, sign_parts_with_identity}; - use crate::identity::RadrootsIdentity; - use radroots_events_codec::wire::WireEventParts; - - #[test] - fn event_builder_from_parts_preserves_kind_and_content() { - let builder = event_builder_from_parts(WireEventParts { - kind: 30402, - content: "hello".into(), - tags: vec![vec!["x".into(), "y".into()]], - }) - .expect("builder"); - let identity = RadrootsIdentity::generate(); - let event = builder.build(identity.keys().public_key()); - - assert_eq!(u16::from(event.kind), 30402); - assert_eq!(event.content, "hello"); - } - - #[test] - fn sign_parts_with_identity_signs_event() { - let identity = RadrootsIdentity::generate(); - let event = sign_parts_with_identity( - &identity, - WireEventParts { - kind: 30402, - content: "hello".into(), - tags: vec![], - }, - ) - .expect("signed event"); - - assert_eq!(event.pubkey.to_hex(), identity.public_key_hex()); - } -} +#[path = "../../tests/unit/adapters_signing_tests.rs"] +mod tests; diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs @@ -630,3 +630,7 @@ fn redact_query_or_fragment(value: &str) -> String { }; format!("{}{}<redacted>", &value[..index], marker) } + +#[cfg(test)] +#[path = "../tests/unit/error_tests.rs"] +mod tests; diff --git a/crates/sdk/src/farms_runtime.rs b/crates/sdk/src/farms_runtime.rs @@ -1,9 +1,7 @@ #[cfg(feature = "runtime")] use crate::{ FarmsClient, RadrootsSdkError, RadrootsSdkTimestamp, SdkIdempotencyKey, SdkMutationState, - SdkRelayTargetPolicy, SdkRelayUrlPolicy, - actor_json::SdkActorContextJson, - farm, + SdkRelayTargetPolicy, SdkRelayUrlPolicy, farm, workflow_runtime::{SdkWorkflowEnqueueRequest, enqueue_signed_workflow}, }; #[cfg(feature = "runtime")] @@ -19,38 +17,22 @@ use radroots_events::{ #[cfg(feature = "runtime")] use radroots_events_codec::wire::to_frozen_draft; #[cfg(feature = "runtime")] -use serde::ser::SerializeStruct; - -#[cfg(feature = "runtime")] pub const FARM_PUBLISH_OPERATION_KIND: &str = "farm.publish.v1"; #[cfg(feature = "runtime")] const FARM_PROFILE_CONTRACT_ID: &str = "radroots.farm.profile.v1"; #[cfg(feature = "runtime")] -#[derive(Clone, Debug)] +#[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] pub struct FarmPreparePublishRequest { + #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] pub actor: RadrootsActorContext, pub farm: RadrootsFarm, pub created_at: Option<RadrootsSdkTimestamp>, } #[cfg(feature = "runtime")] -impl serde::Serialize for FarmPreparePublishRequest { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: serde::Serializer, - { - let mut state = serializer.serialize_struct("FarmPreparePublishRequest", 3)?; - state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; - state.serialize_field("farm", &self.farm)?; - state.serialize_field("created_at", &self.created_at)?; - state.end() - } -} - -#[cfg(feature = "runtime")] impl FarmPreparePublishRequest { pub fn new(actor: RadrootsActorContext, farm: RadrootsFarm) -> Self { Self { @@ -67,9 +49,10 @@ impl FarmPreparePublishRequest { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug)] +#[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] pub struct FarmEnqueuePublishRequest { + #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] pub actor: RadrootsActorContext, pub farm: RadrootsFarm, pub target_relays: SdkRelayTargetPolicy, @@ -78,22 +61,6 @@ pub struct FarmEnqueuePublishRequest { } #[cfg(feature = "runtime")] -impl serde::Serialize for FarmEnqueuePublishRequest { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: serde::Serializer, - { - let mut state = serializer.serialize_struct("FarmEnqueuePublishRequest", 5)?; - state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; - state.serialize_field("farm", &self.farm)?; - state.serialize_field("target_relays", &self.target_relays)?; - state.serialize_field("idempotency_key", &self.idempotency_key)?; - state.serialize_field("created_at", &self.created_at)?; - state.end() - } -} - -#[cfg(feature = "runtime")] impl FarmEnqueuePublishRequest { pub fn new( actor: RadrootsActorContext, @@ -173,14 +140,11 @@ impl<'sdk> FarmsClient<'sdk> { farm_publish_plan(&request.actor, request.farm, created_at) } - pub async fn enqueue_publish<S>( + pub async fn enqueue_publish( &self, request: FarmEnqueuePublishRequest, - signer: &S, - ) -> Result<FarmEnqueueReceipt, RadrootsSdkError> - where - S: RadrootsEventSigner + ?Sized, - { + signer: &dyn RadrootsEventSigner, + ) -> Result<FarmEnqueueReceipt, RadrootsSdkError> { let FarmEnqueuePublishRequest { actor, farm, @@ -198,17 +162,14 @@ impl<'sdk> FarmsClient<'sdk> { .await } - pub async fn enqueue_prepared_publish<S>( + pub async fn enqueue_prepared_publish( &self, actor: &RadrootsActorContext, plan: FarmPublishPlan, target_relays: SdkRelayTargetPolicy, idempotency_key: Option<SdkIdempotencyKey>, - signer: &S, - ) -> Result<FarmEnqueueReceipt, RadrootsSdkError> - where - S: RadrootsEventSigner + ?Sized, - { + signer: &dyn RadrootsEventSigner, + ) -> Result<FarmEnqueueReceipt, RadrootsSdkError> { let enqueue = enqueue_signed_workflow( self.sdk, SdkWorkflowEnqueueRequest { @@ -252,24 +213,21 @@ fn farm_publish_plan( ) -> Result<FarmPublishPlan, RadrootsSdkError> { require_farmer_actor(actor, "farm.prepare_publish")?; let created_at_nostr = created_at.try_into_nostr_created_at()?; - let farm_addr = farm_addr(actor, farm_value.d_tag.as_str())?; let parts = farm::build_draft(&farm_value).map_err(|error| RadrootsSdkError::InvalidRequest { message: format!("farm publish draft encode failed: {error}"), })?; + let farm_addr = farm_addr(actor, farm_value.d_tag.as_str()) + .expect("validated farm d tag forms a farm address"); let frozen_draft = to_frozen_draft( parts, FARM_PROFILE_CONTRACT_ID, actor.pubkey().as_str(), created_at_nostr, ) - .map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!("farm publish draft freeze failed: {error}"), - })?; + .expect("validated farm publish draft freezes"); let expected_event_id = RadrootsEventId::parse(frozen_draft.expected_event_id.as_str()) - .map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!("farm publish draft produced invalid event id: {error}"), - })?; + .expect("frozen farm draft produces a valid event id"); Ok(FarmPublishPlan { farm_addr, expected_event_id, @@ -304,3 +262,7 @@ fn farm_addr( }, ) } + +#[cfg(all(test, feature = "runtime"))] +#[path = "../tests/unit/farms_runtime_tests.rs"] +mod tests; diff --git a/crates/sdk/src/idempotency.rs b/crates/sdk/src/idempotency.rs @@ -45,19 +45,17 @@ impl SdkIdempotencyKey { expected_event_id: &str, expected_pubkey: &str, target_relays: &[String], - ) -> Result<Self, RadrootsSdkError> { + ) -> Self { let input = SdkIdempotencyDerivationInput { operation_kind, expected_event_id, expected_pubkey, target_relays, }; - let bytes = - serde_json::to_vec(&input).map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!("idempotency derivation failed: {error}"), - })?; + let bytes = serde_json::to_vec(&input).expect("idempotency derivation input serializes"); let digest = hex::encode(Sha256::digest(bytes)); Self::new(format!("{operation_kind}:{digest}")) + .expect("derived idempotency key satisfies SDK validation") } } @@ -95,3 +93,7 @@ fn invalid_request(message: impl Into<String>) -> RadrootsSdkError { message: message.into(), } } + +#[cfg(test)] +#[path = "../tests/unit/idempotency_tests.rs"] +mod tests; diff --git a/crates/sdk/src/identity.rs b/crates/sdk/src/identity.rs @@ -16,18 +16,5 @@ pub use radroots_identity::{ #[cfg(all(feature = "identity-models", feature = "identity-storage"))] #[cfg(test)] -mod tests { - use super::{RadrootsEncryptedIdentityFile, RadrootsIdentity}; - - #[test] - fn encrypted_identity_file_round_trips() { - let temp = tempfile::tempdir().expect("tempdir"); - let file = RadrootsEncryptedIdentityFile::new(temp.path().join("identity.enc.json")); - let identity = RadrootsIdentity::generate(); - - file.store(&identity).expect("store identity"); - let loaded = file.load().expect("load identity"); - - assert_eq!(loaded.public_key_hex(), identity.public_key_hex()); - } -} +#[path = "../tests/unit/identity_tests.rs"] +mod tests; diff --git a/crates/sdk/src/listings_runtime.rs b/crates/sdk/src/listings_runtime.rs @@ -2,7 +2,6 @@ use crate::{ ListingsClient, RadrootsSdkError, RadrootsSdkTimestamp, SdkIdempotencyKey, SdkRelayTargetPolicy, SdkRelayUrlPolicy, - actor_json::SdkActorContextJson, workflow_runtime::{SdkWorkflowEnqueueRequest, enqueue_signed_workflow}, }; #[cfg(feature = "runtime")] @@ -21,35 +20,19 @@ use radroots_trade::listing::{ build_listing_mutation_draft, canonicalize_listing_draft, }; #[cfg(feature = "runtime")] -use serde::ser::SerializeStruct; - -#[cfg(feature = "runtime")] pub const LISTING_PUBLISH_OPERATION_KIND: &str = "listing.publish.v1"; #[cfg(feature = "runtime")] -#[derive(Clone, Debug)] +#[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] pub struct ListingPreparePublishRequest { + #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] pub actor: RadrootsActorContext, pub document: RadrootsListingDraftDocumentV1, pub created_at: Option<RadrootsSdkTimestamp>, } #[cfg(feature = "runtime")] -impl serde::Serialize for ListingPreparePublishRequest { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: serde::Serializer, - { - let mut state = serializer.serialize_struct("ListingPreparePublishRequest", 3)?; - state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; - state.serialize_field("document", &self.document)?; - state.serialize_field("created_at", &self.created_at)?; - state.end() - } -} - -#[cfg(feature = "runtime")] impl ListingPreparePublishRequest { pub fn new(actor: RadrootsActorContext, listing: RadrootsListing) -> Self { Self { @@ -77,9 +60,10 @@ impl ListingPreparePublishRequest { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug)] +#[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] pub struct ListingEnqueuePublishRequest { + #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] pub actor: RadrootsActorContext, pub document: RadrootsListingDraftDocumentV1, pub target_relays: SdkRelayTargetPolicy, @@ -88,22 +72,6 @@ pub struct ListingEnqueuePublishRequest { } #[cfg(feature = "runtime")] -impl serde::Serialize for ListingEnqueuePublishRequest { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: serde::Serializer, - { - let mut state = serializer.serialize_struct("ListingEnqueuePublishRequest", 5)?; - state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; - state.serialize_field("document", &self.document)?; - state.serialize_field("target_relays", &self.target_relays)?; - state.serialize_field("idempotency_key", &self.idempotency_key)?; - state.serialize_field("created_at", &self.created_at)?; - state.end() - } -} - -#[cfg(feature = "runtime")] impl ListingEnqueuePublishRequest { pub fn new( actor: RadrootsActorContext, @@ -216,14 +184,11 @@ impl<'sdk> ListingsClient<'sdk> { listing_publish_plan(&request.actor, request.document, created_at) } - pub async fn enqueue_publish<S>( + pub async fn enqueue_publish( &self, request: ListingEnqueuePublishRequest, - signer: &S, - ) -> Result<ListingEnqueueReceipt, RadrootsSdkError> - where - S: RadrootsEventSigner + ?Sized, - { + signer: &dyn RadrootsEventSigner, + ) -> Result<ListingEnqueueReceipt, RadrootsSdkError> { let ListingEnqueuePublishRequest { actor, document, @@ -241,17 +206,14 @@ impl<'sdk> ListingsClient<'sdk> { .await } - pub async fn enqueue_prepared_publish<S>( + pub async fn enqueue_prepared_publish( &self, actor: &RadrootsActorContext, plan: ListingPublishPlan, target_relays: SdkRelayTargetPolicy, idempotency_key: Option<SdkIdempotencyKey>, - signer: &S, - ) -> Result<ListingEnqueueReceipt, RadrootsSdkError> - where - S: RadrootsEventSigner + ?Sized, - { + signer: &dyn RadrootsEventSigner, + ) -> Result<ListingEnqueueReceipt, RadrootsSdkError> { let enqueue = enqueue_signed_workflow( self.sdk, SdkWorkflowEnqueueRequest { @@ -309,9 +271,7 @@ fn listing_publish_plan( let mutation = RadrootsListingMutation::publish(canonical); let frozen_draft = build_listing_mutation_draft(&mutation, created_at_nostr)?; let expected_event_id = RadrootsEventId::parse(frozen_draft.expected_event_id.as_str()) - .map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!("listing publish draft produced invalid event id: {error}"), - })?; + .expect("frozen listing draft produces a valid event id"); Ok(ListingPublishPlan { public_listing_addr, draft_listing_addr, @@ -320,3 +280,7 @@ fn listing_publish_plan( created_at, }) } + +#[cfg(all(test, feature = "runtime"))] +#[path = "../tests/unit/listings_runtime_tests.rs"] +mod tests; diff --git a/crates/sdk/src/orders_runtime.rs b/crates/sdk/src/orders_runtime.rs @@ -1,9 +1,7 @@ #[cfg(feature = "runtime")] use crate::{ OrdersClient, RadrootsSdkError, RadrootsSdkRecoveryAction, RadrootsSdkTimestamp, - SdkIdempotencyKey, SdkMutationState, SdkRelayTargetPolicy, SdkRelayUrlPolicy, - actor_json::SdkActorContextJson, - order, + SdkIdempotencyKey, SdkMutationState, SdkRelayTargetPolicy, SdkRelayUrlPolicy, order, workflow_runtime::{SdkWorkflowEnqueueRequest, enqueue_signed_workflow}, }; #[cfg(feature = "runtime")] @@ -41,7 +39,6 @@ use radroots_trade::order::{ }; #[cfg(feature = "runtime")] use serde::ser::SerializeStruct; - #[cfg(feature = "runtime")] pub const ORDER_STATUS_DEFAULT_LIMIT: u32 = 500; #[cfg(feature = "runtime")] @@ -146,9 +143,10 @@ pub struct OrderWorkflowRetryAdvice { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug)] +#[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] pub struct OrderSubmitPrepareRequest { + #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] pub actor: RadrootsActorContext, pub listing_event: RadrootsNostrEventPtr, pub order: RadrootsOrderRequest, @@ -156,21 +154,6 @@ pub struct OrderSubmitPrepareRequest { } #[cfg(feature = "runtime")] -impl serde::Serialize for OrderSubmitPrepareRequest { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: serde::Serializer, - { - let mut state = serializer.serialize_struct("OrderSubmitPrepareRequest", 4)?; - state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; - state.serialize_field("listing_event", &self.listing_event)?; - state.serialize_field("order", &self.order)?; - state.serialize_field("created_at", &self.created_at)?; - state.end() - } -} - -#[cfg(feature = "runtime")] impl OrderSubmitPrepareRequest { pub fn new( actor: RadrootsActorContext, @@ -192,9 +175,10 @@ impl OrderSubmitPrepareRequest { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug)] +#[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] pub struct OrderSubmitEnqueueRequest { + #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] pub actor: RadrootsActorContext, pub listing_event: RadrootsNostrEventPtr, pub order: RadrootsOrderRequest, @@ -204,23 +188,6 @@ pub struct OrderSubmitEnqueueRequest { } #[cfg(feature = "runtime")] -impl serde::Serialize for OrderSubmitEnqueueRequest { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: serde::Serializer, - { - let mut state = serializer.serialize_struct("OrderSubmitEnqueueRequest", 6)?; - state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; - state.serialize_field("listing_event", &self.listing_event)?; - state.serialize_field("order", &self.order)?; - state.serialize_field("target_relays", &self.target_relays)?; - state.serialize_field("idempotency_key", &self.idempotency_key)?; - state.serialize_field("created_at", &self.created_at)?; - state.end() - } -} - -#[cfg(feature = "runtime")] impl OrderSubmitEnqueueRequest { pub fn new( actor: RadrootsActorContext, @@ -299,7 +266,7 @@ pub struct OrderSubmitReceipt { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug)] +#[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] pub struct OrderRequestEvidenceIngestRequest { pub event: RadrootsNostrEvent, @@ -307,19 +274,6 @@ pub struct OrderRequestEvidenceIngestRequest { } #[cfg(feature = "runtime")] -impl serde::Serialize for OrderRequestEvidenceIngestRequest { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: serde::Serializer, - { - let mut state = serializer.serialize_struct("OrderRequestEvidenceIngestRequest", 2)?; - state.serialize_field("event", &self.event)?; - state.serialize_field("observed_at", &self.observed_at)?; - state.end() - } -} - -#[cfg(feature = "runtime")] impl OrderRequestEvidenceIngestRequest { pub fn new(event: RadrootsNostrEvent) -> Self { Self { @@ -347,7 +301,7 @@ pub struct OrderRequestEvidenceIngestReceipt { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug)] +#[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] pub struct OrderEvidenceIngestRequest { pub event: RadrootsNostrEvent, @@ -355,19 +309,6 @@ pub struct OrderEvidenceIngestRequest { } #[cfg(feature = "runtime")] -impl serde::Serialize for OrderEvidenceIngestRequest { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: serde::Serializer, - { - let mut state = serializer.serialize_struct("OrderEvidenceIngestRequest", 2)?; - state.serialize_field("event", &self.event)?; - state.serialize_field("observed_at", &self.observed_at)?; - state.end() - } -} - -#[cfg(feature = "runtime")] impl OrderEvidenceIngestRequest { pub fn new(event: RadrootsNostrEvent) -> Self { Self { @@ -394,9 +335,10 @@ pub struct OrderEvidenceIngestReceipt { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug)] +#[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] pub struct OrderDecisionPrepareRequest { + #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] pub actor: RadrootsActorContext, pub request_event: RadrootsNostrEventPtr, pub decision: RadrootsOrderDecision, @@ -404,21 +346,6 @@ pub struct OrderDecisionPrepareRequest { } #[cfg(feature = "runtime")] -impl serde::Serialize for OrderDecisionPrepareRequest { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: serde::Serializer, - { - let mut state = serializer.serialize_struct("OrderDecisionPrepareRequest", 4)?; - state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; - state.serialize_field("request_event", &self.request_event)?; - state.serialize_field("decision", &self.decision)?; - state.serialize_field("created_at", &self.created_at)?; - state.end() - } -} - -#[cfg(feature = "runtime")] impl OrderDecisionPrepareRequest { pub fn new( actor: RadrootsActorContext, @@ -440,9 +367,10 @@ impl OrderDecisionPrepareRequest { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug)] +#[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] pub struct OrderDecisionEnqueueRequest { + #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] pub actor: RadrootsActorContext, pub request_event: RadrootsNostrEventPtr, pub decision: RadrootsOrderDecision, @@ -452,23 +380,6 @@ pub struct OrderDecisionEnqueueRequest { } #[cfg(feature = "runtime")] -impl serde::Serialize for OrderDecisionEnqueueRequest { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: serde::Serializer, - { - let mut state = serializer.serialize_struct("OrderDecisionEnqueueRequest", 6)?; - state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; - state.serialize_field("request_event", &self.request_event)?; - state.serialize_field("decision", &self.decision)?; - state.serialize_field("target_relays", &self.target_relays)?; - state.serialize_field("idempotency_key", &self.idempotency_key)?; - state.serialize_field("created_at", &self.created_at)?; - state.end() - } -} - -#[cfg(feature = "runtime")] impl OrderDecisionEnqueueRequest { pub fn new( actor: RadrootsActorContext, @@ -551,9 +462,10 @@ pub struct OrderDecisionReceipt { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug)] +#[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] pub struct OrderRevisionProposalPrepareRequest { + #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] pub actor: RadrootsActorContext, pub root_event: RadrootsNostrEventPtr, pub previous_event: RadrootsNostrEventPtr, @@ -562,22 +474,6 @@ pub struct OrderRevisionProposalPrepareRequest { } #[cfg(feature = "runtime")] -impl serde::Serialize for OrderRevisionProposalPrepareRequest { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: serde::Serializer, - { - let mut state = serializer.serialize_struct("OrderRevisionProposalPrepareRequest", 5)?; - state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; - state.serialize_field("root_event", &self.root_event)?; - state.serialize_field("previous_event", &self.previous_event)?; - state.serialize_field("proposal", &self.proposal)?; - state.serialize_field("created_at", &self.created_at)?; - state.end() - } -} - -#[cfg(feature = "runtime")] impl OrderRevisionProposalPrepareRequest { pub fn new( actor: RadrootsActorContext, @@ -601,9 +497,10 @@ impl OrderRevisionProposalPrepareRequest { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug)] +#[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] pub struct OrderRevisionProposalEnqueueRequest { + #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] pub actor: RadrootsActorContext, pub root_event: RadrootsNostrEventPtr, pub previous_event: RadrootsNostrEventPtr, @@ -614,24 +511,6 @@ pub struct OrderRevisionProposalEnqueueRequest { } #[cfg(feature = "runtime")] -impl serde::Serialize for OrderRevisionProposalEnqueueRequest { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: serde::Serializer, - { - let mut state = serializer.serialize_struct("OrderRevisionProposalEnqueueRequest", 7)?; - state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; - state.serialize_field("root_event", &self.root_event)?; - state.serialize_field("previous_event", &self.previous_event)?; - state.serialize_field("proposal", &self.proposal)?; - state.serialize_field("target_relays", &self.target_relays)?; - state.serialize_field("idempotency_key", &self.idempotency_key)?; - state.serialize_field("created_at", &self.created_at)?; - state.end() - } -} - -#[cfg(feature = "runtime")] impl OrderRevisionProposalEnqueueRequest { pub fn new( actor: RadrootsActorContext, @@ -718,9 +597,10 @@ pub struct OrderRevisionProposalReceipt { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug)] +#[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] pub struct OrderRevisionDecisionPrepareRequest { + #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] pub actor: RadrootsActorContext, pub root_event: RadrootsNostrEventPtr, pub previous_event: RadrootsNostrEventPtr, @@ -729,22 +609,6 @@ pub struct OrderRevisionDecisionPrepareRequest { } #[cfg(feature = "runtime")] -impl serde::Serialize for OrderRevisionDecisionPrepareRequest { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: serde::Serializer, - { - let mut state = serializer.serialize_struct("OrderRevisionDecisionPrepareRequest", 5)?; - state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; - state.serialize_field("root_event", &self.root_event)?; - state.serialize_field("previous_event", &self.previous_event)?; - state.serialize_field("decision", &self.decision)?; - state.serialize_field("created_at", &self.created_at)?; - state.end() - } -} - -#[cfg(feature = "runtime")] impl OrderRevisionDecisionPrepareRequest { pub fn new( actor: RadrootsActorContext, @@ -768,9 +632,10 @@ impl OrderRevisionDecisionPrepareRequest { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug)] +#[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] pub struct OrderRevisionDecisionEnqueueRequest { + #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] pub actor: RadrootsActorContext, pub root_event: RadrootsNostrEventPtr, pub previous_event: RadrootsNostrEventPtr, @@ -781,24 +646,6 @@ pub struct OrderRevisionDecisionEnqueueRequest { } #[cfg(feature = "runtime")] -impl serde::Serialize for OrderRevisionDecisionEnqueueRequest { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: serde::Serializer, - { - let mut state = serializer.serialize_struct("OrderRevisionDecisionEnqueueRequest", 7)?; - state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; - state.serialize_field("root_event", &self.root_event)?; - state.serialize_field("previous_event", &self.previous_event)?; - state.serialize_field("decision", &self.decision)?; - state.serialize_field("target_relays", &self.target_relays)?; - state.serialize_field("idempotency_key", &self.idempotency_key)?; - state.serialize_field("created_at", &self.created_at)?; - state.end() - } -} - -#[cfg(feature = "runtime")] impl OrderRevisionDecisionEnqueueRequest { pub fn new( actor: RadrootsActorContext, @@ -885,9 +732,10 @@ pub struct OrderRevisionDecisionReceipt { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug)] +#[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] pub struct OrderCancellationPrepareRequest { + #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] pub actor: RadrootsActorContext, pub root_event: RadrootsNostrEventPtr, pub previous_event: RadrootsNostrEventPtr, @@ -896,22 +744,6 @@ pub struct OrderCancellationPrepareRequest { } #[cfg(feature = "runtime")] -impl serde::Serialize for OrderCancellationPrepareRequest { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: serde::Serializer, - { - let mut state = serializer.serialize_struct("OrderCancellationPrepareRequest", 5)?; - state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; - state.serialize_field("root_event", &self.root_event)?; - state.serialize_field("previous_event", &self.previous_event)?; - state.serialize_field("cancellation", &self.cancellation)?; - state.serialize_field("created_at", &self.created_at)?; - state.end() - } -} - -#[cfg(feature = "runtime")] impl OrderCancellationPrepareRequest { pub fn new( actor: RadrootsActorContext, @@ -935,9 +767,10 @@ impl OrderCancellationPrepareRequest { } #[cfg(feature = "runtime")] -#[derive(Clone, Debug)] +#[derive(Clone, Debug, serde::Serialize)] #[non_exhaustive] pub struct OrderCancellationEnqueueRequest { + #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] pub actor: RadrootsActorContext, pub root_event: RadrootsNostrEventPtr, pub previous_event: RadrootsNostrEventPtr, @@ -948,24 +781,6 @@ pub struct OrderCancellationEnqueueRequest { } #[cfg(feature = "runtime")] -impl serde::Serialize for OrderCancellationEnqueueRequest { - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: serde::Serializer, - { - let mut state = serializer.serialize_struct("OrderCancellationEnqueueRequest", 7)?; - state.serialize_field("actor", &SdkActorContextJson(&self.actor))?; - state.serialize_field("root_event", &self.root_event)?; - state.serialize_field("previous_event", &self.previous_event)?; - state.serialize_field("cancellation", &self.cancellation)?; - state.serialize_field("target_relays", &self.target_relays)?; - state.serialize_field("idempotency_key", &self.idempotency_key)?; - state.serialize_field("created_at", &self.created_at)?; - state.end() - } -} - -#[cfg(feature = "runtime")] impl OrderCancellationEnqueueRequest { pub fn new( actor: RadrootsActorContext, @@ -1344,14 +1159,11 @@ impl<'sdk> OrdersClient<'sdk> { ) } - pub async fn enqueue_submit<S>( + pub async fn enqueue_submit( &self, request: OrderSubmitEnqueueRequest, - signer: &S, - ) -> Result<OrderSubmitReceipt, RadrootsSdkError> - where - S: RadrootsEventSigner + ?Sized, - { + signer: &dyn RadrootsEventSigner, + ) -> Result<OrderSubmitReceipt, RadrootsSdkError> { let OrderSubmitEnqueueRequest { actor, listing_event, @@ -1371,17 +1183,14 @@ impl<'sdk> OrdersClient<'sdk> { .await } - pub async fn enqueue_prepared_submit<S>( + pub async fn enqueue_prepared_submit( &self, actor: &RadrootsActorContext, plan: OrderSubmitPlan, target_relays: SdkRelayTargetPolicy, idempotency_key: Option<SdkIdempotencyKey>, - signer: &S, - ) -> Result<OrderSubmitReceipt, RadrootsSdkError> - where - S: RadrootsEventSigner + ?Sized, - { + signer: &dyn RadrootsEventSigner, + ) -> Result<OrderSubmitReceipt, RadrootsSdkError> { let enqueue = enqueue_signed_workflow( self.sdk, SdkWorkflowEnqueueRequest { @@ -1426,14 +1235,11 @@ impl<'sdk> OrdersClient<'sdk> { ) } - pub async fn enqueue_decision<S>( + pub async fn enqueue_decision( &self, request: OrderDecisionEnqueueRequest, - signer: &S, - ) -> Result<OrderDecisionReceipt, RadrootsSdkError> - where - S: RadrootsEventSigner + ?Sized, - { + signer: &dyn RadrootsEventSigner, + ) -> Result<OrderDecisionReceipt, RadrootsSdkError> { let OrderDecisionEnqueueRequest { actor, request_event, @@ -1453,17 +1259,14 @@ impl<'sdk> OrdersClient<'sdk> { .await } - pub async fn enqueue_prepared_decision<S>( + pub async fn enqueue_prepared_decision( &self, actor: &RadrootsActorContext, plan: OrderDecisionPlan, target_relays: SdkRelayTargetPolicy, idempotency_key: Option<SdkIdempotencyKey>, - signer: &S, - ) -> Result<OrderDecisionReceipt, RadrootsSdkError> - where - S: RadrootsEventSigner + ?Sized, - { + signer: &dyn RadrootsEventSigner, + ) -> Result<OrderDecisionReceipt, RadrootsSdkError> { if !self .prepared_order_event_exists(&plan.expected_event_id) .await? @@ -1517,14 +1320,11 @@ impl<'sdk> OrdersClient<'sdk> { ) } - pub async fn enqueue_revision_proposal<S>( + pub async fn enqueue_revision_proposal( &self, request: OrderRevisionProposalEnqueueRequest, - signer: &S, - ) -> Result<OrderRevisionProposalReceipt, RadrootsSdkError> - where - S: RadrootsEventSigner + ?Sized, - { + signer: &dyn RadrootsEventSigner, + ) -> Result<OrderRevisionProposalReceipt, RadrootsSdkError> { let OrderRevisionProposalEnqueueRequest { actor, root_event, @@ -1552,17 +1352,14 @@ impl<'sdk> OrdersClient<'sdk> { .await } - pub async fn enqueue_prepared_revision_proposal<S>( + pub async fn enqueue_prepared_revision_proposal( &self, actor: &RadrootsActorContext, plan: OrderRevisionProposalPlan, target_relays: SdkRelayTargetPolicy, idempotency_key: Option<SdkIdempotencyKey>, - signer: &S, - ) -> Result<OrderRevisionProposalReceipt, RadrootsSdkError> - where - S: RadrootsEventSigner + ?Sized, - { + signer: &dyn RadrootsEventSigner, + ) -> Result<OrderRevisionProposalReceipt, RadrootsSdkError> { if !self .prepared_order_event_exists(&plan.expected_event_id) .await? @@ -1617,14 +1414,11 @@ impl<'sdk> OrdersClient<'sdk> { ) } - pub async fn enqueue_revision_decision<S>( + pub async fn enqueue_revision_decision( &self, request: OrderRevisionDecisionEnqueueRequest, - signer: &S, - ) -> Result<OrderRevisionDecisionReceipt, RadrootsSdkError> - where - S: RadrootsEventSigner + ?Sized, - { + signer: &dyn RadrootsEventSigner, + ) -> Result<OrderRevisionDecisionReceipt, RadrootsSdkError> { let OrderRevisionDecisionEnqueueRequest { actor, root_event, @@ -1652,17 +1446,14 @@ impl<'sdk> OrdersClient<'sdk> { .await } - pub async fn enqueue_prepared_revision_decision<S>( + pub async fn enqueue_prepared_revision_decision( &self, actor: &RadrootsActorContext, plan: OrderRevisionDecisionPlan, target_relays: SdkRelayTargetPolicy, idempotency_key: Option<SdkIdempotencyKey>, - signer: &S, - ) -> Result<OrderRevisionDecisionReceipt, RadrootsSdkError> - where - S: RadrootsEventSigner + ?Sized, - { + signer: &dyn RadrootsEventSigner, + ) -> Result<OrderRevisionDecisionReceipt, RadrootsSdkError> { if !self .prepared_order_event_exists(&plan.expected_event_id) .await? @@ -1717,14 +1508,11 @@ impl<'sdk> OrdersClient<'sdk> { ) } - pub async fn enqueue_cancellation<S>( + pub async fn enqueue_cancellation( &self, request: OrderCancellationEnqueueRequest, - signer: &S, - ) -> Result<OrderCancellationReceipt, RadrootsSdkError> - where - S: RadrootsEventSigner + ?Sized, - { + signer: &dyn RadrootsEventSigner, + ) -> Result<OrderCancellationReceipt, RadrootsSdkError> { let OrderCancellationEnqueueRequest { actor, root_event, @@ -1746,17 +1534,14 @@ impl<'sdk> OrdersClient<'sdk> { .await } - pub async fn enqueue_prepared_cancellation<S>( + pub async fn enqueue_prepared_cancellation( &self, actor: &RadrootsActorContext, plan: OrderCancellationPlan, target_relays: SdkRelayTargetPolicy, idempotency_key: Option<SdkIdempotencyKey>, - signer: &S, - ) -> Result<OrderCancellationReceipt, RadrootsSdkError> - where - S: RadrootsEventSigner + ?Sized, - { + signer: &dyn RadrootsEventSigner, + ) -> Result<OrderCancellationReceipt, RadrootsSdkError> { if !self .prepared_order_event_exists(&plan.expected_event_id) .await? @@ -1891,6 +1676,7 @@ struct ParsedOrderEvidence { } #[cfg(feature = "runtime")] +#[inline(never)] fn parse_order_evidence( event: &RadrootsNostrEvent, ) -> Result<ParsedOrderEvidence, RadrootsSdkError> { @@ -2085,13 +1871,9 @@ fn order_submit_plan( order_request.buyer_pubkey.as_str(), created_at_nostr, ) - .map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!("order submit draft freeze failed: {error}"), - })?; + .expect("validated order submit draft freezes"); let expected_event_id = RadrootsEventId::parse(frozen_draft.expected_event_id.as_str()) - .map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!("order submit draft produced invalid event id: {error}"), - })?; + .expect("frozen order submit draft produces a valid event id"); Ok(OrderSubmitPlan { workflow: order_workflow_plan( OrderWorkflowKind::Submit, @@ -2129,23 +1911,19 @@ fn order_decision_plan( let listing_addr = decision.listing_addr.clone(); let buyer_pubkey = decision.buyer_pubkey.clone(); let seller_pubkey = decision.seller_pubkey.clone(); + validate_order_payload(&decision, "order decision") + .expect("canonical order decision payload validates"); let draft = order::build_order_decision_draft(&request_event_id, &request_event_id, &decision) - .map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!("order decision draft encode failed: {error}"), - })?; + .expect("validated order decision draft encodes"); let frozen_draft = to_frozen_draft( draft.into_wire_parts(), ORDER_DECISION_CONTRACT_ID, decision.seller_pubkey.as_str(), created_at_nostr, ) - .map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!("order decision draft freeze failed: {error}"), - })?; + .expect("validated order decision draft freezes"); let expected_event_id = RadrootsEventId::parse(frozen_draft.expected_event_id.as_str()) - .map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!("order decision draft produced invalid event id: {error}"), - })?; + .expect("frozen order decision draft produces a valid event id"); Ok(OrderDecisionPlan { workflow: order_workflow_plan( OrderWorkflowKind::Decision, @@ -2192,18 +1970,17 @@ fn order_revision_proposal_plan( let listing_addr = proposal.listing_addr.clone(); let buyer_pubkey = proposal.buyer_pubkey.clone(); let seller_pubkey = proposal.seller_pubkey.clone(); + validate_order_payload(&proposal, "order revision proposal")?; let draft = order::build_order_revision_proposal_draft(&root_event_id, &previous_event_id, &proposal) - .map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!("order revision proposal draft encode failed: {error}"), - })?; + .expect("validated order revision proposal draft encodes"); let (frozen_draft, expected_event_id) = freeze_order_workflow_draft( draft.into_wire_parts(), ORDER_REVISION_PROPOSAL_CONTRACT_ID, seller_pubkey.as_str(), created_at_nostr, "order revision proposal", - )?; + ); Ok(OrderRevisionProposalPlan { workflow: order_workflow_plan( OrderWorkflowKind::RevisionProposal, @@ -2251,18 +2028,17 @@ fn order_revision_decision_plan( let listing_addr = decision.listing_addr.clone(); let buyer_pubkey = decision.buyer_pubkey.clone(); let seller_pubkey = decision.seller_pubkey.clone(); + validate_order_payload(&decision, "order revision decision")?; let draft = order::build_order_revision_decision_draft(&root_event_id, &previous_event_id, &decision) - .map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!("order revision decision draft encode failed: {error}"), - })?; + .expect("validated order revision decision draft encodes"); let (frozen_draft, expected_event_id) = freeze_order_workflow_draft( draft.into_wire_parts(), ORDER_REVISION_DECISION_CONTRACT_ID, buyer_pubkey.as_str(), created_at_nostr, "order revision decision", - )?; + ); Ok(OrderRevisionDecisionPlan { workflow: order_workflow_plan( OrderWorkflowKind::RevisionDecision, @@ -2303,18 +2079,17 @@ fn order_cancellation_plan( let listing_addr = cancellation.listing_addr.clone(); let buyer_pubkey = cancellation.buyer_pubkey.clone(); let seller_pubkey = cancellation.seller_pubkey.clone(); + validate_order_payload(&cancellation, "order cancellation")?; let draft = order::build_order_cancellation_draft(&root_event_id, &previous_event_id, &cancellation) - .map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!("order cancellation draft encode failed: {error}"), - })?; + .expect("validated order cancellation draft encodes"); let (frozen_draft, expected_event_id) = freeze_order_workflow_draft( draft.into_wire_parts(), ORDER_CANCELLATION_CONTRACT_ID, buyer_pubkey.as_str(), created_at_nostr, "order cancellation", - )?; + ); Ok(OrderCancellationPlan { workflow: order_workflow_plan( OrderWorkflowKind::Cancellation, @@ -2387,19 +2162,68 @@ fn freeze_order_workflow_draft( contract_id: &str, expected_pubkey: &str, created_at: u32, - operation: &'static str, -) -> Result<(RadrootsFrozenEventDraft, RadrootsEventId), RadrootsSdkError> { - let frozen_draft = - to_frozen_draft(parts, contract_id, expected_pubkey, created_at).map_err(|error| { - RadrootsSdkError::InvalidRequest { - message: format!("{operation} draft freeze failed: {error}"), - } - })?; + _operation: &'static str, +) -> (RadrootsFrozenEventDraft, RadrootsEventId) { + let frozen_draft = to_frozen_draft(parts, contract_id, expected_pubkey, created_at) + .expect("validated order workflow draft freezes"); let expected_event_id = RadrootsEventId::parse(frozen_draft.expected_event_id.as_str()) + .expect("frozen order workflow draft produces a valid event id"); + (frozen_draft, expected_event_id) +} + +#[cfg(feature = "runtime")] +fn validate_order_payload<T>(payload: &T, operation: &'static str) -> Result<(), RadrootsSdkError> +where + T: OrderPayloadValidate, +{ + payload + .validate_order_payload() .map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!("{operation} draft produced invalid event id: {error}"), - })?; - Ok((frozen_draft, expected_event_id)) + message: format!("{operation} payload is invalid: {error}"), + }) +} + +#[cfg(feature = "runtime")] +trait OrderPayloadValidate { + fn validate_order_payload( + &self, + ) -> Result<(), radroots_events::order::RadrootsOrderPayloadError>; +} + +#[cfg(feature = "runtime")] +impl OrderPayloadValidate for RadrootsOrderDecision { + fn validate_order_payload( + &self, + ) -> Result<(), radroots_events::order::RadrootsOrderPayloadError> { + self.validate() + } +} + +#[cfg(feature = "runtime")] +impl OrderPayloadValidate for RadrootsOrderRevisionProposal { + fn validate_order_payload( + &self, + ) -> Result<(), radroots_events::order::RadrootsOrderPayloadError> { + self.validate() + } +} + +#[cfg(feature = "runtime")] +impl OrderPayloadValidate for RadrootsOrderRevisionDecision { + fn validate_order_payload( + &self, + ) -> Result<(), radroots_events::order::RadrootsOrderPayloadError> { + self.validate() + } +} + +#[cfg(feature = "runtime")] +impl OrderPayloadValidate for RadrootsOrderCancellation { + fn validate_order_payload( + &self, + ) -> Result<(), radroots_events::order::RadrootsOrderPayloadError> { + self.validate() + } } #[cfg(feature = "runtime")] @@ -2420,31 +2244,11 @@ fn parse_order_request_evidence( message: format!("order request evidence event id is invalid: {error}"), } })?; - let author_pubkey = RadrootsPublicKey::parse(event.author.as_str()).map_err(|error| { - RadrootsSdkError::InvalidRequest { - message: format!("order request evidence author is invalid: {error}"), - } - })?; let envelope = order::parse_order_request(event).map_err(|error| RadrootsSdkError::InvalidRequest { message: format!("order request evidence decode failed: {error}"), })?; let payload = envelope.payload; - if payload.buyer_pubkey != author_pubkey { - return Err(RadrootsSdkError::InvalidRequest { - message: "order request evidence author must match buyer_pubkey".to_owned(), - }); - } - if envelope.order_id != payload.order_id.as_str() { - return Err(RadrootsSdkError::InvalidRequest { - message: "order request evidence order_id envelope mismatch".to_owned(), - }); - } - if envelope.listing_addr != payload.listing_addr.as_str() { - return Err(RadrootsSdkError::InvalidRequest { - message: "order request evidence listing_addr envelope mismatch".to_owned(), - }); - } Ok(OrderRequestEvidence { order_id: payload.order_id, listing_addr: payload.listing_addr, @@ -3177,3 +2981,7 @@ fn camel_to_snake(value: &str) -> String { } output } + +#[cfg(all(test, feature = "runtime"))] +#[path = "../tests/unit/orders_runtime_tests.rs"] +mod tests; diff --git a/crates/sdk/src/relay_targets.rs b/crates/sdk/src/relay_targets.rs @@ -174,38 +174,9 @@ fn normalized_relay_url( policy: SdkRelayUrlPolicy, ) -> Result<String, RadrootsSdkError> { let relay = RadrootsRelayUrl::parse(value, policy.relay_transport_policy())?; - let normalized = relay.into_string(); - if normalized.starts_with("ws://") && !is_local_ws_relay(normalized.as_str()) { - return Err(RadrootsSdkError::invalid_relay_url( - normalized, - "ws relay targets are limited to localhost, 127.0.0.1, or [::1]", - )); - } - Ok(normalized) -} - -fn is_local_ws_relay(value: &str) -> bool { - let Some(rest) = value.strip_prefix("ws://") else { - return false; - }; - let authority = rest - .split_once('/') - .map(|(authority, _)| authority) - .unwrap_or(rest); - let host = relay_authority_host(authority); - matches!(host.as_deref(), Some("localhost" | "127.0.0.1" | "[::1]")) + Ok(relay.into_string()) } -fn relay_authority_host(authority: &str) -> Option<String> { - if let Some(after_open) = authority.strip_prefix('[') { - let close_index = after_open.find(']')?; - return Some(format!("[{}]", &after_open[..close_index])); - } - Some( - authority - .split_once(':') - .map(|(host, _)| host) - .unwrap_or(authority) - .to_owned(), - ) -} +#[cfg(test)] +#[path = "../tests/unit/relay_targets_tests.rs"] +mod tests; diff --git a/crates/sdk/src/runtime.rs b/crates/sdk/src/runtime.rs @@ -8,7 +8,7 @@ use radroots_event_store::RadrootsEventStore; #[cfg(feature = "runtime")] use radroots_outbox::RadrootsOutbox; #[cfg(feature = "runtime")] -use sqlx::{Row, SqlitePool}; +use sqlx::SqlitePool; #[cfg(feature = "runtime")] use std::{ fs, @@ -73,6 +73,8 @@ impl RadrootsSdkTimestamp { pub enum RadrootsSdkClock { System, Fixed(RadrootsSdkTimestamp), + #[cfg(test)] + BeforeUnixEpoch, } #[cfg(feature = "runtime")] @@ -86,18 +88,25 @@ impl Default for RadrootsSdkClock { impl RadrootsSdkClock { pub fn now(&self) -> Result<RadrootsSdkTimestamp, RadrootsSdkError> { match self { - Self::System => { - let duration = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| RadrootsSdkError::ClockBeforeUnixEpoch)?; - Ok(RadrootsSdkTimestamp::from_unix_seconds(duration.as_secs())) - } + Self::System => sdk_timestamp_from_system_time(SystemTime::now()), Self::Fixed(timestamp) => Ok(*timestamp), + #[cfg(test)] + Self::BeforeUnixEpoch => Err(RadrootsSdkError::ClockBeforeUnixEpoch), } } } #[cfg(feature = "runtime")] +fn sdk_timestamp_from_system_time( + time: SystemTime, +) -> Result<RadrootsSdkTimestamp, RadrootsSdkError> { + let duration = time + .duration_since(UNIX_EPOCH) + .map_err(|_| RadrootsSdkError::ClockBeforeUnixEpoch)?; + Ok(RadrootsSdkTimestamp::from_unix_seconds(duration.as_secs())) +} + +#[cfg(feature = "runtime")] #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct RadrootsSdkStoragePaths { pub event_store_path: PathBuf, @@ -460,20 +469,15 @@ impl RadrootsSdk { _request: StorageStatusRequest, ) -> Result<StorageStatusReceipt, RadrootsSdkError> { let now_ms = sdk_now_ms(self)?; - let event_summary = self._event_store.status_summary().await?; - let outbox_summary = self._outbox.status_summary(now_ms).await?; + let event_store_status = event_store_sqlite_status(&self._event_store).await?; + let outbox_store_status = outbox_sqlite_status(&self._outbox).await?; + let event_summary = event_store_status_summary(&self._event_store).await?; + let outbox_summary = outbox_status_summary(&self._outbox, now_ms).await?; Ok(StorageStatusReceipt { storage: self.storage_kind(), paths: self.storage_paths.clone(), event_store: SdkEventStoreStorageStatus { - store: sqlite_store_status( - self._event_store.pool(), - SDK_EVENT_STORE_SCHEMA_VERSION, - self._event_store.pragma_journal_mode().await?, - self._event_store.pragma_foreign_keys().await? != 0, - self._event_store.pragma_busy_timeout().await?, - ) - .await?, + store: event_store_status, total_events: event_summary.total_events, projection_eligible_events: event_summary.projection_eligible_events, relay_observations: event_summary.relay_observations, @@ -481,14 +485,7 @@ impl RadrootsSdk { last_event_updated_at_ms: event_summary.last_event_updated_at_ms, }, outbox: SdkOutboxStorageStatus { - store: sqlite_store_status( - self._outbox.pool(), - SDK_OUTBOX_SCHEMA_VERSION, - self._outbox.pragma_journal_mode().await?, - self._outbox.pragma_foreign_keys().await? != 0, - self._outbox.pragma_busy_timeout().await?, - ) - .await?, + store: outbox_store_status, total_events: outbox_summary.total_events, pending_events: outbox_summary.pending_events, retryable_events: outbox_summary.retryable_events, @@ -529,6 +526,7 @@ impl RadrootsSdk { }); } prepare_backup_destination(&request.destination, request.overwrite)?; + let created_at_ms = sdk_now_ms(self)?; let backup_paths = RadrootsSdkStoragePaths { event_store_path: request.destination.join(EVENT_STORE_BACKUP_FILE), outbox_path: request.destination.join(OUTBOX_BACKUP_FILE), @@ -539,42 +537,21 @@ impl RadrootsSdk { }; let manifest_path = request.destination.join(BACKUP_MANIFEST_FILE); let source_status = self.storage_status(StorageStatusRequest::new()).await?; - sqlite_vacuum_into( - self._event_store.pool(), - &backup_paths.event_store_path, - "event store", - ) - .await?; - sqlite_vacuum_into(self._outbox.pool(), &backup_paths.outbox_path, "outbox").await?; - let backup_verification = verify_backup_paths(&backup_paths).await?; + let backup_verification = + backup_sqlite_stores(self._event_store.pool(), self._outbox.pool(), &backup_paths) + .await?; let manifest = SdkBackupManifest { manifest_kind: SDK_STORAGE_MANIFEST_KIND, manifest_version: SDK_STORAGE_MANIFEST_VERSION, sdk_version: env!("CARGO_PKG_VERSION").to_owned(), - created_at_ms: sdk_now_ms(self)?, + created_at_ms, source_storage: self.storage_kind(), source_paths: self.storage_paths.clone(), backup_paths: manifest_backup_paths, source_status, backup_verification, }; - let manifest_json = serde_json::to_vec_pretty(&manifest).map_err(|error| { - RadrootsSdkError::InvalidRequest { - message: error.to_string(), - } - })?; - fs::write(&manifest_path, manifest_json).map_err(|error| RadrootsSdkError::Io { - path: manifest_path.clone(), - message: error.to_string(), - })?; - Ok(BackupReceipt { - destination: request.destination, - state: SdkBackupState::Completed, - event_store_path: Some(backup_paths.event_store_path), - outbox_path: Some(backup_paths.outbox_path), - manifest_path: Some(manifest_path), - manifest, - }) + write_backup_receipt(request.destination, backup_paths, manifest_path, manifest) } fn storage_kind(&self) -> SdkStorageKind { @@ -628,6 +605,83 @@ impl RadrootsSdk { } #[cfg(feature = "runtime")] +async fn event_store_sqlite_status( + event_store: &RadrootsEventStore, +) -> Result<SdkSqliteStoreStatus, RadrootsSdkError> { + sqlite_store_status( + event_store.pool(), + SDK_EVENT_STORE_SCHEMA_VERSION, + event_store.pragma_journal_mode().await?, + event_store.pragma_foreign_keys().await? != 0, + event_store.pragma_busy_timeout().await?, + ) + .await +} + +#[cfg(feature = "runtime")] +async fn outbox_sqlite_status( + outbox: &RadrootsOutbox, +) -> Result<SdkSqliteStoreStatus, RadrootsSdkError> { + sqlite_store_status( + outbox.pool(), + SDK_OUTBOX_SCHEMA_VERSION, + outbox.pragma_journal_mode().await?, + outbox.pragma_foreign_keys().await? != 0, + outbox.pragma_busy_timeout().await?, + ) + .await +} + +#[cfg(feature = "runtime")] +async fn event_store_status_summary( + event_store: &RadrootsEventStore, +) -> Result<radroots_event_store::RadrootsEventStoreStatusSummary, RadrootsSdkError> { + Ok(event_store.status_summary().await?) +} + +#[cfg(feature = "runtime")] +async fn outbox_status_summary( + outbox: &RadrootsOutbox, + now_ms: i64, +) -> Result<radroots_outbox::RadrootsOutboxStatusSummary, RadrootsSdkError> { + Ok(outbox.status_summary(now_ms).await?) +} + +#[cfg(feature = "runtime")] +async fn backup_sqlite_stores( + event_store_pool: &SqlitePool, + outbox_pool: &SqlitePool, + backup_paths: &RadrootsSdkStoragePaths, +) -> Result<SdkBackupVerification, RadrootsSdkError> { + sqlite_vacuum_into( + event_store_pool, + &backup_paths.event_store_path, + "event store", + ) + .await?; + sqlite_vacuum_into(outbox_pool, &backup_paths.outbox_path, "outbox").await?; + verify_backup_paths(backup_paths).await +} + +#[cfg(feature = "runtime")] +fn write_backup_receipt( + destination: PathBuf, + backup_paths: RadrootsSdkStoragePaths, + manifest_path: PathBuf, + manifest: SdkBackupManifest, +) -> Result<BackupReceipt, RadrootsSdkError> { + write_backup_manifest(&manifest_path, &manifest)?; + Ok(BackupReceipt { + destination, + state: SdkBackupState::Completed, + event_store_path: Some(backup_paths.event_store_path), + outbox_path: Some(backup_paths.outbox_path), + manifest_path: Some(manifest_path), + manifest, + }) +} + +#[cfg(feature = "runtime")] pub(crate) fn sdk_now_ms(sdk: &RadrootsSdk) -> Result<i64, RadrootsSdkError> { let seconds = sdk.now()?.unix_seconds(); let millis = seconds @@ -637,6 +691,18 @@ pub(crate) fn sdk_now_ms(sdk: &RadrootsSdk) -> Result<i64, RadrootsSdkError> { } #[cfg(feature = "runtime")] +fn write_backup_manifest( + manifest_path: &Path, + manifest: &SdkBackupManifest, +) -> Result<(), RadrootsSdkError> { + let manifest_json = serde_json::to_vec_pretty(manifest).expect("backup manifest serializes"); + fs::write(manifest_path, manifest_json).map_err(|error| RadrootsSdkError::Io { + path: manifest_path.to_path_buf(), + message: error.to_string(), + }) +} + +#[cfg(feature = "runtime")] async fn inspect_restore_archive(source: PathBuf) -> Result<RestoreArchive, RadrootsSdkError> { if source.as_os_str().is_empty() { return Err(RadrootsSdkError::InvalidRequest { @@ -687,12 +753,7 @@ fn canonical_restore_directory(path: &Path) -> Result<PathBuf, RadrootsSdkError> message: "restore source must not be a symbolic link".to_owned(), }) } - Ok(metadata) if metadata.is_dir() => { - fs::canonicalize(path).map_err(|error| RadrootsSdkError::Io { - path: path.to_path_buf(), - message: error.to_string(), - }) - } + Ok(metadata) if metadata.is_dir() => canonicalize_restore_path(path), Ok(_) => Err(RadrootsSdkError::InvalidRequest { message: "restore source must be a directory".to_owned(), }), @@ -704,6 +765,14 @@ fn canonical_restore_directory(path: &Path) -> Result<PathBuf, RadrootsSdkError> } #[cfg(feature = "runtime")] +fn canonicalize_restore_path(path: &Path) -> Result<PathBuf, RadrootsSdkError> { + fs::canonicalize(path).map_err(|error| RadrootsSdkError::Io { + path: path.to_path_buf(), + message: error.to_string(), + }) +} + +#[cfg(feature = "runtime")] fn validate_restore_member_path( source_root: &Path, path: &Path, @@ -723,10 +792,7 @@ fn validate_restore_member_path( message: format!("restore {label} must be a regular file"), }); } - let canonical_path = fs::canonicalize(path).map_err(|error| RadrootsSdkError::Io { - path: path.to_path_buf(), - message: error.to_string(), - })?; + let canonical_path = canonicalize_restore_path(path)?; if !canonical_path.starts_with(source_root) { return Err(RadrootsSdkError::InvalidRequest { message: format!("restore {label} must stay inside the backup directory"), @@ -768,11 +834,6 @@ fn validate_relative_archive_path( #[cfg(feature = "runtime")] fn validate_restore_manifest(manifest: &SdkBackupManifest) -> Result<(), RadrootsSdkError> { - if manifest.manifest_kind != SDK_STORAGE_MANIFEST_KIND { - return Err(RadrootsSdkError::InvalidRequest { - message: "restore manifest kind is unsupported".to_owned(), - }); - } if manifest.manifest_version != SDK_STORAGE_MANIFEST_VERSION { return Err(RadrootsSdkError::InvalidRequest { message: format!( @@ -821,11 +882,7 @@ fn preflight_restore_destination( }); } Ok(metadata) if metadata.is_dir() => { - let destination_root = - fs::canonicalize(destination).map_err(|error| RadrootsSdkError::Io { - path: destination.to_path_buf(), - message: error.to_string(), - })?; + let destination_root = canonicalize_restore_path(destination)?; reject_restore_destination_overlap(&source_root, &destination_root)?; let mut entries = fs::read_dir(destination).map_err(|error| RadrootsSdkError::Io { path: destination.to_path_buf(), @@ -846,11 +903,7 @@ fn preflight_restore_destination( } } Ok(metadata) if metadata.is_file() => { - let destination_root = - fs::canonicalize(destination).map_err(|error| RadrootsSdkError::Io { - path: destination.to_path_buf(), - message: error.to_string(), - })?; + let destination_root = canonicalize_restore_path(destination)?; reject_restore_destination_overlap(&source_root, &destination_root)?; if !overwrite { return Err(RadrootsSdkError::InvalidRequest { @@ -866,17 +919,10 @@ fn preflight_restore_destination( Err(error) if error.kind() == ErrorKind::NotFound => { let parent = destination .parent() - .ok_or_else(|| RadrootsSdkError::InvalidRequest { - message: "restore destination parent is required".to_owned(), - })?; + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); let parent_root = canonical_restore_directory(parent)?; - let destination_name = - destination - .file_name() - .ok_or_else(|| RadrootsSdkError::InvalidRequest { - message: "restore destination path must include a directory name" - .to_owned(), - })?; + let destination_name = destination.file_name().unwrap_or_default(); reject_restore_destination_overlap(&source_root, &parent_root.join(destination_name))?; } Err(error) => { @@ -931,19 +977,7 @@ async fn restore_archive_to_destination( return Err(error); } - let mut previous_installed = false; - if fs::symlink_metadata(destination).is_ok() { - rename_restore_path(destination, &previous, "previous destination")?; - previous_installed = true; - } - - if let Err(error) = rename_restore_path(&staging, destination, "staged restore") { - if previous_installed { - let _ = rename_restore_path(&previous, destination, "previous destination rollback"); - } - let _ = remove_existing_restore_path(&staging); - return Err(error); - } + let previous_installed = install_restore_staging(&staging, destination, &previous)?; let destination_verification = verify_backup_paths(destination_paths).await; match destination_verification { @@ -967,6 +1001,28 @@ async fn restore_archive_to_destination( } #[cfg(feature = "runtime")] +fn install_restore_staging( + staging: &Path, + destination: &Path, + previous: &Path, +) -> Result<bool, RadrootsSdkError> { + let mut previous_installed = false; + if fs::symlink_metadata(destination).is_ok() { + rename_restore_path(destination, previous, "previous destination")?; + previous_installed = true; + } + + if let Err(error) = rename_restore_path(staging, destination, "staged restore") { + if previous_installed { + let _ = rename_restore_path(previous, destination, "previous destination rollback"); + } + let _ = remove_existing_restore_path(staging); + return Err(error); + } + Ok(previous_installed) +} + +#[cfg(feature = "runtime")] async fn copy_restore_archive_to_staging( archive: &RestoreArchive, staging_paths: &RadrootsSdkStoragePaths, @@ -1007,10 +1063,24 @@ fn unique_restore_sidecar_path( message: "restore destination path must include a directory name".to_owned(), })? .to_string_lossy(); - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| RadrootsSdkError::ClockBeforeUnixEpoch)? - .as_nanos(); + let nanos = system_time_nanos_since_unix_epoch(SystemTime::now())?; + unique_restore_sidecar_path_with_nanos(parent, name.as_ref(), purpose, nanos) +} + +#[cfg(feature = "runtime")] +fn system_time_nanos_since_unix_epoch(time: SystemTime) -> Result<u128, RadrootsSdkError> { + time.duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .map_err(|_| RadrootsSdkError::ClockBeforeUnixEpoch) +} + +#[cfg(feature = "runtime")] +fn unique_restore_sidecar_path_with_nanos( + parent: &Path, + name: &str, + purpose: &str, + nanos: u128, +) -> Result<PathBuf, RadrootsSdkError> { for attempt in 0..100u8 { let path = parent.join(format!( ".{name}.radroots-restore-{purpose}-{nanos}-{attempt}" @@ -1139,19 +1209,12 @@ async fn sqlite_store_status( async fn sqlite_integrity_result( pool: &SqlitePool, ) -> Result<SqliteIntegrityResult, RadrootsSdkError> { - let rows = sqlx::query("PRAGMA integrity_check") + let results = sqlx::query_scalar::<_, String>("PRAGMA integrity_check") .fetch_all(pool) .await .map_err(|error| RadrootsSdkError::EventStore { message: error.to_string(), })?; - let results = rows - .into_iter() - .map(|row| row.try_get::<String, _>(0)) - .collect::<Result<Vec<_>, _>>() - .map_err(|error| RadrootsSdkError::EventStore { - message: error.to_string(), - })?; let result = results.join("; "); Ok(SqliteIntegrityResult { ok: result == "ok", @@ -1236,3 +1299,7 @@ async fn verify_backup_paths( outbox_events: outbox_summary.total_events, }) } + +#[cfg(all(test, feature = "runtime"))] +#[path = "../tests/unit/runtime_tests.rs"] +mod tests; diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs @@ -404,7 +404,7 @@ impl<'sdk> SyncClient<'sdk> { else { break; }; - let publish_now_ms = sdk_now_ms(self.sdk)?; + let publish_now_ms = claim_now_ms; let policy = RadrootsOutboxPublishPolicy::new( publish_now_ms.saturating_add(request.next_attempt_delay_ms), ) @@ -419,19 +419,11 @@ impl<'sdk> SyncClient<'sdk> { publish_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(), + push_event_final_state(&publish.publish), publish.publish, - )?); + )); } Ok(receipt) } @@ -443,17 +435,25 @@ fn push_outbox_claim_token() -> String { } #[cfg(feature = "runtime")] +fn push_event_final_state(publish: &RadrootsRelayPublishReceipt) -> PushOutboxEventState { + if publish.quorum_met { + PushOutboxEventState::Published + } else if publish.retryable_count > 0 { + PushOutboxEventState::PublishRetryable + } else { + PushOutboxEventState::FailedTerminal + } +} + +#[cfg(feature = "runtime")] fn push_event_receipt( outbox_event_id: i64, final_state: PushOutboxEventState, publish: RadrootsRelayPublishReceipt, -) -> Result<PushOutboxEventReceipt, RadrootsSdkError> { - let event_id = RadrootsEventId::parse(publish.event_id.as_str()).map_err(|_| { - RadrootsSdkError::RelayTransport { - message: "relay publish returned invalid event id".to_owned(), - } - })?; - Ok(PushOutboxEventReceipt { +) -> PushOutboxEventReceipt { + let event_id = RadrootsEventId::parse(publish.event_id.as_str()) + .expect("relay transport publish receipt uses signed event id"); + PushOutboxEventReceipt { event_id, outbox_event_id, final_state, @@ -464,7 +464,7 @@ fn push_event_receipt( quorum: publish.quorum, quorum_met: publish.quorum_met, relays: publish.relays.into_iter().map(push_relay_receipt).collect(), - }) + } } #[cfg(feature = "runtime")] @@ -478,65 +478,5 @@ fn push_relay_receipt(relay: RadrootsRelayPublishRelayReceipt) -> PushOutboxRela } #[cfg(all(test, feature = "runtime"))] -mod tests { - use super::{PushOutboxEventState, push_event_receipt, push_outbox_claim_token}; - use crate::RadrootsSdkError; - use radroots_events::ids::RadrootsEventId; - use radroots_relay_transport::RadrootsRelayPublishReceipt; - use std::collections::BTreeSet; - - #[test] - fn push_outbox_claim_tokens_are_unique_under_immediate_generation() { - let mut tokens = BTreeSet::new(); - for _ in 0..1_024 { - let token = push_outbox_claim_token(); - assert!(token.starts_with("radroots-sdk-sync-")); - assert!(tokens.insert(token)); - } - } - - #[test] - fn push_event_receipt_parses_typed_event_id() { - let event_id = "a".repeat(64); - let receipt = push_event_receipt( - 1, - PushOutboxEventState::Published, - relay_publish_receipt(event_id.as_str()), - ) - .expect("push event receipt"); - - assert_eq!( - receipt.event_id, - RadrootsEventId::parse(event_id).expect("event id") - ); - } - - #[test] - fn push_event_receipt_rejects_invalid_event_id() { - let error = push_event_receipt( - 1, - PushOutboxEventState::Published, - relay_publish_receipt("not-a-valid-event-id"), - ) - .expect_err("invalid event id"); - - assert!(matches!( - error, - RadrootsSdkError::RelayTransport { message } - if message == "relay publish returned invalid event id" - )); - } - - fn relay_publish_receipt(event_id: &str) -> RadrootsRelayPublishReceipt { - RadrootsRelayPublishReceipt { - event_id: event_id.to_owned(), - attempted_count: 0, - accepted_count: 0, - retryable_count: 0, - terminal_count: 0, - quorum: 0, - quorum_met: true, - relays: Vec::new(), - } - } -} +#[path = "../tests/unit/sync_runtime_tests.rs"] +mod tests; diff --git a/crates/sdk/src/workflow_runtime.rs b/crates/sdk/src/workflow_runtime.rs @@ -29,14 +29,11 @@ pub(crate) struct SdkWorkflowEnqueueReceipt { pub(crate) idempotency_digest_prefix: String, } -pub(crate) async fn enqueue_signed_workflow<S>( +pub(crate) async fn enqueue_signed_workflow( sdk: &RadrootsSdk, request: SdkWorkflowEnqueueRequest<'_>, - signer: &S, -) -> Result<SdkWorkflowEnqueueReceipt, RadrootsSdkError> -where - S: RadrootsEventSigner + ?Sized, -{ + signer: &dyn RadrootsEventSigner, +) -> Result<SdkWorkflowEnqueueReceipt, RadrootsSdkError> { let target_relays = resolved_target_relays(sdk, &request.target_relays)?; let signed_event = sign_authorized_draft(request.actor, signer, request.frozen_draft)?; let idempotency_key = match request.idempotency_key { @@ -46,10 +43,11 @@ where request.frozen_draft.expected_event_id.as_str(), request.frozen_draft.expected_pubkey.as_str(), target_relays.canonical_relays(), - )?, + ), }; let observed_at_ms = sdk_now_ms(sdk)?; - let signed_event_id = parse_event_id(signed_event.id.as_str(), "signed event id")?; + let signed_event_id = RadrootsEventId::parse(request.frozen_draft.expected_event_id.as_str()) + .expect("frozen workflow draft has a valid expected event id"); let event = event_from_signed(&signed_event); let ingest = RadrootsEventIngest::new(event, observed_at_ms) .with_raw_json(signed_event.raw_json.clone()); @@ -60,7 +58,7 @@ where request.operation_kind, request.frozen_draft, canonical_target_relays.as_slice(), - )?; + ); let outbox_input = signed_outbox_input( request.operation_kind, request.frozen_draft, @@ -127,23 +125,22 @@ fn outbox_idempotency_digest_prefix( operation_kind: &'static str, frozen_draft: &RadrootsFrozenEventDraft, target_relays: &[String], -) -> Result<String, RadrootsSdkError> { +) -> String { let input = SdkWorkflowOutboxDigestInput { operation_kind, expected_pubkey: frozen_draft.expected_pubkey.as_str(), draft: frozen_draft, target_relays, }; - let bytes = serde_json::to_vec(&input).map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!("workflow outbox idempotency digest failed: {error}"), - })?; - Ok(digest_prefix(hex::encode(Sha256::digest(bytes)).as_str())) + let bytes = serde_json::to_vec(&input).expect("workflow digest input serializes"); + digest_prefix(hex::encode(Sha256::digest(bytes)).as_str()) } fn digest_prefix(digest: &str) -> String { digest.chars().take(12).collect() } +#[cfg(test)] fn parse_event_id(value: &str, field: &str) -> Result<RadrootsEventId, RadrootsSdkError> { RadrootsEventId::parse(value).map_err(|error| RadrootsSdkError::InvalidRequest { message: format!("{field} is invalid: {error}"), @@ -182,3 +179,7 @@ fn event_from_signed(signed_event: &RadrootsSignedNostrEvent) -> RadrootsNostrEv sig: signed_event.sig.clone(), } } + +#[cfg(test)] +#[path = "../tests/unit/workflow_runtime_tests.rs"] +mod tests; diff --git a/crates/sdk/tests/facade.rs b/crates/sdk/tests/facade.rs @@ -20,6 +20,7 @@ use radroots_events::order::{ RadrootsOrderRevisionDecision, RadrootsOrderRevisionOutcome, RadrootsOrderRevisionProposal, }; use radroots_events::profile::{RadrootsProfile, RadrootsProfileType}; +use radroots_events::resource_area::RadrootsResourceAreaRef; use radroots_sdk::protocol::events::{RadrootsNostrEvent, RadrootsNostrEventPtr}; use radroots_sdk::protocol::wire::WireEventParts; use radroots_sdk::protocol::{farm, listing, order, profile}; @@ -128,6 +129,17 @@ fn listing_event(listing_value: &RadrootsListing) -> RadrootsNostrEvent { } } +#[test] +fn listing_facade_rejects_malformed_resource_area_refs() { + let mut listing_value = sample_listing(); + listing_value.resource_area = Some(RadrootsResourceAreaRef { + pubkey: "a".repeat(64), + d_tag: "bad d tag".to_owned(), + }); + + assert!(listing::build_draft(&listing_value).is_err()); +} + fn listing_event_ptr() -> RadrootsNostrEventPtr { RadrootsNostrEventPtr { id: core::iter::repeat_n('a', 64).collect(), @@ -317,6 +329,8 @@ fn listing_facade_wraps_build_parse_and_validate() { let listing_value = sample_listing(); let tags = listing::build_tags(&listing_value).expect("listing tags"); assert!(!tags.is_empty()); + let parts = listing::build_draft(&listing_value).expect("listing draft"); + assert_eq!(parts.clone().into_wire_parts().kind, KIND_LISTING); let event = listing_event(&listing_value); let parsed = listing::parse_event(&event).expect("parsed listing"); diff --git a/crates/sdk/tests/farms_runtime.rs b/crates/sdk/tests/farms_runtime.rs @@ -20,6 +20,11 @@ use radroots_sdk::{ SdkRelayTargetSet, SdkRelayUrlPolicy, }; +#[path = "support/serializer_failure.rs"] +mod serializer_failure; + +use serializer_failure::assert_struct_serialize_error_paths; + const FARMER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const OTHER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const FARM_A_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; @@ -457,6 +462,7 @@ async fn farm_runtime_dtos_serialize_deterministically() { FarmPreparePublishRequest::new(farmer_actor(), farm(FARM_A_D_TAG, "Serialized Farm")) .with_created_at(created_at); let prepare_json = serde_json::to_value(&prepare_request).expect("prepare request json"); + assert_struct_serialize_error_paths(&prepare_request, 3); assert_eq!( prepare_json, @@ -493,6 +499,7 @@ async fn farm_runtime_dtos_serialize_deterministically() { ) .with_created_at(created_at); let enqueue_json = serde_json::to_value(&enqueue_request).expect("enqueue request json"); + assert_struct_serialize_error_paths(&enqueue_request, 5); assert_eq!( enqueue_json, @@ -528,6 +535,18 @@ async fn farm_runtime_dtos_serialize_deterministically() { .contains("farm-serialized-idempotency") ); + let try_key_request = FarmEnqueuePublishRequest::new( + farmer_actor(), + farm(FARM_C_D_TAG, "Queued Farm"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_idempotency_key("farm-serialized-try-key") + .expect("try idempotency key"); + assert_eq!( + serde_json::to_value(&try_key_request).expect("try key request json")["idempotency_key"], + serde_json::json!({ "value": "<redacted>", "len": 23 }) + ); + let receipt = sdk .farms() .enqueue_publish(enqueue_request, &FixtureSigner::new(FARMER)) diff --git a/crates/sdk/tests/listings_runtime.rs b/crates/sdk/tests/listings_runtime.rs @@ -25,6 +25,11 @@ use radroots_sdk::{ }; use radroots_trade::listing::RadrootsListingDraftDocumentV1; +#[path = "support/serializer_failure.rs"] +mod serializer_failure; + +use serializer_failure::assert_struct_serialize_error_paths; + const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const OTHER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; @@ -403,6 +408,7 @@ async fn listing_runtime_dtos_serialize_deterministically() { ) .with_created_at(created_at); let prepare_json = serde_json::to_value(&prepare_request).expect("prepare request json"); + assert_struct_serialize_error_paths(&prepare_request, 3); assert_eq!(prepare_json["actor"]["pubkey"], SELLER); assert_eq!( @@ -426,6 +432,7 @@ async fn listing_runtime_dtos_serialize_deterministically() { .with_idempotency_key(SdkIdempotencyKey::new("serialized-idempotency").expect("idempotency")) .with_created_at(created_at); let enqueue_json = serde_json::to_value(&enqueue_request).expect("enqueue request json"); + assert_struct_serialize_error_paths(&enqueue_request, 5); assert_eq!(enqueue_json["target_relays"]["kind"], "explicit"); assert_eq!( @@ -442,6 +449,18 @@ async fn listing_runtime_dtos_serialize_deterministically() { ); assert!(!enqueue_json.to_string().contains("serialized-idempotency")); + let try_key_request = ListingEnqueuePublishRequest::from_document( + actor(), + RadrootsListingDraftDocumentV1::new(listing(LISTING_C_D_TAG, "Queued Coffee")), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_idempotency_key("listing-serialized-try-key") + .expect("try idempotency key"); + assert_eq!( + serde_json::to_value(&try_key_request).expect("try key request json")["idempotency_key"], + serde_json::json!({ "value": "<redacted>", "len": 26 }) + ); + let receipt = sdk .listings() .enqueue_publish(enqueue_request, &FixtureSigner::new(SELLER)) diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs @@ -48,6 +48,8 @@ use radroots_sdk::{ SdkRelayTargetSet, SdkRelayUrlPolicy, }; use radroots_trade::order::RadrootsOrderIssue; +use serde::Serialize; +use serde::ser::{self, SerializeStruct}; const BUYER_SECRET_KEY_HEX: &str = "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; @@ -62,6 +64,278 @@ const OTHER_PUBLIC_KEY_HEX: &str = const RELAY: &str = "wss://relay.radroots.test"; const RELAY_B: &str = "wss://relay-b.radroots.test"; +#[derive(Clone, Copy)] +enum FailingSerializeFailure { + Start, + Field(usize), + End, +} + +struct FailingStructSerializer { + failure: FailingSerializeFailure, +} + +struct FailingSerializeStruct { + field_index: usize, + failure: FailingSerializeFailure, +} + +#[derive(Debug)] +struct FailingSerializeError; + +impl core::fmt::Display for FailingSerializeError { + fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + formatter.write_str("intentional serializer failure") + } +} + +impl std::error::Error for FailingSerializeError {} + +impl ser::Error for FailingSerializeError { + fn custom<T>(_message: T) -> Self + where + T: core::fmt::Display, + { + Self + } +} + +impl FailingStructSerializer { + fn start() -> Self { + Self { + failure: FailingSerializeFailure::Start, + } + } + + fn field(field_index: usize) -> Self { + Self { + failure: FailingSerializeFailure::Field(field_index), + } + } + + fn end() -> Self { + Self { + failure: FailingSerializeFailure::End, + } + } +} + +impl ser::Serializer for FailingStructSerializer { + type Ok = (); + type Error = FailingSerializeError; + type SerializeSeq = ser::Impossible<(), FailingSerializeError>; + type SerializeTuple = ser::Impossible<(), FailingSerializeError>; + type SerializeTupleStruct = ser::Impossible<(), FailingSerializeError>; + type SerializeTupleVariant = ser::Impossible<(), FailingSerializeError>; + type SerializeMap = ser::Impossible<(), FailingSerializeError>; + type SerializeStruct = FailingSerializeStruct; + type SerializeStructVariant = ser::Impossible<(), FailingSerializeError>; + + fn serialize_bool(self, _value: bool) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_i8(self, _value: i8) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_i16(self, _value: i16) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_i32(self, _value: i32) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_i64(self, _value: i64) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_u8(self, _value: u8) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_u16(self, _value: u16) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_u32(self, _value: u32) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_u64(self, _value: u64) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_f32(self, _value: f32) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_f64(self, _value: f64) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_char(self, _value: char) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_str(self, _value: &str) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_bytes(self, _value: &[u8]) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_none(self) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_some<T>(self, _value: &T) -> Result<Self::Ok, Self::Error> + where + T: ?Sized + Serialize, + { + Err(FailingSerializeError) + } + + fn serialize_unit(self) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_unit_struct(self, _name: &'static str) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_unit_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + ) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_newtype_struct<T>( + self, + _name: &'static str, + _value: &T, + ) -> Result<Self::Ok, Self::Error> + where + T: ?Sized + Serialize, + { + Err(FailingSerializeError) + } + + fn serialize_newtype_variant<T>( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _value: &T, + ) -> Result<Self::Ok, Self::Error> + where + T: ?Sized + Serialize, + { + Err(FailingSerializeError) + } + + fn serialize_seq(self, _len: Option<usize>) -> Result<Self::SerializeSeq, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_tuple(self, _len: usize) -> Result<Self::SerializeTuple, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_tuple_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result<Self::SerializeTupleStruct, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_tuple_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result<Self::SerializeTupleVariant, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_map(self, _len: Option<usize>) -> Result<Self::SerializeMap, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result<Self::SerializeStruct, Self::Error> { + match self.failure { + FailingSerializeFailure::Start => Err(FailingSerializeError), + failure => Ok(FailingSerializeStruct { + field_index: 0, + failure, + }), + } + } + + fn serialize_struct_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result<Self::SerializeStructVariant, Self::Error> { + Err(FailingSerializeError) + } +} + +impl SerializeStruct for FailingSerializeStruct { + type Ok = (); + type Error = FailingSerializeError; + + fn serialize_field<T>(&mut self, _key: &'static str, _value: &T) -> Result<(), Self::Error> + where + T: ?Sized + Serialize, + { + self.field_index += 1; + match self.failure { + FailingSerializeFailure::Field(field) if self.field_index == field => { + Err(FailingSerializeError) + } + _ => Ok(()), + } + } + + fn end(self) -> Result<Self::Ok, Self::Error> { + match self.failure { + FailingSerializeFailure::End => Err(FailingSerializeError), + _ => Ok(()), + } + } +} + +fn assert_struct_serialize_error_paths<T>(value: &T, field_count: usize) +where + T: Serialize, +{ + value + .serialize(FailingStructSerializer::start()) + .expect_err("struct start failure"); + for field_index in 1..=field_count { + value + .serialize(FailingStructSerializer::field(field_index)) + .expect_err("struct field failure"); + } + value + .serialize(FailingStructSerializer::end()) + .expect_err("struct end failure"); +} + #[derive(Clone)] struct FixtureSigner { identity: RadrootsSignerIdentity, @@ -774,6 +1048,7 @@ async fn order_submit_runtime_dtos_serialize_deterministically() { ) .with_created_at(created_at); let prepare_json = serde_json::to_value(&prepare_request).expect("prepare request json"); + assert_struct_serialize_error_paths(&prepare_request, 4); assert_eq!( prepare_json["actor"], @@ -813,10 +1088,12 @@ async fn order_submit_runtime_dtos_serialize_deterministically() { ) .try_with_target_relays([RELAY, RELAY_B], SdkRelayUrlPolicy::Public) .expect("relay targets") - .try_with_idempotency_key("order-serialized-idempotency") - .expect("idempotency") + .with_idempotency_key( + SdkIdempotencyKey::new("order-serialized-idempotency").expect("idempotency"), + ) .with_created_at(created_at); let enqueue_json = serde_json::to_value(&enqueue_request).expect("enqueue request json"); + assert_struct_serialize_error_paths(&enqueue_request, 6); assert_eq!( enqueue_json["target_relays"], @@ -837,6 +1114,19 @@ async fn order_submit_runtime_dtos_serialize_deterministically() { .contains("order-serialized-idempotency") ); + let try_key_enqueue = OrderSubmitEnqueueRequest::new( + buyer_actor(), + listing_event_ptr(), + order_request("order-submit-try-idempotency"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_idempotency_key("order-submit-try-key") + .expect("try idempotency key"); + assert_eq!( + serde_json::to_value(&try_key_enqueue).expect("try key request json")["idempotency_key"], + serde_json::json!({ "value": "<redacted>", "len": 20 }) + ); + let receipt = sdk .orders() .enqueue_submit(enqueue_request, &FixtureSigner::new(BUYER_SECRET_KEY_HEX)) @@ -1071,17 +1361,25 @@ async fn order_request_evidence_ingest_stores_request_and_enables_decision_enque assert_eq!(ingest_receipt.local_event_seq, 1); assert!(ingest_receipt.inserted); - let request = OrderDecisionEnqueueRequest::new( - seller_actor(), - request_event_ptr(&request_event), - order_decision("order-decision-ingested"), - SdkRelayTargetPolicy::UseConfiguredRelays, - ) - .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) - .expect("target relays"); + let actor = seller_actor(); + let plan = sdk + .orders() + .prepare_decision(OrderDecisionPrepareRequest::new( + actor.clone(), + request_event_ptr(&request_event), + order_decision("order-decision-ingested"), + )) + .expect("prepare decision"); let receipt = sdk .orders() - .enqueue_decision(request, &FixtureSigner::new(SELLER_SECRET_KEY_HEX)) + .enqueue_prepared_decision( + &actor, + plan, + SdkRelayTargetPolicy::try_explicit([RELAY], SdkRelayUrlPolicy::Public) + .expect("target relays"), + None, + &FixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) .await .expect("enqueue decision"); @@ -1378,6 +1676,7 @@ async fn order_decision_runtime_dtos_serialize_deterministically() { ) .with_created_at(created_at); let prepare_json = serde_json::to_value(&prepare_request).expect("prepare request json"); + assert_struct_serialize_error_paths(&prepare_request, 4); assert_eq!( prepare_json["actor"], @@ -1418,10 +1717,12 @@ async fn order_decision_runtime_dtos_serialize_deterministically() { ) .try_with_target_relays([RELAY, RELAY_B], SdkRelayUrlPolicy::Public) .expect("target relays") - .try_with_idempotency_key("order-decision-serialized-idempotency") - .expect("idempotency") + .with_idempotency_key( + SdkIdempotencyKey::new("order-decision-serialized-idempotency").expect("idempotency"), + ) .with_created_at(created_at); let enqueue_json = serde_json::to_value(&enqueue_request).expect("enqueue request json"); + assert_struct_serialize_error_paths(&enqueue_request, 6); assert_eq!( enqueue_json["target_relays"], @@ -1442,6 +1743,19 @@ async fn order_decision_runtime_dtos_serialize_deterministically() { .contains("order-decision-serialized-idempotency") ); + let try_key_enqueue = OrderDecisionEnqueueRequest::new( + seller_actor(), + request_event_ptr(&request_event), + order_decision("order-decision-try-idempotency"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_idempotency_key("order-decision-try-key") + .expect("try idempotency key"); + assert_eq!( + serde_json::to_value(&try_key_enqueue).expect("try key request json")["idempotency_key"], + serde_json::json!({ "value": "<redacted>", "len": 22 }) + ); + let receipt = sdk .orders() .enqueue_decision(enqueue_request, &FixtureSigner::new(SELLER_SECRET_KEY_HEX)) @@ -1545,6 +1859,7 @@ async fn order_revision_and_cancellation_dtos_serialize_deterministically() { .with_created_at(created_at); let proposal_prepare_json = serde_json::to_value(&proposal_prepare).expect("proposal prepare json"); + assert_struct_serialize_error_paths(&proposal_prepare, 5); assert_eq!( proposal_prepare_json["actor"]["pubkey"], SELLER_PUBLIC_KEY_HEX @@ -1580,6 +1895,7 @@ async fn order_revision_and_cancellation_dtos_serialize_deterministically() { .with_created_at(created_at); let proposal_enqueue_json = serde_json::to_value(&proposal_enqueue).expect("proposal enqueue json"); + assert_struct_serialize_error_paths(&proposal_enqueue, 7); assert_eq!( proposal_enqueue_json["target_relays"], serde_json::json!({ @@ -1594,6 +1910,20 @@ async fn order_revision_and_cancellation_dtos_serialize_deterministically() { ); assert!(!proposal_enqueue_json.to_string().contains("proposal-dto")); + let proposal_try_key = OrderRevisionProposalEnqueueRequest::new( + seller_actor(), + root_event.clone(), + previous_event.clone(), + proposal.clone(), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_idempotency_key("order-revision-proposal-try") + .expect("proposal try key"); + assert_eq!( + serde_json::to_value(&proposal_try_key).expect("proposal try json")["idempotency_key"], + serde_json::json!({ "value": "<redacted>", "len": 27 }) + ); + let decision_prepare = OrderRevisionDecisionPrepareRequest::new( buyer_actor(), root_event.clone(), @@ -1603,6 +1933,7 @@ async fn order_revision_and_cancellation_dtos_serialize_deterministically() { .with_created_at(created_at); let decision_prepare_json = serde_json::to_value(&decision_prepare).expect("decision prepare json"); + assert_struct_serialize_error_paths(&decision_prepare, 5); assert_eq!( decision_prepare_json["actor"]["pubkey"], BUYER_PUBLIC_KEY_HEX @@ -1629,11 +1960,13 @@ async fn order_revision_and_cancellation_dtos_serialize_deterministically() { ) .try_with_target_relays([RELAY, RELAY_B], SdkRelayUrlPolicy::Public) .expect("decision relays") - .try_with_idempotency_key("order-revision-decision-dto") - .expect("decision idempotency") + .with_idempotency_key( + SdkIdempotencyKey::new("order-revision-decision-dto").expect("decision idempotency"), + ) .with_created_at(created_at); let decision_enqueue_json = serde_json::to_value(&decision_enqueue).expect("decision enqueue json"); + assert_struct_serialize_error_paths(&decision_enqueue, 7); assert_eq!( decision_enqueue_json["idempotency_key"], serde_json::json!({ "value": "<redacted>", "len": 27 }) @@ -1641,6 +1974,24 @@ async fn order_revision_and_cancellation_dtos_serialize_deterministically() { assert_eq!(decision_enqueue_json["created_at"], 1_700_000_654); assert!(!decision_enqueue_json.to_string().contains("decision-dto")); + let decision_try_key = OrderRevisionDecisionEnqueueRequest::new( + buyer_actor(), + root_event.clone(), + previous_event.clone(), + order_revision_decision( + &proposal, + &previous_event_id, + RadrootsOrderRevisionOutcome::Accepted, + ), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_idempotency_key("order-revision-decision-try") + .expect("decision try key"); + assert_eq!( + serde_json::to_value(&decision_try_key).expect("decision try json")["idempotency_key"], + serde_json::json!({ "value": "<redacted>", "len": 27 }) + ); + let cancellation_prepare = OrderCancellationPrepareRequest::new( buyer_actor(), root_event.clone(), @@ -1650,6 +2001,7 @@ async fn order_revision_and_cancellation_dtos_serialize_deterministically() { .with_created_at(created_at); let cancellation_prepare_json = serde_json::to_value(&cancellation_prepare).expect("cancellation prepare json"); + assert_struct_serialize_error_paths(&cancellation_prepare, 5); assert_eq!( cancellation_prepare_json["cancellation"]["reason"], "buyer changed pickup plan" @@ -1658,18 +2010,20 @@ async fn order_revision_and_cancellation_dtos_serialize_deterministically() { let cancellation_enqueue = OrderCancellationEnqueueRequest::new( buyer_actor(), - root_event, - previous_event, + root_event.clone(), + previous_event.clone(), cancellation, SdkRelayTargetPolicy::UseConfiguredRelays, ) .try_with_target_relays([RELAY, RELAY_B], SdkRelayUrlPolicy::Public) .expect("cancellation relays") - .try_with_idempotency_key("order-cancellation-dto") - .expect("cancellation idempotency") + .with_idempotency_key( + SdkIdempotencyKey::new("order-cancellation-dto").expect("cancellation idempotency"), + ) .with_created_at(created_at); let cancellation_enqueue_json = serde_json::to_value(&cancellation_enqueue).expect("cancellation enqueue json"); + assert_struct_serialize_error_paths(&cancellation_enqueue, 7); assert_eq!( cancellation_enqueue_json["idempotency_key"], serde_json::json!({ "value": "<redacted>", "len": 22 }) @@ -1681,17 +2035,33 @@ async fn order_revision_and_cancellation_dtos_serialize_deterministically() { .contains("cancellation-dto") ); + let cancellation_try_key = OrderCancellationEnqueueRequest::new( + buyer_actor(), + root_event.clone(), + previous_event.clone(), + order_cancellation("order-revision-dto"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_idempotency_key("order-cancellation-try") + .expect("cancellation try key"); + assert_eq!( + serde_json::to_value(&cancellation_try_key).expect("cancellation try json")["idempotency_key"], + serde_json::json!({ "value": "<redacted>", "len": 22 }) + ); + let event = signed_order_request_event("order-evidence-dto", 77); let request_evidence = OrderRequestEvidenceIngestRequest::new(event.clone()).with_observed_at(created_at); let request_evidence_json = serde_json::to_value(&request_evidence).expect("request evidence json"); + assert_struct_serialize_error_paths(&request_evidence, 2); assert_eq!(request_evidence_json["event"]["id"], event.id.as_str()); assert_eq!(request_evidence_json["observed_at"], 1_700_000_654); let order_evidence = OrderEvidenceIngestRequest::new(event.clone()).with_observed_at(created_at); let order_evidence_json = serde_json::to_value(&order_evidence).expect("order evidence json"); + assert_struct_serialize_error_paths(&order_evidence, 2); assert_eq!(order_evidence_json["event"]["id"], event.id.as_str()); assert_eq!(order_evidence_json["observed_at"], 1_700_000_654); } @@ -2001,20 +2371,27 @@ async fn order_revision_lifecycle_accepts_proposal_and_finalizes_agreement() { &request_event_id, &request_event_id, ); + let proposal_actor = seller_actor(); + let proposal_plan = sdk + .orders() + .prepare_revision_proposal(OrderRevisionProposalPrepareRequest::new( + proposal_actor.clone(), + request_event_ptr(&request_event), + request_event_ptr(&request_event), + proposal.clone(), + )) + .expect("prepare revision proposal"); let proposal_receipt = sdk .orders() - .enqueue_revision_proposal( - OrderRevisionProposalEnqueueRequest::new( - seller_actor(), - request_event_ptr(&request_event), - request_event_ptr(&request_event), - proposal.clone(), - SdkRelayTargetPolicy::UseConfiguredRelays, - ) - .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) - .expect("proposal target relays") - .try_with_idempotency_key("order-lifecycle-revision-proposal") - .expect("proposal idempotency"), + .enqueue_prepared_revision_proposal( + &proposal_actor, + proposal_plan, + SdkRelayTargetPolicy::try_explicit([RELAY], SdkRelayUrlPolicy::Public) + .expect("proposal target relays"), + Some( + SdkIdempotencyKey::new("order-lifecycle-revision-proposal") + .expect("proposal idempotency"), + ), &FixtureSigner::new(SELLER_SECRET_KEY_HEX), ) .await @@ -2043,18 +2420,24 @@ async fn order_revision_lifecycle_accepts_proposal_and_finalizes_agreement() { &proposal_receipt.signed_event_id, RadrootsOrderRevisionOutcome::Accepted, ); + let revision_decision_actor = buyer_actor(); + let revision_decision_plan = sdk + .orders() + .prepare_revision_decision(OrderRevisionDecisionPrepareRequest::new( + revision_decision_actor.clone(), + request_event_ptr(&request_event), + order_event_ptr(&proposal_receipt.signed_event_id), + revision_decision, + )) + .expect("prepare revision decision"); let revision_decision_receipt = sdk .orders() - .enqueue_revision_decision( - OrderRevisionDecisionEnqueueRequest::new( - buyer_actor(), - request_event_ptr(&request_event), - order_event_ptr(&proposal_receipt.signed_event_id), - revision_decision, - SdkRelayTargetPolicy::UseConfiguredRelays, - ) - .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) - .expect("revision decision target relays"), + .enqueue_prepared_revision_decision( + &revision_decision_actor, + revision_decision_plan, + SdkRelayTargetPolicy::try_explicit([RELAY], SdkRelayUrlPolicy::Public) + .expect("revision decision target relays"), + None, &FixtureSigner::new(BUYER_SECRET_KEY_HEX), ) .await @@ -2350,20 +2733,26 @@ async fn order_cancel_lifecycle_enqueue_updates_status() { .ingest_event(RadrootsEventIngest::new(request_event.clone(), 6_000)) .await .expect("ingest request"); + let cancellation_actor = buyer_actor(); + let cancellation_plan = sdk + .orders() + .prepare_cancellation(OrderCancellationPrepareRequest::new( + cancellation_actor.clone(), + request_event_ptr(&request_event), + request_event_ptr(&request_event), + order_cancellation("order-lifecycle-cancel"), + )) + .expect("prepare cancellation"); let cancellation = sdk .orders() - .enqueue_cancellation( - OrderCancellationEnqueueRequest::new( - buyer_actor(), - request_event_ptr(&request_event), - request_event_ptr(&request_event), - order_cancellation("order-lifecycle-cancel"), - SdkRelayTargetPolicy::UseConfiguredRelays, - ) - .try_with_target_relays([RELAY], SdkRelayUrlPolicy::Public) - .expect("cancellation target relays") - .try_with_idempotency_key("order-lifecycle-cancel") - .expect("cancellation idempotency"), + .enqueue_prepared_cancellation( + &cancellation_actor, + cancellation_plan, + SdkRelayTargetPolicy::try_explicit([RELAY], SdkRelayUrlPolicy::Public) + .expect("cancellation target relays"), + Some( + SdkIdempotencyKey::new("order-lifecycle-cancel").expect("cancellation idempotency"), + ), &FixtureSigner::new(BUYER_SECRET_KEY_HEX), ) .await @@ -2619,6 +3008,7 @@ async fn order_status_contract_dtos_serialize_deterministically() { let (_tempdir, sdk, _store) = directory_sdk_and_store().await; let request = status_request("order-1").with_limit(25); let request_json = serde_json::to_value(&request).expect("request json"); + assert_struct_serialize_error_paths(&request, 2); assert_eq!( request_json, @@ -2651,6 +3041,7 @@ async fn order_status_contract_dtos_serialize_deterministically() { event_ids: vec![deterministic_event_id("issue-event")], }; assert_eq!(issue.code(), "decision_payload_invalid"); + assert_struct_serialize_error_paths(&issue, 3); assert_eq!( serde_json::to_value(issue).expect("issue json"), serde_json::json!({ diff --git a/crates/sdk/tests/source_boundary.rs b/crates/sdk/tests/source_boundary.rs @@ -91,20 +91,20 @@ const REQUIRED_ORDERS_CLIENT_METHODS: &[&str] = &[ "pub async fn ingest_evidence(", "pub async fn ingest_request_evidence(", "pub fn prepare_submit(", - "pub async fn enqueue_submit<", - "pub async fn enqueue_prepared_submit<", + "pub async fn enqueue_submit(", + "pub async fn enqueue_prepared_submit(", "pub fn prepare_decision(", - "pub async fn enqueue_decision<", - "pub async fn enqueue_prepared_decision<", + "pub async fn enqueue_decision(", + "pub async fn enqueue_prepared_decision(", "pub fn prepare_revision_proposal(", - "pub async fn enqueue_revision_proposal<", - "pub async fn enqueue_prepared_revision_proposal<", + "pub async fn enqueue_revision_proposal(", + "pub async fn enqueue_prepared_revision_proposal(", "pub fn prepare_revision_decision(", - "pub async fn enqueue_revision_decision<", - "pub async fn enqueue_prepared_revision_decision<", + "pub async fn enqueue_revision_decision(", + "pub async fn enqueue_prepared_revision_decision(", "pub fn prepare_cancellation(", - "pub async fn enqueue_cancellation<", - "pub async fn enqueue_prepared_cancellation<", + "pub async fn enqueue_cancellation(", + "pub async fn enqueue_prepared_cancellation(", "pub async fn status(", ]; diff --git a/crates/sdk/tests/support/fixture_signer.rs b/crates/sdk/tests/support/fixture_signer.rs @@ -0,0 +1,58 @@ +use radroots_authority::{RadrootsEventSigner, RadrootsSignerError, RadrootsSignerIdentity}; +use radroots_events::draft::{ + RadrootsFrozenEventDraft, RadrootsSignedNostrEvent, RadrootsSignedNostrEventParts, +}; + +#[derive(Clone)] +pub struct FixtureSigner { + identity: RadrootsSignerIdentity, +} + +impl FixtureSigner { + pub 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(), + }) + } +} diff --git a/crates/sdk/tests/support/serializer_failure.rs b/crates/sdk/tests/support/serializer_failure.rs @@ -0,0 +1,274 @@ +use serde::Serialize; +use serde::ser::{self, SerializeStruct}; + +#[derive(Clone, Copy)] +enum FailingSerializeFailure { + Start, + Field(usize), + End, +} + +struct FailingStructSerializer { + failure: FailingSerializeFailure, +} + +struct FailingSerializeStruct { + field_index: usize, + failure: FailingSerializeFailure, +} + +#[derive(Debug)] +struct FailingSerializeError; + +impl core::fmt::Display for FailingSerializeError { + fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + formatter.write_str("intentional serializer failure") + } +} + +impl std::error::Error for FailingSerializeError {} + +impl ser::Error for FailingSerializeError { + fn custom<T>(_message: T) -> Self + where + T: core::fmt::Display, + { + Self + } +} + +impl FailingStructSerializer { + fn start() -> Self { + Self { + failure: FailingSerializeFailure::Start, + } + } + + fn field(field_index: usize) -> Self { + Self { + failure: FailingSerializeFailure::Field(field_index), + } + } + + fn end() -> Self { + Self { + failure: FailingSerializeFailure::End, + } + } +} + +impl ser::Serializer for FailingStructSerializer { + type Ok = (); + type Error = FailingSerializeError; + type SerializeSeq = ser::Impossible<(), FailingSerializeError>; + type SerializeTuple = ser::Impossible<(), FailingSerializeError>; + type SerializeTupleStruct = ser::Impossible<(), FailingSerializeError>; + type SerializeTupleVariant = ser::Impossible<(), FailingSerializeError>; + type SerializeMap = ser::Impossible<(), FailingSerializeError>; + type SerializeStruct = FailingSerializeStruct; + type SerializeStructVariant = ser::Impossible<(), FailingSerializeError>; + + fn serialize_bool(self, _value: bool) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_i8(self, _value: i8) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_i16(self, _value: i16) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_i32(self, _value: i32) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_i64(self, _value: i64) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_u8(self, _value: u8) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_u16(self, _value: u16) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_u32(self, _value: u32) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_u64(self, _value: u64) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_f32(self, _value: f32) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_f64(self, _value: f64) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_char(self, _value: char) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_str(self, _value: &str) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_bytes(self, _value: &[u8]) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_none(self) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_some<T>(self, _value: &T) -> Result<Self::Ok, Self::Error> + where + T: ?Sized + Serialize, + { + Err(FailingSerializeError) + } + + fn serialize_unit(self) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_unit_struct(self, _name: &'static str) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_unit_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + ) -> Result<Self::Ok, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_newtype_struct<T>( + self, + _name: &'static str, + _value: &T, + ) -> Result<Self::Ok, Self::Error> + where + T: ?Sized + Serialize, + { + Err(FailingSerializeError) + } + + fn serialize_newtype_variant<T>( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _value: &T, + ) -> Result<Self::Ok, Self::Error> + where + T: ?Sized + Serialize, + { + Err(FailingSerializeError) + } + + fn serialize_seq(self, _len: Option<usize>) -> Result<Self::SerializeSeq, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_tuple(self, _len: usize) -> Result<Self::SerializeTuple, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_tuple_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result<Self::SerializeTupleStruct, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_tuple_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result<Self::SerializeTupleVariant, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_map(self, _len: Option<usize>) -> Result<Self::SerializeMap, Self::Error> { + Err(FailingSerializeError) + } + + fn serialize_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result<Self::SerializeStruct, Self::Error> { + match self.failure { + FailingSerializeFailure::Start => Err(FailingSerializeError), + failure => Ok(FailingSerializeStruct { + field_index: 0, + failure, + }), + } + } + + fn serialize_struct_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result<Self::SerializeStructVariant, Self::Error> { + Err(FailingSerializeError) + } +} + +impl SerializeStruct for FailingSerializeStruct { + type Ok = (); + type Error = FailingSerializeError; + + fn serialize_field<T>(&mut self, _key: &'static str, _value: &T) -> Result<(), Self::Error> + where + T: ?Sized + Serialize, + { + self.field_index += 1; + match self.failure { + FailingSerializeFailure::Field(field) if self.field_index == field => { + Err(FailingSerializeError) + } + _ => Ok(()), + } + } + + fn end(self) -> Result<Self::Ok, Self::Error> { + match self.failure { + FailingSerializeFailure::End => Err(FailingSerializeError), + _ => Ok(()), + } + } +} + +pub fn assert_struct_serialize_error_paths<T>(value: &T, field_count: usize) +where + T: Serialize, +{ + value + .serialize(FailingStructSerializer::start()) + .expect_err("struct start failure"); + for field_index in 1..=field_count { + value + .serialize(FailingStructSerializer::field(field_index)) + .expect_err("struct field failure"); + } + value + .serialize(FailingStructSerializer::end()) + .expect_err("struct end failure"); +} diff --git a/crates/sdk/tests/unit/actor_json_tests.rs b/crates/sdk/tests/unit/actor_json_tests.rs @@ -0,0 +1,75 @@ +use super::{SdkActorContextJson, actor_role_code, actor_source_code}; +use radroots_authority::{RadrootsActorContext, RadrootsActorSource}; +use radroots_events::contract::RadrootsActorRole; + +#[path = "../support/serializer_failure.rs"] +mod serializer_failure; + +use serializer_failure::assert_struct_serialize_error_paths; + +const PUBKEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +#[test] +fn actor_role_and_source_codes_cover_public_actor_taxonomy() { + assert_eq!(actor_role_code(&RadrootsActorRole::Any), "any"); + assert_eq!( + actor_role_code(&RadrootsActorRole::Application), + "application" + ); + assert_eq!(actor_role_code(&RadrootsActorRole::Buyer), "buyer"); + assert_eq!(actor_role_code(&RadrootsActorRole::Farmer), "farmer"); + assert_eq!(actor_role_code(&RadrootsActorRole::Member), "member"); + assert_eq!(actor_role_code(&RadrootsActorRole::Moderator), "moderator"); + assert_eq!(actor_role_code(&RadrootsActorRole::Relay), "relay"); + assert_eq!(actor_role_code(&RadrootsActorRole::Seller), "seller"); + assert_eq!(actor_role_code(&RadrootsActorRole::Service), "service"); + + assert_eq!( + actor_source_code(RadrootsActorSource::LocalAccount), + "local_account" + ); + assert_eq!( + actor_source_code(RadrootsActorSource::ExplicitPubkey), + "explicit_pubkey" + ); + assert_eq!( + actor_source_code(RadrootsActorSource::RemoteSigner), + "remote_signer" + ); + assert_eq!(actor_source_code(RadrootsActorSource::Service), "service"); + assert_eq!(actor_source_code(RadrootsActorSource::Test), "test"); +} + +#[test] +fn actor_context_json_preserves_source_roles_and_account_id() { + let actor = RadrootsActorContext::local_account( + PUBKEY, + "acct-1", + [RadrootsActorRole::Buyer, RadrootsActorRole::Seller], + ) + .expect("actor"); + + let json = serde_json::to_value(SdkActorContextJson(&actor)).expect("actor json"); + + assert_eq!( + json, + serde_json::json!({ + "pubkey": PUBKEY, + "roles": ["buyer", "seller"], + "account_id": "acct-1", + "source": "local_account" + }) + ); +} + +#[test] +fn actor_context_json_reports_serializer_failures() { + let actor = RadrootsActorContext::local_account( + PUBKEY, + "acct-1", + [RadrootsActorRole::Buyer, RadrootsActorRole::Seller], + ) + .expect("actor"); + + assert_struct_serialize_error_paths(&SdkActorContextJson(&actor), 4); +} diff --git a/crates/sdk/tests/unit/adapters_radrootsd_tests.rs b/crates/sdk/tests/unit/adapters_radrootsd_tests.rs @@ -0,0 +1,720 @@ +use super::*; +use crate::farm::RadrootsFarmRef; +use crate::listing::{ + RadrootsListingAvailability, RadrootsListingBin, RadrootsListingDeliveryMethod, + RadrootsListingLocation, RadrootsListingProduct, RadrootsListingStatus, +}; +use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, + RadrootsCoreQuantityPrice, RadrootsCoreUnit, +}; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::thread::JoinHandle; + +struct RecordedHttpRequest { + request_line: String, + headers: Vec<(String, String)>, + body: String, +} + +fn spawn_http_server( + status: &str, + response_body: &str, +) -> (String, JoinHandle<RecordedHttpRequest>) { + spawn_http_server_with_content_length(status, response_body, response_body.len()) +} + +fn spawn_http_server_with_content_length( + status: &str, + response_body: &str, + content_length: usize, +) -> (String, JoinHandle<RecordedHttpRequest>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let endpoint = format!("http://{}/rpc", listener.local_addr().expect("addr")); + let status = status.to_owned(); + let response_body = response_body.to_owned(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + let mut request = Vec::new(); + let mut buffer = [0u8; 1024]; + loop { + let read = stream.read(&mut buffer).expect("read request"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + let headers_end = request + .windows(4) + .position(|window| window == b"\r\n\r\n") + .expect("headers end") + + 4; + let header_text = String::from_utf8_lossy(&request[..headers_end]); + let content_length = header_text + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::<usize>().expect("content length")) + }) + .unwrap_or(0); + while request.len() < headers_end + content_length { + let read = stream.read(&mut buffer).expect("read body"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + } + break; + } + } + let request_text = String::from_utf8_lossy(&request); + let (headers_text, body) = request_text.split_once("\r\n\r\n").expect("request body"); + let mut header_lines = headers_text.lines(); + let request_line = header_lines.next().expect("request line").to_owned(); + let headers = header_lines + .filter_map(|line| { + let (name, value) = line.split_once(':')?; + Some((name.to_ascii_lowercase(), value.trim().to_owned())) + }) + .collect::<Vec<_>>(); + let response = format!( + "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {content_length}\r\nconnection: close\r\n\r\n{response_body}", + ); + stream + .write_all(response.as_bytes()) + .expect("write response"); + RecordedHttpRequest { + request_line, + headers, + body: body.to_owned(), + } + }); + (endpoint, handle) +} + +fn sample_listing() -> RadrootsListing { + RadrootsListing { + d_tag: "AAAAAAAAAAAAAAAAAAAAAg".parse().expect("listing d tag"), + published_at: None, + farm: RadrootsFarmRef { + pubkey: "a".repeat(64), + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), + }, + product: RadrootsListingProduct { + key: "coffee".into(), + title: "Coffee".into(), + category: "coffee".into(), + summary: Some("Single origin coffee".into()), + process: None, + lot: None, + location: None, + profile: None, + year: None, + }, + primary_bin_id: "bin-1".parse().expect("primary bin id"), + bins: vec![RadrootsListingBin { + bin_id: "bin-1".parse().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: Some(RadrootsCoreDecimal::from(5u32)), + availability: Some(RadrootsListingAvailability::Status { + status: RadrootsListingStatus::Active, + }), + delivery_method: Some(RadrootsListingDeliveryMethod::Pickup), + location: Some(RadrootsListingLocation { + primary: "North Farm".into(), + city: None, + region: None, + country: None, + lat: None, + lng: None, + geohash: None, + }), + images: None, + } +} + +fn sample_profile() -> RadrootsProfile { + RadrootsProfile { + name: "North Farm".into(), + display_name: Some("North Farm".into()), + nip05: None, + about: Some("Organic coffee".into()), + website: Some("https://example.com".into()), + picture: None, + banner: None, + lud06: None, + lud16: None, + bot: None, + } +} + +fn sample_farm() -> RadrootsFarm { + RadrootsFarm { + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), + name: "North Farm".into(), + about: Some("Organic coffee".into()), + website: None, + picture: None, + banner: None, + location: None, + tags: Some(vec!["coffee".into()]), + } +} + +fn sample_listing_event(kind: u32) -> RadrootsNostrEvent { + let listing = sample_listing(); + let parts = listing::build_draft(&listing).expect("listing draft"); + RadrootsNostrEvent { + id: "event-1".into(), + author: listing.farm.pubkey, + created_at: 1, + kind, + tags: parts.as_wire_parts().tags.clone(), + content: parts.as_wire_parts().content.clone(), + sig: String::new(), + } +} + +fn sample_authority() -> SdkRadrootsdSignerAuthority { + SdkRadrootsdSignerAuthority { + provider_runtime_id: "local-runtime".into(), + account_identity_id: "account-1".into(), + provider_signer_session_id: Some("provider-session-secret".into()), + } +} + +fn sample_listing_publish_request() -> SdkRadrootsdListingPublishRequest { + SdkRadrootsdListingPublishRequest { + listing: sample_listing(), + kind: Some(KIND_LISTING), + signer_session_id: "signer-session-secret".into(), + signer_authority: Some(sample_authority()), + idempotency_key: Some("idem-1".into()), + } +} + +fn assert_message(error: RadrootsdError, fragment: &str) { + let message = error.to_string(); + assert!( + message.contains(fragment), + "expected {message:?} to contain {fragment:?}" + ); +} + +#[test] +fn auth_headers_omit_authorization_when_auth_is_none() { + let headers = auth_headers(&RadrootsdAuth::None).expect("headers"); + + assert!(!headers.contains_key(AUTHORIZATION)); +} + +#[test] +fn auth_headers_build_bearer_authorization() { + let headers = auth_headers(&RadrootsdAuth::BearerToken("sdk-token".into())).expect("headers"); + + assert_eq!( + headers + .get(AUTHORIZATION) + .expect("authorization") + .to_str() + .expect("authorization str"), + "Bearer sdk-token" + ); +} + +#[test] +fn auth_headers_reject_invalid_bearer_header_values() { + let error = auth_headers(&RadrootsdAuth::BearerToken("bad\ntoken".into())).expect_err("error"); + + assert!(matches!(error, RadrootsdError::InvalidAuthHeader(_))); +} + +#[test] +fn bridge_listing_publish_request_json_preserves_request_contract() { + let value = + bridge_listing_publish_request_json(&sample_listing_publish_request()).expect("json"); + + assert_eq!(value["kind"], KIND_LISTING); + assert_eq!(value["signer_session_id"], "signer-session-secret"); + assert_eq!( + value["signer_authority"]["provider_signer_session_id"], + "provider-session-secret" + ); + assert_eq!(value["idempotency_key"], "idem-1"); + assert_eq!(value["listing"]["product"]["title"], "Coffee"); +} + +#[test] +fn listing_publish_request_from_event_parses_listing_and_rejects_wrong_kind() { + let event = sample_listing_event(KIND_LISTING); + let request = SdkRadrootsdListingPublishRequest::from_event( + &event, + "signer-session-secret", + Some(sample_authority()), + Some("idem-1".to_owned()), + ) + .expect("request"); + + assert_eq!(request.kind, Some(KIND_LISTING)); + assert_eq!(request.signer_session_id, "signer-session-secret"); + assert_eq!(request.idempotency_key.as_deref(), Some("idem-1")); + assert_eq!(request.listing.product.title, "Coffee"); + + let wrong_kind = sample_listing_event(1); + assert_eq!( + SdkRadrootsdListingPublishRequest::from_event(&wrong_kind, "session", None, None) + .expect_err("wrong kind"), + listing::RadrootsListingParseError::InvalidKind(1) + ); + + let mut malformed = sample_listing_event(KIND_LISTING); + malformed.tags = Vec::new(); + let malformed_error = + SdkRadrootsdListingPublishRequest::from_event(&malformed, "session", None, None) + .expect_err("malformed listing"); + assert!(!malformed_error.to_string().is_empty()); +} + +#[tokio::test] +async fn jsonrpc_call_rejects_invalid_auth_before_transport() { + let error = jsonrpc_call::<_, Value>( + "http://127.0.0.1:9/rpc", + &RadrootsdAuth::BearerToken("bad\ntoken".to_owned()), + "1", + "listing.publish", + &json!({}), + core::time::Duration::from_millis(10), + ) + .await + .expect_err("invalid auth"); + assert!(matches!(error, RadrootsdError::InvalidAuthHeader(_))); +} + +#[test] +fn debug_output_redacts_auth_and_signer_secrets() { + let auth = RadrootsdAuth::BearerToken("token-secret".into()); + let none_auth = RadrootsdAuth::None; + let bunker = SdkRadrootsdSignerSessionConnectRequest::bunker("bunker://session"); + let connect = + SdkRadrootsdSignerSessionConnectRequest::nostrconnect("nostrconnect://session", "nsec") + .with_signer_authority(sample_authority()); + let profile_request = SdkRadrootsdProfilePublishRequest { + profile: sample_profile(), + profile_type: Some(RadrootsProfileType::Farm), + signer_session_id: "profile-session-secret".into(), + signer_authority: Some(sample_authority()), + idempotency_key: Some("profile-idem".into()), + }; + let farm_request = SdkRadrootsdFarmPublishRequest { + farm: sample_farm(), + kind: Some(30_000), + signer_session_id: "farm-session-secret".into(), + signer_authority: Some(sample_authority()), + idempotency_key: Some("farm-idem".into()), + }; + let listing_request = sample_listing_publish_request(); + let job = SdkRadrootsdBridgeJob { + job_id: "job-1".into(), + command: "bridge.listing.publish".into(), + status: "accepted".into(), + terminal: false, + recovered_after_restart: false, + signer_mode: "bunker".into(), + signer_session_id: Some("signer-session-secret".into()), + event_kind: KIND_LISTING, + event_id: Some("event-1".into()), + event_addr: Some("30402:pubkey:d-tag".into()), + relay_count: 2, + acknowledged_relay_count: 1, + }; + let job_view = SdkRadrootsdBridgeJobView { + job_id: "job-view-1".into(), + command: "bridge.listing.publish".into(), + idempotency_key: Some("view-idem".into()), + status: SdkRadrootsdBridgeJobStatus::Accepted, + terminal: false, + recovered_after_restart: true, + requested_at_unix: 1, + completed_at_unix: Some(2), + signer_mode: "nostrconnect".into(), + signer_session_id: Some("view-session-secret".into()), + event_kind: KIND_LISTING, + event_id: Some("event-1".into()), + event_addr: Some("30402:pubkey:d-tag".into()), + delivery_policy: SdkRadrootsdBridgeDeliveryPolicy::Quorum, + delivery_quorum: Some(2), + relay_count: 3, + acknowledged_relay_count: 2, + required_acknowledged_relay_count: 2, + attempt_count: 1, + attempt_summaries: vec!["ok".into()], + relay_results: vec![SdkRadrootsdBridgeRelayPublishResult { + relay_url: "wss://relay.example.com".into(), + acknowledged: true, + detail: Some("accepted".into()), + }], + relay_outcome_summary: "2/3 acknowledged".into(), + }; + + let rendered = format!( + "{none_auth:?} {auth:?} {bunker:?} {connect:?} {profile_request:?} {farm_request:?} {listing_request:?} {job:?} {job_view:?}" + ); + + assert!(rendered.contains("None")); + assert!(rendered.contains("<redacted>")); + assert!(!rendered.contains("token-secret")); + assert!(!rendered.contains("nsec")); + assert!(!rendered.contains("provider-session-secret")); + assert!(!rendered.contains("signer-session-secret")); + assert!(!rendered.contains("profile-session-secret")); + assert!(!rendered.contains("farm-session-secret")); + assert!(!rendered.contains("view-session-secret")); + assert!(!rendered.contains("signer_mode: \"bunker\"")); +} + +#[test] +fn http_status_error_omits_raw_body() { + let error = http_status_error(reqwest::StatusCode::UNAUTHORIZED, "missing secret token"); + + let message = error.to_string(); + assert!(message.contains("radrootsd returned http 401")); + assert!(message.contains("response body omitted")); + assert!(!message.contains("missing secret token")); + + assert_message( + http_status_error(reqwest::StatusCode::BAD_GATEWAY, ""), + "response body empty", + ); +} + +#[test] +fn radrootsd_error_display_covers_all_variants() { + assert_message( + RadrootsdError::InvalidAuthHeader("bad header".into()), + "invalid radrootsd bearer token header", + ); + assert_message(RadrootsdError::Http("http".into()), "http"); + assert_message(RadrootsdError::JsonRpc("jsonrpc".into()), "jsonrpc"); + assert_message( + RadrootsdError::MalformedResponse("malformed".into()), + "malformed", + ); +} + +#[test] +fn decode_jsonrpc_response_returns_result() { + let response: SdkRadrootsdBridgePublishResponse = decode_jsonrpc_response( + "bridge.listing.publish", + "radroots-sdk-listing-publish", + r#"{ + "jsonrpc": "2.0", + "id": "radroots-sdk-listing-publish", + "result": { + "deduplicated": false, + "job": { + "job_id": "job-1", + "command": "bridge.listing.publish", + "status": "accepted", + "terminal": false, + "recovered_after_restart": false, + "signer_mode": "bunker", + "signer_session_id": "signer-session-secret", + "event_kind": 30402, + "event_id": "event-1", + "event_addr": "30402:pubkey:d-tag", + "relay_count": 2, + "acknowledged_relay_count": 1 + } + } + }"#, + ) + .expect("response"); + + assert!(!response.deduplicated); + assert_eq!(response.job.job_id, "job-1"); + assert_eq!( + response.job.signer_session_id.as_deref(), + Some("signer-session-secret") + ); +} + +#[test] +fn decode_jsonrpc_response_returns_jsonrpc_error() { + let error = decode_jsonrpc_response::<SdkRadrootsdBridgePublishResponse>( + "bridge.listing.publish", + "radroots-sdk-listing-publish", + r#"{ + "jsonrpc": "2.0", + "id": "radroots-sdk-listing-publish", + "error": { "code": -32001, "message": "signer unavailable" } + }"#, + ) + .expect_err("error"); + + assert!(matches!(error, RadrootsdError::JsonRpc(_))); + assert_message( + error, + "radrootsd bridge.listing.publish failed -32001: signer unavailable", + ); +} + +#[test] +fn decode_jsonrpc_response_rejects_result_plus_error() { + let error = decode_jsonrpc_response::<serde_json::Value>( + "bridge.listing.publish", + "radroots-sdk-listing-publish", + r#"{ + "jsonrpc": "2.0", + "id": "radroots-sdk-listing-publish", + "result": { "ok": true }, + "error": { "code": -32002, "message": "ambiguous response" } + }"#, + ) + .expect_err("error"); + + assert!(matches!(error, RadrootsdError::MalformedResponse(_))); + assert_message( + error, + "radrootsd bridge.listing.publish returned result and error: -32002 ambiguous response", + ); +} + +#[test] +fn decode_jsonrpc_response_rejects_missing_result_and_error() { + let error = decode_jsonrpc_response::<serde_json::Value>( + "bridge.listing.publish", + "radroots-sdk-listing-publish", + r#"{ "jsonrpc": "2.0", "id": "radroots-sdk-listing-publish" }"#, + ) + .expect_err("error"); + + assert!(matches!(error, RadrootsdError::MalformedResponse(_))); + assert_message( + error, + "radrootsd bridge.listing.publish returned neither result nor error", + ); +} + +#[test] +fn decode_jsonrpc_response_rejects_malformed_json() { + let error = decode_jsonrpc_response::<serde_json::Value>( + "bridge.listing.publish", + "radroots-sdk-listing-publish", + r#"{ "result": "#, + ) + .expect_err("error"); + + assert!(matches!(error, RadrootsdError::MalformedResponse(_))); + assert_message(error, "decode radrootsd bridge.listing.publish response"); +} + +#[test] +fn decode_jsonrpc_response_rejects_invalid_version() { + let error = decode_jsonrpc_response::<serde_json::Value>( + "bridge.listing.publish", + "radroots-sdk-listing-publish", + r#"{ + "jsonrpc": "1.0", + "id": "radroots-sdk-listing-publish", + "result": { "ok": true } + }"#, + ) + .expect_err("error"); + + assert_message(error, "returned invalid jsonrpc version"); +} + +#[test] +fn decode_jsonrpc_response_rejects_mismatched_id() { + let error = decode_jsonrpc_response::<serde_json::Value>( + "bridge.listing.publish", + "radroots-sdk-listing-publish", + r#"{ + "jsonrpc": "2.0", + "id": "other-id", + "result": { "ok": true } + }"#, + ) + .expect_err("error"); + + assert_message(error, "returned mismatched jsonrpc id"); +} + +#[tokio::test] +async fn publish_listing_uses_http_jsonrpc_request_path() { + let (endpoint, handle) = spawn_http_server( + "200 OK", + r#"{ + "jsonrpc": "2.0", + "id": "radroots-sdk-listing-publish", + "result": { + "deduplicated": true, + "job": { + "job_id": "job-1", + "command": "bridge.listing.publish", + "status": "accepted", + "terminal": false, + "recovered_after_restart": false, + "signer_mode": "bunker", + "signer_session_id": "signer-session-secret", + "event_kind": 30402, + "event_id": "event-1", + "event_addr": "30402:pubkey:d-tag", + "relay_count": 2, + "acknowledged_relay_count": 1 + } + } + }"#, + ); + + let response = publish_listing( + &endpoint, + &RadrootsdAuth::BearerToken("sdk-token".into()), + &sample_listing_publish_request(), + Duration::from_secs(5), + ) + .await + .expect("publish response"); + let request = handle.join().expect("request"); + let body = serde_json::from_str::<Value>(&request.body).expect("body json"); + + assert!(response.deduplicated); + assert_eq!(request.request_line, "POST /rpc HTTP/1.1"); + assert!( + request + .headers + .iter() + .any(|(name, value)| { name == "authorization" && value == "Bearer sdk-token" }) + ); + assert_eq!(body["jsonrpc"], "2.0"); + assert_eq!(body["id"], "radroots-sdk-listing-publish"); + assert_eq!(body["method"], "bridge.listing.publish"); + assert_eq!( + body["params"]["signer_authority"]["provider_signer_session_id"], + "provider-session-secret" + ); +} + +#[tokio::test] +async fn publish_listing_returns_jsonrpc_errors_from_http_path() { + let (endpoint, handle) = spawn_http_server( + "200 OK", + r#"{ + "jsonrpc": "2.0", + "id": "radroots-sdk-listing-publish", + "error": { "code": -32001, "message": "signer unavailable" } + }"#, + ); + + let error = publish_listing( + &endpoint, + &RadrootsdAuth::None, + &sample_listing_publish_request(), + Duration::from_secs(5), + ) + .await + .expect_err("error"); + handle.join().expect("request"); + + assert!(matches!(error, RadrootsdError::JsonRpc(_))); + assert_message(error, "signer unavailable"); +} + +#[tokio::test] +async fn publish_listing_sanitizes_http_status_body() { + let (endpoint, handle) = spawn_http_server("500 Internal Server Error", "secret body"); + + let error = publish_listing( + &endpoint, + &RadrootsdAuth::None, + &sample_listing_publish_request(), + Duration::from_secs(5), + ) + .await + .expect_err("error"); + handle.join().expect("request"); + + let message = error.to_string(); + assert!(message.contains("radrootsd returned http 500")); + assert!(!message.contains("secret body")); +} + +#[tokio::test] +async fn publish_listing_reports_malformed_http_response_body() { + let (endpoint, handle) = spawn_http_server("200 OK", r#"{ "result": "#); + + let error = publish_listing( + &endpoint, + &RadrootsdAuth::None, + &sample_listing_publish_request(), + Duration::from_secs(5), + ) + .await + .expect_err("error"); + handle.join().expect("request"); + + assert!(matches!(error, RadrootsdError::MalformedResponse(_))); + assert_message(error, "decode radrootsd bridge.listing.publish response"); +} + +#[tokio::test] +async fn publish_listing_reports_http_response_body_read_errors() { + let body = r#"{ "jsonrpc": "2.0", "id": "radroots-sdk-listing-publish" }"#; + let (endpoint, handle) = spawn_http_server_with_content_length("200 OK", body, body.len() + 64); + + let error = publish_listing( + &endpoint, + &RadrootsdAuth::None, + &sample_listing_publish_request(), + Duration::from_secs(5), + ) + .await + .expect_err("error"); + handle.join().expect("request"); + + assert!(matches!(error, RadrootsdError::Http(_))); + assert_message(error, "read radrootsd response body"); +} + +#[tokio::test] +async fn publish_listing_reports_transport_send_errors() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind unused port"); + let endpoint = format!("http://{}/rpc", listener.local_addr().expect("addr")); + drop(listener); + + let error = publish_listing( + &endpoint, + &RadrootsdAuth::None, + &sample_listing_publish_request(), + Duration::from_millis(250), + ) + .await + .expect_err("error"); + + assert!(matches!(error, RadrootsdError::Http(_))); + assert_message(error, "send radrootsd bridge.listing.publish request"); +} diff --git a/crates/sdk/tests/unit/adapters_relay_tests.rs b/crates/sdk/tests/unit/adapters_relay_tests.rs @@ -0,0 +1,87 @@ +use super::{ + client_from_identity, configure_write_relays, connected_client_from_identity, + connected_relay_urls, publish_signed_event, signerless_client, signerless_client_with_options, +}; +use crate::adapters::signing::sign_parts_with_identity; +use crate::identity::RadrootsIdentity; +use core::time::Duration; +use radroots_events_codec::wire::WireEventParts; +use tokio::runtime::Runtime; + +#[test] +fn client_constructors_build_without_runtime_net() { + let identity = RadrootsIdentity::generate(); + let _client = client_from_identity(&identity); + let _signerless = signerless_client(); + let _signerless_with_options = signerless_client_with_options(super::RelayClientOptions::new()) + .expect("signerless client with options"); +} + +#[test] +fn signerless_client_has_no_signer() { + let runtime = Runtime::new().expect("tokio runtime"); + runtime.block_on(async { + let client = signerless_client(); + assert!(!client.has_signer().await); + }); +} + +#[test] +fn relay_helpers_accept_empty_relay_sets_without_network_endpoints() { + let runtime = Runtime::new().expect("tokio runtime"); + runtime.block_on(async { + let identity = RadrootsIdentity::generate(); + let client = client_from_identity(&identity); + + configure_write_relays(&client, &[], Duration::from_millis(1)) + .await + .expect("configure empty relays"); + assert_eq!(connected_relay_urls(&client).await, Vec::<String>::new()); + + let invalid_relays = vec!["not-a-relay-url".to_owned()]; + let error = configure_write_relays(&client, &invalid_relays, Duration::from_millis(1)) + .await + .expect_err("invalid relay"); + assert!(format!("{error:?}").contains("Url")); + let connected_error = match connected_client_from_identity( + &identity, + &invalid_relays, + Duration::from_millis(1), + ) + .await + { + Ok(_) => panic!("expected invalid connected relay"), + Err(error) => error, + }; + assert!(format!("{connected_error:?}").contains("Url")); + + let disconnected = client_from_identity(&identity); + disconnected + .add_write_relay("wss://relay.example.com") + .await + .expect("add relay"); + assert_eq!( + connected_relay_urls(&disconnected).await, + Vec::<String>::new() + ); + + let connected = connected_client_from_identity(&identity, &[], Duration::from_millis(1)) + .await + .expect("connected client"); + assert_eq!(connected_relay_urls(&connected).await, Vec::<String>::new()); + + let signed = sign_parts_with_identity( + &identity, + WireEventParts { + kind: 1, + content: "hello".to_owned(), + tags: Vec::new(), + }, + ) + .expect("signed event"); + let error = publish_signed_event(&connected, &signed) + .await + .expect_err("publish without relays"); + assert!(format!("{error:?}").contains("NoRelaysSpecified")); + }); +} diff --git a/crates/sdk/tests/unit/adapters_signing_tests.rs b/crates/sdk/tests/unit/adapters_signing_tests.rs @@ -0,0 +1,34 @@ +use super::{event_builder_from_parts, sign_parts_with_identity}; +use crate::identity::RadrootsIdentity; +use radroots_events_codec::wire::WireEventParts; + +#[test] +fn event_builder_from_parts_preserves_kind_and_content() { + let builder = event_builder_from_parts(WireEventParts { + kind: 30402, + content: "hello".into(), + tags: vec![vec!["x".into(), "y".into()]], + }) + .expect("builder"); + let identity = RadrootsIdentity::generate(); + let event = builder.build(identity.keys().public_key()); + + assert_eq!(u16::from(event.kind), 30402); + assert_eq!(event.content, "hello"); +} + +#[test] +fn sign_parts_with_identity_signs_event() { + let identity = RadrootsIdentity::generate(); + let event = sign_parts_with_identity( + &identity, + WireEventParts { + kind: 30402, + content: "hello".into(), + tags: vec![], + }, + ) + .expect("signed event"); + + assert_eq!(event.pubkey.to_hex(), identity.public_key_hex()); +} diff --git a/crates/sdk/tests/unit/error_tests.rs b/crates/sdk/tests/unit/error_tests.rs @@ -0,0 +1,321 @@ +use super::{ + RadrootsSdkError, RadrootsSdkPartialLocalMutationError, RadrootsSdkPartialLocalMutationFailure, + RadrootsSdkRecoveryAction, redacted_relay_url, +}; +use radroots_authority::RadrootsAuthorityError; +use radroots_events::contract::RadrootsActorRole; + +#[test] +fn partial_local_mutation_constructor_preserves_supplied_error() { + let error = RadrootsSdkPartialLocalMutationError { + event_id: None, + operation_kind: "listing.publish.v1".to_owned(), + idempotency_digest_prefix: None, + stored: true, + queued: false, + recovery: RadrootsSdkRecoveryAction::RetryOutboxEnqueue, + failure: RadrootsSdkPartialLocalMutationFailure::OutboxEnqueue, + }; + + assert!(matches!( + RadrootsSdkError::partial_local_mutation(error), + RadrootsSdkError::PartialLocalMutation(RadrootsSdkPartialLocalMutationError { + stored: true, + queued: false, + recovery: RadrootsSdkRecoveryAction::RetryOutboxEnqueue, + failure: RadrootsSdkPartialLocalMutationFailure::OutboxEnqueue, + .. + }) + )); + + assert!(matches!( + RadrootsSdkError::partial_outbox_enqueue_mutation( + "a".repeat(64), + "listing.publish.v1", + "digest-prefix", + ), + RadrootsSdkError::PartialLocalMutation(RadrootsSdkPartialLocalMutationError { + event_id: Some(_), + operation_kind, + idempotency_digest_prefix: Some(_), + stored: true, + queued: false, + recovery: RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey, + failure: RadrootsSdkPartialLocalMutationFailure::OutboxEnqueue, + }) if operation_kind == "listing.publish.v1" + )); + + assert!(matches!( + RadrootsSdkError::partial_outbox_idempotency_conflict_mutation( + "a".repeat(64), + "listing.publish.v1", + "digest-prefix", + ), + RadrootsSdkError::PartialLocalMutation(RadrootsSdkPartialLocalMutationError { + event_id: Some(_), + operation_kind, + idempotency_digest_prefix: Some(_), + stored: true, + queued: false, + recovery: RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey, + failure: RadrootsSdkPartialLocalMutationFailure::OutboxIdempotencyConflict, + }) if operation_kind == "listing.publish.v1" + )); +} + +#[test] +fn authority_error_conversion_redacts_pubkey_mismatches_and_falls_back() { + let actor_error = RadrootsSdkError::from(RadrootsAuthorityError::ActorPubkeyMismatch { + expected_pubkey: "a".repeat(64), + actor_pubkey: "b".repeat(64), + }); + assert!(matches!( + actor_error, + RadrootsSdkError::UnauthorizedActor { ref reason, .. } + if reason == "actor_pubkey_prefix=bbbbbbbbbbbb expected_pubkey_prefix=aaaaaaaaaaaa" + )); + + let fallback = RadrootsSdkError::from(RadrootsAuthorityError::UnknownContract { + contract_id: "contract-x".to_owned(), + }); + assert!(matches!( + fallback, + RadrootsSdkError::Authority { ref message } if message.contains("contract-x") + )); +} + +#[test] +fn listing_and_store_errors_convert_to_sdk_error_classes() { + let draft = RadrootsSdkError::from( + radroots_trade::listing::RadrootsListingDraftError::ActorRoleUnsatisfied { + required_role: RadrootsActorRole::Seller, + }, + ); + assert!(matches!( + draft, + RadrootsSdkError::UnauthorizedActor { ref operation, ref reason } + if operation == "listing.prepare_publish" && reason == "missing role Seller" + )); + + let draft_fallback = RadrootsSdkError::from( + radroots_trade::listing::RadrootsListingDraftError::InvalidFarmPubkey( + radroots_events::ids::RadrootsIdParseError::InvalidCharacter, + ), + ); + assert!(matches!( + draft_fallback, + RadrootsSdkError::ListingDraft { ref message } + if message.contains("invalid listing draft farm pubkey") + )); + + let mutation = RadrootsSdkError::from( + radroots_trade::listing::RadrootsListingMutationError::UnsupportedMutation, + ); + assert!(matches!( + mutation, + RadrootsSdkError::ListingMutation { ref message } + if message == "listing mutation is not supported" + )); + + let store = RadrootsSdkError::from( + radroots_event_store::RadrootsEventStoreError::MissingEvent("event-a".to_owned()), + ); + assert!(matches!( + store, + RadrootsSdkError::EventStore { ref message } if message.contains("event-a") + )); +} + +#[test] +fn outbox_error_conversion_handles_empty_targets_and_fallbacks() { + assert!(matches!( + RadrootsSdkError::from(radroots_outbox::RadrootsOutboxError::EmptyTargetRelays), + RadrootsSdkError::EmptyTargetRelays { ref operation } if operation == "outbox enqueue" + )); + + assert!(matches!( + RadrootsSdkError::from(radroots_outbox::RadrootsOutboxError::EventNotFound(42)), + RadrootsSdkError::Outbox { ref message } if message.contains("42") + )); +} + +#[test] +fn relay_transport_error_conversion_redacts_and_classifies_url_errors() { + let unsupported = RadrootsSdkError::from( + radroots_relay_transport::RadrootsRelayTransportError::UnsupportedRelayScheme { + url: "ftp://user:secret@relay.example.com/path?token=secret".to_owned(), + scheme: "ftp".to_owned(), + }, + ); + assert!(matches!( + unsupported, + RadrootsSdkError::InvalidRelayUrl { ref url, ref reason } + if url == "ftp://<redacted>@relay.example.com/path?<redacted>" + && reason == "unsupported scheme `ftp`" + )); + + assert!(matches!( + RadrootsSdkError::from( + radroots_relay_transport::RadrootsRelayTransportError::EmptyRelayHost { + url: "wss://".to_owned(), + }, + ), + RadrootsSdkError::InvalidRelayUrl { ref reason, .. } + if reason == "relay URL must include a host" + )); + assert!(matches!( + RadrootsSdkError::from( + radroots_relay_transport::RadrootsRelayTransportError::RelayUrlQueryOrFragment { + url: "wss://relay.example.com?token=secret".to_owned(), + }, + ), + RadrootsSdkError::InvalidRelayUrl { ref url, .. } + if url == "wss://relay.example.com?<redacted>" + )); + assert!(matches!( + RadrootsSdkError::from( + radroots_relay_transport::RadrootsRelayTransportError::EmptyTargetSet + ), + RadrootsSdkError::EmptyTargetRelays { ref operation } if operation == "relay publish" + )); + assert!(matches!( + RadrootsSdkError::from( + radroots_relay_transport::RadrootsRelayTransportError::RelayUrlParse { + url: "wss://user:secret@relay.example.com/path?token=secret".to_owned(), + reason: "bad relay URL".to_owned(), + }, + ), + RadrootsSdkError::InvalidRelayUrl { ref url, ref reason } + if url == "wss://<redacted>@relay.example.com/path?<redacted>" + && reason == "bad relay URL" + )); + assert!(matches!( + RadrootsSdkError::from(radroots_relay_transport::RadrootsRelayTransportError::Outbox( + radroots_outbox::RadrootsOutboxError::EmptyTargetRelays, + )), + RadrootsSdkError::EmptyTargetRelays { ref operation } if operation == "outbox enqueue" + )); + assert!(matches!( + RadrootsSdkError::from(radroots_relay_transport::RadrootsRelayTransportError::Transport( + "offline".to_owned(), + )), + RadrootsSdkError::RelayTransport { ref message } if message == "Relay transport error: offline" + )); +} + +#[test] +fn relay_url_redaction_handles_plain_values_and_userinfo() { + assert_eq!(redacted_relay_url("not-a-url".to_owned()), "not-a-url"); + assert_eq!( + redacted_relay_url("not-a-url?token=secret".to_owned()), + "not-a-url?<redacted>" + ); + assert_eq!( + redacted_relay_url("not-a-url#fragment".to_owned()), + "not-a-url#<redacted>" + ); + assert_eq!( + redacted_relay_url("wss://relay.example.com/path?token=secret".to_owned()), + "wss://relay.example.com/path?<redacted>" + ); + assert_eq!( + redacted_relay_url("wss://user:secret@relay.example.com/path#frag".to_owned()), + "wss://<redacted>@relay.example.com/path#<redacted>" + ); + assert_eq!( + redacted_relay_url("wss://relay.example.com/path#fragment".to_owned()), + "wss://relay.example.com/path#<redacted>" + ); + assert_eq!( + redacted_relay_url("wss://relay.example.com/path".to_owned()), + "wss://relay.example.com/path" + ); +} + +#[test] +fn sdk_error_contract_methods_cover_representative_classes_and_details() { + let errors = vec![ + RadrootsSdkError::Io { + path: "store.sqlite".into(), + message: "readonly".to_owned(), + }, + RadrootsSdkError::ClockBeforeUnixEpoch, + RadrootsSdkError::TimestampOutOfRange { value: u64::MAX }, + RadrootsSdkError::UnauthorizedActor { + operation: "listing.publish".to_owned(), + reason: "missing seller".to_owned(), + }, + RadrootsSdkError::SignerPubkeyMismatch { + operation: "listing.publish".to_owned(), + expected_pubkey_prefix: "aaaaaaaaaaaa".to_owned(), + signer_pubkey_prefix: "bbbbbbbbbbbb".to_owned(), + }, + RadrootsSdkError::EmptyTargetRelays { + operation: "relay publish".to_owned(), + }, + RadrootsSdkError::RelayTargetLimitExceeded { max: 2, actual: 3 }, + RadrootsSdkError::invalid_relay_url( + "wss://user:secret@relay.example.com/path?token=secret", + "userinfo", + ), + RadrootsSdkError::IdempotencyConflict { + operation_kind: "listing.publish.v1".to_owned(), + expected_pubkey_prefix: "aaaaaaaaaaaa".to_owned(), + existing_digest_prefix: "existing".to_owned(), + new_digest_prefix: "new".to_owned(), + }, + RadrootsSdkError::order_status_limit_invalid(0, 1, 100), + RadrootsSdkError::invalid_order_id("bad order", "bad id"), + RadrootsSdkError::ProductSyncUnsupported { + operation: "sync.push_outbox", + required_feature: "relay-runtime", + }, + RadrootsSdkError::ProductSyncRelaySetupFailure { + message: "offline".to_owned(), + }, + RadrootsSdkError::Authority { + message: "authority".to_owned(), + }, + RadrootsSdkError::EventStore { + message: "event store".to_owned(), + }, + RadrootsSdkError::InvalidRequest { + message: "invalid".to_owned(), + }, + RadrootsSdkError::ListingDraft { + message: "draft".to_owned(), + }, + RadrootsSdkError::ListingMutation { + message: "mutation".to_owned(), + }, + RadrootsSdkError::Outbox { + message: "outbox".to_owned(), + }, + RadrootsSdkError::RelayTransport { + message: "transport".to_owned(), + }, + RadrootsSdkError::Projection { + message: "projection".to_owned(), + }, + RadrootsSdkError::partial_outbox_idempotency_conflict_mutation( + "a".repeat(64), + "listing.publish.v1", + "digest-prefix", + ), + ]; + + for error in errors { + let detail = error.detail_json(); + assert_eq!(detail["code"], error.code()); + assert_eq!( + detail["class"], + serde_json::to_value(error.class()).expect("class json") + ); + assert_eq!(detail["retryable"], error.retryable()); + assert_eq!( + detail["recovery_actions"], + serde_json::to_value(error.recovery_actions()).expect("recovery json") + ); + assert!(error.to_string().starts_with("sdk ")); + } +} diff --git a/crates/sdk/tests/unit/farms_runtime_tests.rs b/crates/sdk/tests/unit/farms_runtime_tests.rs @@ -0,0 +1,263 @@ +use super::*; + +#[path = "../support/fixture_signer.rs"] +mod fixture_signer; +#[path = "../support/serializer_failure.rs"] +mod serializer_failure; + +use fixture_signer::FixtureSigner; +use serializer_failure::assert_struct_serialize_error_paths; + +const FARMER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const FARM_A_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; +const FARM_B_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAQ"; +const FARM_C_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAg"; +const RELAY_A: &str = "wss://relay-a.radroots.test"; +const RELAY_B: &str = "wss://relay-b.radroots.test"; + +fn farmer_actor() -> RadrootsActorContext { + RadrootsActorContext::test(FARMER, [RadrootsActorRole::Farmer]).expect("actor") +} + +fn farm(d_tag: &str, name: &str) -> RadrootsFarm { + RadrootsFarm { + d_tag: d_tag.to_owned(), + name: name.to_owned(), + about: Some("Vegetable farm".to_owned()), + website: Some("https://example.invalid/farm".to_owned()), + picture: None, + banner: None, + location: None, + tags: Some(vec!["vegetables".to_owned(), "local".to_owned()]), + } +} + +#[test] +fn farm_publish_plan_rejects_invalid_draft_tags() { + let actor = RadrootsActorContext::test( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + [RadrootsActorRole::Farmer], + ) + .expect("actor"); + let farm = RadrootsFarm { + d_tag: "AAAAAAAAAAAAAAAAAAAAA!".to_owned(), + name: "Invalid Farm".to_owned(), + about: None, + website: None, + picture: None, + banner: None, + location: None, + tags: None, + }; + let error = farm_publish_plan( + &actor, + farm, + RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000), + ) + .err() + .expect("invalid farm plan"); + assert!(matches!( + error, + RadrootsSdkError::InvalidRequest { message } if message.contains("draft encode failed") + )); + + assert!(matches!( + farm_addr(&actor, ""), + Err(RadrootsSdkError::InvalidRequest { message }) if message.contains("farm address") + )); +} + +#[test] +fn farm_runtime_request_builders_and_serializers_cover_success_paths() { + let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_321); + let prepare = + FarmPreparePublishRequest::new(farmer_actor(), farm(FARM_A_D_TAG, "Serialized Farm")) + .with_created_at(created_at); + assert_struct_serialize_error_paths(&prepare, 3); + let prepare_json = serde_json::to_value(&prepare).expect("prepare json"); + assert_eq!(prepare_json["actor"]["pubkey"], FARMER); + assert_eq!(prepare_json["created_at"], 1_700_000_321); + + let enqueue = FarmEnqueuePublishRequest::new( + farmer_actor(), + farm(FARM_B_D_TAG, "Queued Farm"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY_A, RELAY_B], SdkRelayUrlPolicy::Public) + .expect("relay targets") + .with_idempotency_key(SdkIdempotencyKey::new("farm-unit-key").expect("key")) + .with_created_at(created_at); + assert_struct_serialize_error_paths(&enqueue, 5); + let enqueue_json = serde_json::to_value(&enqueue).expect("enqueue json"); + assert_eq!(enqueue_json["target_relays"]["kind"], "explicit"); + assert_eq!(enqueue_json["created_at"], 1_700_000_321); + assert!(!enqueue_json.to_string().contains("farm-unit-key")); + + let try_key = FarmEnqueuePublishRequest::new( + farmer_actor(), + farm(FARM_C_D_TAG, "Try Key Farm"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_idempotency_key("farm-unit-try-key") + .expect("try key"); + assert_eq!( + serde_json::to_value(&try_key).expect("try key json")["idempotency_key"]["len"], + "farm-unit-try-key".len() + ); +} + +#[test] +fn farm_request_builders_reject_invalid_options_and_timestamp_bounds() { + let invalid_relays = FarmEnqueuePublishRequest::new( + farmer_actor(), + farm(FARM_A_D_TAG, "Invalid Relay Farm"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays(["http://relay.radroots.test"], SdkRelayUrlPolicy::Public); + assert!(invalid_relays.is_err()); + + let invalid_key = FarmEnqueuePublishRequest::new( + farmer_actor(), + farm(FARM_B_D_TAG, "Invalid Key Farm"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_idempotency_key(""); + assert!(invalid_key.is_err()); + + let timestamp_error = farm_publish_plan( + &farmer_actor(), + farm(FARM_C_D_TAG, "Future Farm"), + RadrootsSdkTimestamp::from_unix_seconds(u64::MAX), + ) + .err() + .expect("timestamp error"); + assert!(matches!( + timestamp_error, + RadrootsSdkError::TimestampOutOfRange { .. } + )); +} + +#[tokio::test] +async fn farm_client_prepare_resolves_default_and_explicit_created_at() { + let sdk = crate::RadrootsSdk::builder() + .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_400)) + .build() + .await + .expect("sdk"); + let default_plan = sdk + .farms() + .prepare_publish(FarmPreparePublishRequest::new( + farmer_actor(), + farm(FARM_A_D_TAG, "Default Clock Farm"), + )) + .expect("default plan"); + assert_eq!( + default_plan.created_at, + RadrootsSdkTimestamp::from_unix_seconds(1_700_000_400) + ); + + let explicit_plan = sdk + .farms() + .prepare_publish( + FarmPreparePublishRequest::new( + farmer_actor(), + farm(FARM_B_D_TAG, "Explicit Clock Farm"), + ) + .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_401)), + ) + .expect("explicit plan"); + assert_eq!( + explicit_plan.created_at, + RadrootsSdkTimestamp::from_unix_seconds(1_700_000_401) + ); +} + +#[tokio::test] +async fn farm_client_prepare_reports_clock_errors() { + let sdk = crate::RadrootsSdk::builder() + .clock(crate::RadrootsSdkClock::BeforeUnixEpoch) + .build() + .await + .expect("sdk"); + let error = sdk + .farms() + .prepare_publish(FarmPreparePublishRequest::new( + farmer_actor(), + farm(FARM_A_D_TAG, "Clock Error Farm"), + )) + .expect_err("clock error"); + assert!(matches!(error, RadrootsSdkError::ClockBeforeUnixEpoch)); +} + +#[tokio::test] +async fn farm_enqueue_publish_reports_prepare_errors_before_signing() { + let sdk = crate::RadrootsSdk::builder() + .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500)) + .build() + .await + .expect("sdk"); + let error = sdk + .farms() + .enqueue_publish( + FarmEnqueuePublishRequest::new( + farmer_actor(), + farm("AAAAAAAAAAAAAAAAAAAAA!", "Invalid Enqueue Farm"), + SdkRelayTargetPolicy::try_explicit([RELAY_A], SdkRelayUrlPolicy::Public) + .expect("target relays"), + ), + &FixtureSigner::new(FARMER), + ) + .await + .expect_err("prepare error"); + assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); +} + +#[tokio::test] +async fn farm_client_enqueue_methods_cover_source_attached_workflow_paths() { + let sdk = crate::RadrootsSdk::builder() + .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500)) + .build() + .await + .expect("sdk"); + let signer = FixtureSigner::new(FARMER); + let actor = farmer_actor(); + let receipt = sdk + .farms() + .enqueue_publish( + FarmEnqueuePublishRequest::new( + actor.clone(), + farm(FARM_A_D_TAG, "Enqueued Farm"), + SdkRelayTargetPolicy::try_explicit([RELAY_A], SdkRelayUrlPolicy::Public) + .expect("target relays"), + ) + .try_with_idempotency_key("farm-source-attached-enqueue") + .expect("idempotency"), + &signer, + ) + .await + .expect("enqueue farm"); + assert_eq!(receipt.signed_event_id, receipt.expected_event_id); + assert_eq!(receipt.state, SdkMutationState::StoredAndQueued); + + let plan = sdk + .farms() + .prepare_publish(FarmPreparePublishRequest::new( + actor.clone(), + farm(FARM_B_D_TAG, "Prepared Farm"), + )) + .expect("prepared farm"); + let prepared = sdk + .farms() + .enqueue_prepared_publish( + &actor, + plan, + SdkRelayTargetPolicy::try_explicit([RELAY_B], SdkRelayUrlPolicy::Public) + .expect("prepared target relays"), + None, + &signer, + ) + .await + .expect("enqueue prepared farm"); + assert_eq!(prepared.signed_event_id, prepared.expected_event_id); + assert_eq!(prepared.local_event_seq, 2); +} diff --git a/crates/sdk/tests/unit/idempotency_tests.rs b/crates/sdk/tests/unit/idempotency_tests.rs @@ -0,0 +1,36 @@ +use super::SdkIdempotencyKey; +use crate::RadrootsSdkError; + +#[path = "../support/serializer_failure.rs"] +mod serializer_failure; + +use serializer_failure::assert_struct_serialize_error_paths; + +#[test] +fn empty_key_is_rejected_before_redacted_storage() { + assert!(matches!( + SdkIdempotencyKey::new(""), + Err(RadrootsSdkError::InvalidRequest { ref message }) + if message == "idempotency key must not be empty" + )); +} + +#[test] +fn derived_key_is_deterministic_and_consumable() { + let relays = vec![ + "wss://relay-b.example.com".to_owned(), + "wss://relay-a.example.com".to_owned(), + ]; + let first = SdkIdempotencyKey::derive("listing.publish.v1", "event-a", "pubkey-a", &relays); + let second = SdkIdempotencyKey::derive("listing.publish.v1", "event-a", "pubkey-a", &relays); + + assert_eq!(first.as_str(), second.as_str()); + assert!(first.into_string().starts_with("listing.publish.v1:")); +} + +#[test] +fn idempotency_key_reports_serializer_failures() { + let key = SdkIdempotencyKey::new("idempotent").expect("key"); + + assert_struct_serialize_error_paths(&key, 2); +} diff --git a/crates/sdk/tests/unit/identity_tests.rs b/crates/sdk/tests/unit/identity_tests.rs @@ -0,0 +1,13 @@ +use super::{RadrootsEncryptedIdentityFile, RadrootsIdentity}; + +#[test] +fn encrypted_identity_file_round_trips() { + let temp = tempfile::tempdir().expect("tempdir"); + let file = RadrootsEncryptedIdentityFile::new(temp.path().join("identity.enc.json")); + let identity = RadrootsIdentity::generate(); + + file.store(&identity).expect("store identity"); + let loaded = file.load().expect("load identity"); + + assert_eq!(loaded.public_key_hex(), identity.public_key_hex()); +} diff --git a/crates/sdk/tests/unit/listings_runtime_tests.rs b/crates/sdk/tests/unit/listings_runtime_tests.rs @@ -0,0 +1,294 @@ +use super::*; +use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, + RadrootsCoreQuantityPrice, RadrootsCoreUnit, +}; +use radroots_events::{ + contract::RadrootsActorRole, + farm::RadrootsFarmRef, + ids::{RadrootsDTag, RadrootsInventoryBinId}, + listing::{RadrootsListingBin, RadrootsListingProduct}, + resource_area::RadrootsResourceAreaRef, +}; + +#[path = "../support/fixture_signer.rs"] +mod fixture_signer; +#[path = "../support/serializer_failure.rs"] +mod serializer_failure; + +use fixture_signer::FixtureSigner; +use serializer_failure::assert_struct_serialize_error_paths; + +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.radroots.test"; +const RELAY_B: &str = "wss://relay-b.radroots.test"; + +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: "lettuce".to_owned(), + title: title.to_owned(), + category: "greens".to_owned(), + summary: Some("Fresh greens".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(12u32), + RadrootsCoreUnit::Each, + ), + price_per_canonical_unit: RadrootsCoreQuantityPrice { + amount: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(4u32), + RadrootsCoreCurrency::USD, + ), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1u32), + RadrootsCoreUnit::Each, + ), + }, + 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, + } +} + +#[test] +fn listing_runtime_request_builders_and_serializers_cover_success_paths() { + let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_321); + let prepare = + ListingPreparePublishRequest::new(actor(), listing(LISTING_A_D_TAG, "Serialized Greens")) + .with_created_at(created_at); + assert_struct_serialize_error_paths(&prepare, 3); + let prepare_json = serde_json::to_value(&prepare).expect("prepare json"); + assert_eq!(prepare_json["actor"]["pubkey"], SELLER); + assert_eq!(prepare_json["created_at"], 1_700_000_321); + + let enqueue = ListingEnqueuePublishRequest::from_document( + actor(), + RadrootsListingDraftDocumentV1::new(listing(LISTING_B_D_TAG, "Queued Greens")), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_target_relays([RELAY_A, RELAY_B], SdkRelayUrlPolicy::Public) + .expect("relay targets") + .with_idempotency_key(SdkIdempotencyKey::new("listing-unit-key").expect("key")) + .with_created_at(created_at); + assert_struct_serialize_error_paths(&enqueue, 5); + let enqueue_json = serde_json::to_value(&enqueue).expect("enqueue json"); + assert_eq!(enqueue_json["target_relays"]["kind"], "explicit"); + assert_eq!(enqueue_json["created_at"], 1_700_000_321); + assert!(!enqueue_json.to_string().contains("listing-unit-key")); + + let try_key = ListingEnqueuePublishRequest::new( + actor(), + listing(LISTING_C_D_TAG, "Try Key Greens"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_idempotency_key("listing-unit-try-key") + .expect("try key"); + assert_eq!( + serde_json::to_value(&try_key).expect("try key json")["idempotency_key"]["len"], + "listing-unit-try-key".len() + ); +} + +#[test] +fn listing_request_builders_reject_invalid_options_and_timestamp_bounds() { + let invalid_key = ListingEnqueuePublishRequest::new( + actor(), + listing(LISTING_A_D_TAG, "Invalid Key Greens"), + SdkRelayTargetPolicy::UseConfiguredRelays, + ) + .try_with_idempotency_key(""); + assert!(invalid_key.is_err()); + + let timestamp_error = listing_publish_plan( + &actor(), + RadrootsListingDraftDocumentV1::new(listing(LISTING_B_D_TAG, "Future Greens")), + RadrootsSdkTimestamp::from_unix_seconds(u64::MAX), + ) + .err() + .expect("timestamp error"); + assert!(matches!( + timestamp_error, + RadrootsSdkError::TimestampOutOfRange { .. } + )); + + let mut invalid_resource_area_listing = + listing(LISTING_C_D_TAG, "Invalid Resource Area Greens"); + invalid_resource_area_listing.resource_area = Some(RadrootsResourceAreaRef { + pubkey: SELLER.to_owned(), + d_tag: "bad d tag".to_owned(), + }); + let mutation_error = listing_publish_plan( + &actor(), + RadrootsListingDraftDocumentV1::new(invalid_resource_area_listing), + RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000), + ) + .err() + .expect("mutation error"); + assert!(matches!( + mutation_error, + RadrootsSdkError::ListingMutation { message } if message.contains("failed to encode") + )); +} + +#[tokio::test] +async fn listing_client_prepare_resolves_default_and_explicit_created_at() { + let sdk = crate::RadrootsSdk::builder() + .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_400)) + .build() + .await + .expect("sdk"); + let default_plan = sdk + .listings() + .prepare_publish(ListingPreparePublishRequest::new( + actor(), + listing(LISTING_A_D_TAG, "Default Clock Greens"), + )) + .expect("default plan"); + assert_eq!( + default_plan.created_at, + RadrootsSdkTimestamp::from_unix_seconds(1_700_000_400) + ); + + let explicit_plan = sdk + .listings() + .prepare_publish( + ListingPreparePublishRequest::new( + actor(), + listing(LISTING_B_D_TAG, "Explicit Clock Greens"), + ) + .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_401)), + ) + .expect("explicit plan"); + assert_eq!( + explicit_plan.created_at, + RadrootsSdkTimestamp::from_unix_seconds(1_700_000_401) + ); +} + +#[tokio::test] +async fn listing_client_prepare_reports_clock_errors() { + let sdk = crate::RadrootsSdk::builder() + .clock(crate::RadrootsSdkClock::BeforeUnixEpoch) + .build() + .await + .expect("sdk"); + let error = sdk + .listings() + .prepare_publish(ListingPreparePublishRequest::new( + actor(), + listing(LISTING_A_D_TAG, "Clock Error Greens"), + )) + .expect_err("clock error"); + assert!(matches!(error, RadrootsSdkError::ClockBeforeUnixEpoch)); +} + +#[tokio::test] +async fn listing_enqueue_publish_reports_prepare_errors_before_signing() { + let sdk = crate::RadrootsSdk::builder() + .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500)) + .build() + .await + .expect("sdk"); + let error = sdk + .listings() + .enqueue_publish( + ListingEnqueuePublishRequest::new( + actor(), + listing(LISTING_A_D_TAG, "Future Enqueue Greens"), + SdkRelayTargetPolicy::try_explicit([RELAY_A], SdkRelayUrlPolicy::Public) + .expect("target relays"), + ) + .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(u64::MAX)), + &FixtureSigner::new(SELLER), + ) + .await + .expect_err("prepare error"); + assert!(matches!( + error, + RadrootsSdkError::TimestampOutOfRange { .. } + )); +} + +#[tokio::test] +async fn listing_client_enqueue_methods_cover_source_attached_workflow_paths() { + let sdk = crate::RadrootsSdk::builder() + .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500)) + .build() + .await + .expect("sdk"); + let signer = FixtureSigner::new(SELLER); + let actor = actor(); + let receipt = sdk + .listings() + .enqueue_publish( + ListingEnqueuePublishRequest::new( + actor.clone(), + listing(LISTING_A_D_TAG, "Enqueued Greens"), + SdkRelayTargetPolicy::try_explicit([RELAY_A], SdkRelayUrlPolicy::Public) + .expect("target relays"), + ) + .try_with_idempotency_key("listing-source-attached-enqueue") + .expect("idempotency"), + &signer, + ) + .await + .expect("enqueue listing"); + assert_eq!(receipt.signed_event_id, receipt.expected_event_id); + assert_eq!(receipt.state, SdkMutationState::StoredAndQueued); + + let plan = sdk + .listings() + .prepare_publish(ListingPreparePublishRequest::from_document( + actor.clone(), + RadrootsListingDraftDocumentV1::new(listing(LISTING_B_D_TAG, "Prepared Greens")), + )) + .expect("prepared listing"); + let prepared = sdk + .listings() + .enqueue_prepared_publish( + &actor, + plan, + SdkRelayTargetPolicy::try_explicit([RELAY_B], SdkRelayUrlPolicy::Public) + .expect("prepared target relays"), + None, + &signer, + ) + .await + .expect("enqueue prepared listing"); + assert_eq!(prepared.signed_event_id, prepared.expected_event_id); + assert_eq!(prepared.local_event_seq, 2); +} diff --git a/crates/sdk/tests/unit/orders_runtime_tests.rs b/crates/sdk/tests/unit/orders_runtime_tests.rs @@ -0,0 +1,3274 @@ +use super::*; +use crate::RadrootsSdk; +use radroots_authority::{RadrootsSignerError, RadrootsSignerIdentity}; +use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, +}; +use radroots_event_store::RadrootsEventStoreError; +use radroots_events::{ + draft::RadrootsSignedNostrEvent, + kinds::KIND_LISTING, + order::{ + RadrootsOrderDecisionOutcome, RadrootsOrderEconomicItem, RadrootsOrderEconomicLine, + RadrootsOrderInventoryCommitment, RadrootsOrderItem, RadrootsOrderPricingBasis, + RadrootsOrderRevisionOutcome, + }, +}; +use radroots_nostr::prelude::{ + RadrootsNostrKeys, RadrootsNostrSecretKey, radroots_nostr_sign_frozen_draft, +}; +use radroots_trade::order::{RadrootsOrderEventDecodeError, RadrootsOrderIssue}; + +#[path = "../support/serializer_failure.rs"] +mod serializer_failure; + +use serializer_failure::assert_struct_serialize_error_paths; + +const BUYER_SECRET_KEY_HEX: &str = + "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; +const BUYER_PUBLIC_KEY_HEX: &str = + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; +const SELLER_SECRET_KEY_HEX: &str = + "59392e9068f66431b12f70218fb61281cb6b433d7f27c55d61f1a63fe1a96ff8"; +const SELLER_PUBLIC_KEY_HEX: &str = + "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; +const RELAY: &str = "wss://relay.radroots.test"; + +fn hex_64(character: char) -> String { + core::iter::repeat_n(character, 64).collect() +} + +fn hex_128(character: char) -> String { + core::iter::repeat_n(character, 128).collect() +} + +fn event_id(character: char) -> RadrootsEventId { + RadrootsEventId::parse(hex_64(character)).expect("event id") +} + +fn pubkey(character: char) -> RadrootsPublicKey { + RadrootsPublicKey::parse(hex_64(character)).expect("pubkey") +} + +fn order_id() -> RadrootsOrderId { + RadrootsOrderId::parse("order-test-1").expect("order id") +} + +fn listing_addr(seller_pubkey: &RadrootsPublicKey) -> RadrootsListingAddress { + RadrootsListingAddress::parse(format!( + "30402:{}:AAAAAAAAAAAAAAAAAAAAAg", + seller_pubkey.as_str() + )) + .expect("listing address") +} + +fn projection( + order_id: &RadrootsOrderId, + listing_addr: &RadrootsListingAddress, + buyer_pubkey: &RadrootsPublicKey, + seller_pubkey: &RadrootsPublicKey, + root_event_id: &RadrootsEventId, + previous_event_id: &RadrootsEventId, +) -> RadrootsOrderProjection { + RadrootsOrderProjection { + order_id: order_id.clone(), + status: RadrootsOrderStatus::Requested, + request_event_id: Some(root_event_id.clone()), + decision_event_id: None, + cancellation_event_id: None, + lifecycle_terminal: false, + economics: None, + agreement_event_id: None, + pending_revision_event_id: None, + listing_addr: Some(listing_addr.clone()), + buyer_pubkey: Some(buyer_pubkey.clone()), + seller_pubkey: Some(seller_pubkey.clone()), + last_event_id: Some(previous_event_id.clone()), + issues: Vec::new(), + } +} + +fn refs<'a>( + order_id: &'a RadrootsOrderId, + listing_addr: &'a RadrootsListingAddress, + buyer_pubkey: &'a RadrootsPublicKey, + seller_pubkey: &'a RadrootsPublicKey, + root_event_id: &'a RadrootsEventId, + previous_event_id: &'a RadrootsEventId, +) -> OrderLifecycleReferences<'a> { + OrderLifecycleReferences { + operation: "order test", + order_id, + listing_addr, + buyer_pubkey, + seller_pubkey, + root_event_id, + previous_event_id, + } +} + +fn ptr(id: String) -> RadrootsNostrEventPtr { + RadrootsNostrEventPtr { id, relays: None } +} + +fn nostr_event(id: String, kind: u32) -> RadrootsNostrEvent { + RadrootsNostrEvent { + id, + author: hex_64('c'), + created_at: 1_700_000_000, + kind, + tags: Vec::new(), + content: "{}".to_owned(), + sig: hex_128('f'), + } +} + +fn actor(pubkey: &RadrootsPublicKey, role: RadrootsActorRole) -> RadrootsActorContext { + RadrootsActorContext::test(pubkey.as_str(), [role]).expect("actor") +} + +fn buyer_actor() -> RadrootsActorContext { + actor(&pubkey('c'), RadrootsActorRole::Buyer) +} + +fn seller_actor() -> RadrootsActorContext { + actor(&pubkey('d'), RadrootsActorRole::Seller) +} + +fn decimal(value: &str) -> RadrootsCoreDecimal { + value.parse().expect("decimal") +} + +fn usd(value: &str) -> RadrootsCoreMoney { + RadrootsCoreMoney::new(decimal(value), RadrootsCoreCurrency::USD) +} + +fn economics(bin_count: u32, total: &str) -> RadrootsOrderEconomics { + RadrootsOrderEconomics { + quote_id: "quote-1".parse().expect("quote id"), + quote_version: 1, + pricing_basis: RadrootsOrderPricingBasis::ListingEvent, + currency: RadrootsCoreCurrency::USD, + items: vec![RadrootsOrderEconomicItem { + bin_id: "bin-1".parse().expect("bin id"), + bin_count, + quantity_amount: decimal("1"), + quantity_unit: RadrootsCoreUnit::Each, + unit_price_amount: decimal("5"), + unit_price_currency: RadrootsCoreCurrency::USD, + line_subtotal: usd(total), + }], + discounts: Vec::<RadrootsOrderEconomicLine>::new(), + adjustments: Vec::<RadrootsOrderEconomicLine>::new(), + subtotal: usd(total), + discount_total: usd("0"), + adjustment_total: usd("0"), + total: usd(total), + } +} + +fn order_request_payload() -> RadrootsOrderRequest { + let buyer_pubkey = pubkey('c'); + let seller_pubkey = pubkey('d'); + RadrootsOrderRequest { + order_id: order_id(), + listing_addr: listing_addr(&seller_pubkey), + buyer_pubkey, + seller_pubkey, + items: vec![RadrootsOrderItem { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 2, + }], + economics: economics(2, "10"), + } +} + +fn order_decision_payload() -> RadrootsOrderDecision { + let buyer_pubkey = pubkey('c'); + let seller_pubkey = pubkey('d'); + RadrootsOrderDecision { + order_id: order_id(), + listing_addr: listing_addr(&seller_pubkey), + buyer_pubkey, + seller_pubkey, + decision: RadrootsOrderDecisionOutcome::Accepted { + inventory_commitments: vec![RadrootsOrderInventoryCommitment { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 2, + }], + }, + } +} + +fn revision_proposal_payload( + root_event_id: &RadrootsEventId, + previous_event_id: &RadrootsEventId, +) -> RadrootsOrderRevisionProposal { + let buyer_pubkey = pubkey('c'); + let seller_pubkey = pubkey('d'); + RadrootsOrderRevisionProposal { + revision_id: "revision-order-test-1".parse().expect("revision id"), + order_id: order_id(), + listing_addr: listing_addr(&seller_pubkey), + buyer_pubkey, + seller_pubkey, + root_event_id: root_event_id.clone(), + prev_event_id: previous_event_id.clone(), + items: vec![RadrootsOrderItem { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 3, + }], + economics: economics(3, "15"), + reason: "increase quantity".to_owned(), + } +} + +fn revision_decision_payload( + proposal: &RadrootsOrderRevisionProposal, + previous_event_id: &RadrootsEventId, + decision: RadrootsOrderRevisionOutcome, +) -> RadrootsOrderRevisionDecision { + RadrootsOrderRevisionDecision { + revision_id: proposal.revision_id.clone(), + order_id: proposal.order_id.clone(), + listing_addr: proposal.listing_addr.clone(), + buyer_pubkey: proposal.buyer_pubkey.clone(), + seller_pubkey: proposal.seller_pubkey.clone(), + root_event_id: proposal.root_event_id.clone(), + prev_event_id: previous_event_id.clone(), + decision, + } +} + +fn cancellation_payload() -> RadrootsOrderCancellation { + let buyer_pubkey = pubkey('c'); + let seller_pubkey = pubkey('d'); + RadrootsOrderCancellation { + order_id: order_id(), + listing_addr: listing_addr(&seller_pubkey), + buyer_pubkey, + seller_pubkey, + reason: "buyer changed pickup plan".to_owned(), + } +} + +#[derive(Clone)] +struct OrderFixtureSigner { + identity: RadrootsSignerIdentity, + keys: RadrootsNostrKeys, +} + +impl OrderFixtureSigner { + fn new(secret_key_hex: &str) -> Self { + let secret_key = RadrootsNostrSecretKey::from_hex(secret_key_hex).expect("secret key"); + let keys = RadrootsNostrKeys::new(secret_key); + let pubkey = keys.public_key().to_hex(); + Self { + identity: RadrootsSignerIdentity::new(pubkey).expect("identity"), + keys, + } + } +} + +impl RadrootsEventSigner for OrderFixtureSigner { + fn pubkey(&self) -> &RadrootsPublicKey { + self.identity.pubkey() + } + + fn sign_frozen_draft( + &self, + draft: &RadrootsFrozenEventDraft, + ) -> Result<RadrootsSignedNostrEvent, RadrootsSignerError> { + radroots_nostr_sign_frozen_draft(&self.keys, draft).map_err(|error| { + RadrootsSignerError::SigningFailed { + message: error.to_string(), + } + }) + } +} + +fn fixture_buyer_actor() -> RadrootsActorContext { + RadrootsActorContext::test(BUYER_PUBLIC_KEY_HEX, [RadrootsActorRole::Buyer]).expect("actor") +} + +fn fixture_seller_actor() -> RadrootsActorContext { + RadrootsActorContext::test(SELLER_PUBLIC_KEY_HEX, [RadrootsActorRole::Seller]).expect("actor") +} + +fn fixture_listing_addr() -> RadrootsListingAddress { + RadrootsListingAddress::parse(format!( + "{KIND_LISTING}:{SELLER_PUBLIC_KEY_HEX}:AAAAAAAAAAAAAAAAAAAAAg" + )) + .expect("listing address") +} + +fn fixture_event_ptr(character: char) -> RadrootsNostrEventPtr { + RadrootsNostrEventPtr { + id: hex_64(character), + relays: Some(RELAY.to_owned()), + } +} + +fn fixture_order_event_ptr(event_id: &RadrootsEventId) -> RadrootsNostrEventPtr { + RadrootsNostrEventPtr { + id: event_id.as_str().to_owned(), + relays: Some(RELAY.to_owned()), + } +} + +fn fixture_order_id(raw: &str) -> RadrootsOrderId { + RadrootsOrderId::parse(raw).expect("order id") +} + +fn fixture_order_request(raw_order_id: &str) -> RadrootsOrderRequest { + RadrootsOrderRequest { + order_id: fixture_order_id(raw_order_id), + listing_addr: fixture_listing_addr(), + buyer_pubkey: BUYER_PUBLIC_KEY_HEX.parse().expect("buyer pubkey"), + seller_pubkey: SELLER_PUBLIC_KEY_HEX.parse().expect("seller pubkey"), + items: vec![RadrootsOrderItem { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 2, + }], + economics: economics(2, "10"), + } +} + +fn fixture_order_decision(raw_order_id: &str) -> RadrootsOrderDecision { + RadrootsOrderDecision { + order_id: fixture_order_id(raw_order_id), + listing_addr: fixture_listing_addr(), + buyer_pubkey: BUYER_PUBLIC_KEY_HEX.parse().expect("buyer pubkey"), + seller_pubkey: SELLER_PUBLIC_KEY_HEX.parse().expect("seller pubkey"), + decision: RadrootsOrderDecisionOutcome::Accepted { + inventory_commitments: vec![RadrootsOrderInventoryCommitment { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 2, + }], + }, + } +} + +fn fixture_revision_proposal( + raw_order_id: &str, + root_event_id: &RadrootsEventId, + previous_event_id: &RadrootsEventId, +) -> RadrootsOrderRevisionProposal { + RadrootsOrderRevisionProposal { + revision_id: format!("revision-{raw_order_id}") + .parse() + .expect("revision id"), + order_id: fixture_order_id(raw_order_id), + listing_addr: fixture_listing_addr(), + buyer_pubkey: BUYER_PUBLIC_KEY_HEX.parse().expect("buyer pubkey"), + seller_pubkey: SELLER_PUBLIC_KEY_HEX.parse().expect("seller pubkey"), + root_event_id: root_event_id.clone(), + prev_event_id: previous_event_id.clone(), + items: vec![RadrootsOrderItem { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 3, + }], + economics: economics(3, "15"), + reason: "increase quantity".to_owned(), + } +} + +fn fixture_revision_decision( + proposal: &RadrootsOrderRevisionProposal, + previous_event_id: &RadrootsEventId, +) -> RadrootsOrderRevisionDecision { + RadrootsOrderRevisionDecision { + revision_id: proposal.revision_id.clone(), + order_id: proposal.order_id.clone(), + listing_addr: proposal.listing_addr.clone(), + buyer_pubkey: proposal.buyer_pubkey.clone(), + seller_pubkey: proposal.seller_pubkey.clone(), + root_event_id: proposal.root_event_id.clone(), + prev_event_id: previous_event_id.clone(), + decision: RadrootsOrderRevisionOutcome::Accepted, + } +} + +fn fixture_cancellation(raw_order_id: &str) -> RadrootsOrderCancellation { + RadrootsOrderCancellation { + order_id: fixture_order_id(raw_order_id), + listing_addr: fixture_listing_addr(), + buyer_pubkey: BUYER_PUBLIC_KEY_HEX.parse().expect("buyer pubkey"), + seller_pubkey: SELLER_PUBLIC_KEY_HEX.parse().expect("seller pubkey"), + reason: "buyer changed pickup plan".to_owned(), + } +} + +fn fixture_target_relays() -> SdkRelayTargetPolicy { + SdkRelayTargetPolicy::try_explicit([RELAY], SdkRelayUrlPolicy::Public).expect("target relays") +} + +async fn prepared_order_sdk() -> RadrootsSdk { + RadrootsSdk::builder() + .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)) + .build() + .await + .expect("sdk") +} + +async fn enqueue_fixture_submit(sdk: &RadrootsSdk, raw_order_id: &str) -> OrderSubmitReceipt { + let buyer = fixture_buyer_actor(); + let plan = sdk + .orders() + .prepare_submit(OrderSubmitPrepareRequest::new( + buyer.clone(), + fixture_event_ptr('a'), + fixture_order_request(raw_order_id), + )) + .expect("submit plan"); + sdk.orders() + .enqueue_prepared_submit( + &buyer, + plan, + fixture_target_relays(), + None, + &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue submit") +} + +fn event_from_parts( + parts: WireEventParts, + contract_id: &str, + expected_pubkey: &RadrootsPublicKey, +) -> RadrootsNostrEvent { + let frozen = to_frozen_draft(parts, contract_id, expected_pubkey.as_str(), 1_700_000_000) + .expect("frozen draft"); + RadrootsNostrEvent { + id: frozen.expected_event_id, + author: expected_pubkey.as_str().to_owned(), + created_at: frozen.created_at, + kind: frozen.kind, + tags: frozen.tags, + content: frozen.content, + sig: hex_128('f'), + } +} + +fn request_event() -> RadrootsNostrEvent { + let listing_event = ptr(event_id('a').as_str().to_owned()); + let request = order_request_payload(); + event_from_parts( + order::build_order_request_draft(&listing_event, &request) + .expect("request draft") + .into_wire_parts(), + ORDER_REQUEST_CONTRACT_ID, + &request.buyer_pubkey, + ) +} + +fn order_request_evidence_error( + result: Result<OrderRequestEvidence, RadrootsSdkError>, +) -> RadrootsSdkError { + result.err().expect("expected order request evidence error") +} + +fn invalid_request_message(error: RadrootsSdkError) -> String { + match error { + RadrootsSdkError::InvalidRequest { message } => message, + other => panic!("expected invalid request error, got {other:?}"), + } +} + +fn parsed_order_evidence_error( + result: Result<ParsedOrderEvidence, RadrootsSdkError>, +) -> RadrootsSdkError { + result.err().expect("expected parse error") +} + +fn projection_message(error: RadrootsSdkError) -> String { + match error { + RadrootsSdkError::Projection { message } => message, + other => panic!("expected projection error, got {other:?}"), + } +} + +fn assert_error_display<T: core::fmt::Debug>(result: Result<T, RadrootsSdkError>, expected: &str) { + assert!(result.unwrap_err().to_string().contains(expected)); +} + +fn assert_partial_outbox_enqueue(error: RadrootsSdkError, operation_kind: &str) { + assert!(matches!( + error, + RadrootsSdkError::PartialLocalMutation(partial) + if partial.operation_kind == operation_kind + && partial.stored + && !partial.queued + && partial.failure == crate::RadrootsSdkPartialLocalMutationFailure::OutboxEnqueue + )); +} + +#[test] +fn workflow_plan_builders_cover_success_and_actor_mismatch_paths() { + let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000); + let buyer_actor = buyer_actor(); + let seller_actor = seller_actor(); + let listing_event_id = event_id('a'); + let submit_plan = order_submit_plan( + &buyer_actor, + ptr(listing_event_id.as_str().to_owned()), + order_request_payload(), + created_at, + ) + .expect("submit plan"); + assert_eq!(submit_plan.workflow.kind, OrderWorkflowKind::Submit); + assert_eq!(submit_plan.listing_event_id, listing_event_id); + + let request_event = ptr(submit_plan.expected_event_id.as_str().to_owned()); + let decision_plan = order_decision_plan( + &seller_actor, + request_event.clone(), + order_decision_payload(), + created_at, + ) + .expect("decision plan"); + assert_eq!(decision_plan.workflow.kind, OrderWorkflowKind::Decision); + + let proposal = revision_proposal_payload( + &submit_plan.expected_event_id, + &decision_plan.expected_event_id, + ); + let proposal_plan = order_revision_proposal_plan( + &seller_actor, + request_event.clone(), + ptr(decision_plan.expected_event_id.as_str().to_owned()), + proposal.clone(), + created_at, + ) + .expect("revision proposal plan"); + assert_eq!( + proposal_plan.workflow.kind, + OrderWorkflowKind::RevisionProposal + ); + + let revision_decision = revision_decision_payload( + &proposal, + &proposal_plan.expected_event_id, + RadrootsOrderRevisionOutcome::Accepted, + ); + let revision_decision_plan = order_revision_decision_plan( + &buyer_actor, + request_event.clone(), + ptr(proposal_plan.expected_event_id.as_str().to_owned()), + revision_decision, + created_at, + ) + .expect("revision decision plan"); + assert_eq!( + revision_decision_plan.workflow.kind, + OrderWorkflowKind::RevisionDecision + ); + + let cancellation_plan = order_cancellation_plan( + &buyer_actor, + request_event, + ptr(decision_plan.expected_event_id.as_str().to_owned()), + cancellation_payload(), + created_at, + ) + .expect("cancellation plan"); + assert_eq!( + cancellation_plan.workflow.kind, + OrderWorkflowKind::Cancellation + ); + + let mut wrong_submit = order_request_payload(); + wrong_submit.buyer_pubkey = pubkey('e'); + assert!(matches!( + order_submit_plan( + &buyer_actor, + ptr(listing_event_id.as_str().to_owned()), + wrong_submit, + created_at, + ), + Err(RadrootsSdkError::UnauthorizedActor { .. }) + )); + + let mut wrong_proposal = proposal; + wrong_proposal.seller_pubkey = pubkey('e'); + assert!(matches!( + order_revision_proposal_plan( + &seller_actor, + ptr(submit_plan.expected_event_id.as_str().to_owned()), + ptr(decision_plan.expected_event_id.as_str().to_owned()), + wrong_proposal, + created_at, + ), + Err(RadrootsSdkError::UnauthorizedActor { .. }) + )); + + let mut wrong_proposal_refs = revision_proposal_payload( + &submit_plan.expected_event_id, + &decision_plan.expected_event_id, + ); + wrong_proposal_refs.prev_event_id = event_id('f'); + assert!( + invalid_request_message( + order_revision_proposal_plan( + &seller_actor, + ptr(submit_plan.expected_event_id.as_str().to_owned()), + ptr(decision_plan.expected_event_id.as_str().to_owned()), + wrong_proposal_refs, + created_at, + ) + .unwrap_err() + ) + .contains("prev_event_id") + ); + + let mut wrong_revision_decision = revision_decision_payload( + &revision_proposal_payload( + &submit_plan.expected_event_id, + &decision_plan.expected_event_id, + ), + &proposal_plan.expected_event_id, + RadrootsOrderRevisionOutcome::Declined { + reason: "not workable".to_owned(), + }, + ); + wrong_revision_decision.buyer_pubkey = pubkey('e'); + assert!(matches!( + order_revision_decision_plan( + &buyer_actor, + ptr(submit_plan.expected_event_id.as_str().to_owned()), + ptr(proposal_plan.expected_event_id.as_str().to_owned()), + wrong_revision_decision, + created_at, + ), + Err(RadrootsSdkError::UnauthorizedActor { .. }) + )); + + let mut wrong_revision_decision_refs = revision_decision_payload( + &revision_proposal_payload( + &submit_plan.expected_event_id, + &decision_plan.expected_event_id, + ), + &proposal_plan.expected_event_id, + RadrootsOrderRevisionOutcome::Accepted, + ); + wrong_revision_decision_refs.root_event_id = event_id('f'); + assert!( + invalid_request_message( + order_revision_decision_plan( + &buyer_actor, + ptr(submit_plan.expected_event_id.as_str().to_owned()), + ptr(proposal_plan.expected_event_id.as_str().to_owned()), + wrong_revision_decision_refs, + created_at, + ) + .unwrap_err() + ) + .contains("root_event_id") + ); + assert!(matches!( + order_revision_decision_plan( + &buyer_actor, + ptr("not-hex".to_owned()), + ptr(proposal_plan.expected_event_id.as_str().to_owned()), + revision_decision_payload( + &revision_proposal_payload( + &submit_plan.expected_event_id, + &decision_plan.expected_event_id, + ), + &proposal_plan.expected_event_id, + RadrootsOrderRevisionOutcome::Accepted, + ), + created_at, + ), + Err(RadrootsSdkError::InvalidRequest { .. }) + )); + assert!(matches!( + order_revision_decision_plan( + &buyer_actor, + ptr(submit_plan.expected_event_id.as_str().to_owned()), + ptr("not-hex".to_owned()), + revision_decision_payload( + &revision_proposal_payload( + &submit_plan.expected_event_id, + &decision_plan.expected_event_id, + ), + &proposal_plan.expected_event_id, + RadrootsOrderRevisionOutcome::Accepted, + ), + created_at, + ), + Err(RadrootsSdkError::InvalidRequest { .. }) + )); + + let mut wrong_cancellation = cancellation_payload(); + wrong_cancellation.buyer_pubkey = pubkey('e'); + assert!(matches!( + order_cancellation_plan( + &buyer_actor, + ptr(submit_plan.expected_event_id.as_str().to_owned()), + ptr(decision_plan.expected_event_id.as_str().to_owned()), + wrong_cancellation, + created_at, + ), + Err(RadrootsSdkError::UnauthorizedActor { .. }) + )); + assert!(matches!( + order_cancellation_plan( + &buyer_actor, + ptr("not-hex".to_owned()), + ptr(decision_plan.expected_event_id.as_str().to_owned()), + cancellation_payload(), + created_at, + ), + Err(RadrootsSdkError::InvalidRequest { .. }) + )); + assert!(matches!( + order_cancellation_plan( + &buyer_actor, + ptr(submit_plan.expected_event_id.as_str().to_owned()), + ptr("not-hex".to_owned()), + cancellation_payload(), + created_at, + ), + Err(RadrootsSdkError::InvalidRequest { .. }) + )); + + assert!(matches!( + sdk_timestamp_ms(RadrootsSdkTimestamp::from_unix_seconds(u64::MAX)), + Err(RadrootsSdkError::TimestampOutOfRange { .. }) + )); + assert!(matches!( + sdk_timestamp_ms(RadrootsSdkTimestamp::from_unix_seconds( + (i64::MAX as u64 / 1_000) + 1 + )), + Err(RadrootsSdkError::TimestampOutOfRange { .. }) + )); + + let mut invalid_decision = order_decision_payload(); + invalid_decision.decision = RadrootsOrderDecisionOutcome::Accepted { + inventory_commitments: Vec::new(), + }; + assert!( + invalid_request_message( + validate_order_payload(&invalid_decision, "order decision").unwrap_err() + ) + .contains("payload is invalid") + ); + assert!(matches!( + order_decision_plan( + &seller_actor, + ptr(submit_plan.expected_event_id.as_str().to_owned()), + invalid_decision, + created_at, + ), + Err(RadrootsSdkError::InvalidRequest { .. }) + )); + + let mut invalid_proposal = revision_proposal_payload( + &submit_plan.expected_event_id, + &decision_plan.expected_event_id, + ); + invalid_proposal.reason.clear(); + assert!( + invalid_request_message( + order_revision_proposal_plan( + &seller_actor, + ptr(submit_plan.expected_event_id.as_str().to_owned()), + ptr(decision_plan.expected_event_id.as_str().to_owned()), + invalid_proposal, + created_at, + ) + .unwrap_err() + ) + .contains("payload is invalid") + ); + + let invalid_revision_decision = revision_decision_payload( + &revision_proposal_payload( + &submit_plan.expected_event_id, + &decision_plan.expected_event_id, + ), + &proposal_plan.expected_event_id, + RadrootsOrderRevisionOutcome::Declined { + reason: " ".to_owned(), + }, + ); + assert!( + invalid_request_message( + order_revision_decision_plan( + &buyer_actor, + ptr(submit_plan.expected_event_id.as_str().to_owned()), + ptr(proposal_plan.expected_event_id.as_str().to_owned()), + invalid_revision_decision, + created_at, + ) + .unwrap_err() + ) + .contains("payload is invalid") + ); + + let mut invalid_cancellation = cancellation_payload(); + invalid_cancellation.reason = " ".to_owned(); + assert!( + invalid_request_message( + order_cancellation_plan( + &buyer_actor, + ptr(submit_plan.expected_event_id.as_str().to_owned()), + ptr(decision_plan.expected_event_id.as_str().to_owned()), + invalid_cancellation, + created_at, + ) + .unwrap_err() + ) + .contains("payload is invalid") + ); + + let validation_error = validate_order_payload( + &RadrootsOrderCancellation { + reason: String::new(), + ..cancellation_payload() + }, + "order cancellation", + ) + .err() + .expect("validation error"); + assert!(invalid_request_message(validation_error).contains("payload is invalid")); + + let (frozen_draft, expected_event_id) = freeze_order_workflow_draft( + WireEventParts { + kind: KIND_ORDER_REQUEST, + content: "{}".to_owned(), + tags: vec![vec!["p".to_owned(), pubkey('c').as_str().to_owned()]], + }, + ORDER_REQUEST_CONTRACT_ID, + pubkey('c').as_str(), + 1_700_000_000, + "order test", + ); + assert_eq!(expected_event_id, frozen_draft.expected_event_id); +} + +#[test] +fn workflow_plan_builders_cover_timestamp_reference_and_role_errors() { + let out_of_range = RadrootsSdkTimestamp::from_unix_seconds(u64::MAX); + let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000); + let buyer_actor = buyer_actor(); + let seller_actor = seller_actor(); + let root_event_id = event_id('a'); + let previous_event_id = event_id('b'); + + assert!(matches!( + order_submit_plan( + &buyer_actor, + ptr(root_event_id.as_str().to_owned()), + order_request_payload(), + out_of_range, + ), + Err(RadrootsSdkError::TimestampOutOfRange { .. }) + )); + assert!(matches!( + order_decision_plan( + &seller_actor, + ptr(root_event_id.as_str().to_owned()), + order_decision_payload(), + out_of_range, + ), + Err(RadrootsSdkError::TimestampOutOfRange { .. }) + )); + assert!(matches!( + order_revision_proposal_plan( + &seller_actor, + ptr(root_event_id.as_str().to_owned()), + ptr(previous_event_id.as_str().to_owned()), + revision_proposal_payload(&root_event_id, &previous_event_id), + out_of_range, + ), + Err(RadrootsSdkError::TimestampOutOfRange { .. }) + )); + assert!(matches!( + order_revision_decision_plan( + &buyer_actor, + ptr(root_event_id.as_str().to_owned()), + ptr(previous_event_id.as_str().to_owned()), + revision_decision_payload( + &revision_proposal_payload(&root_event_id, &previous_event_id), + &previous_event_id, + RadrootsOrderRevisionOutcome::Accepted, + ), + out_of_range, + ), + Err(RadrootsSdkError::TimestampOutOfRange { .. }) + )); + assert!(matches!( + order_cancellation_plan( + &buyer_actor, + ptr(root_event_id.as_str().to_owned()), + ptr(previous_event_id.as_str().to_owned()), + cancellation_payload(), + out_of_range, + ), + Err(RadrootsSdkError::TimestampOutOfRange { .. }) + )); + + assert!(matches!( + order_decision_plan( + &seller_actor, + ptr("not-hex".to_owned()), + order_decision_payload(), + created_at, + ), + Err(RadrootsSdkError::InvalidRequest { .. }) + )); + assert!(matches!( + order_revision_proposal_plan( + &buyer_actor, + ptr(root_event_id.as_str().to_owned()), + ptr(previous_event_id.as_str().to_owned()), + revision_proposal_payload(&root_event_id, &previous_event_id), + created_at, + ), + Err(RadrootsSdkError::UnauthorizedActor { .. }) + )); + assert!(matches!( + order_revision_proposal_plan( + &seller_actor, + ptr("not-hex".to_owned()), + ptr(previous_event_id.as_str().to_owned()), + revision_proposal_payload(&root_event_id, &previous_event_id), + created_at, + ), + Err(RadrootsSdkError::InvalidRequest { .. }) + )); + assert!(matches!( + order_revision_proposal_plan( + &seller_actor, + ptr(root_event_id.as_str().to_owned()), + ptr("not-hex".to_owned()), + revision_proposal_payload(&root_event_id, &previous_event_id), + created_at, + ), + Err(RadrootsSdkError::InvalidRequest { .. }) + )); + assert!(matches!( + order_revision_decision_plan( + &seller_actor, + ptr(root_event_id.as_str().to_owned()), + ptr(previous_event_id.as_str().to_owned()), + revision_decision_payload( + &revision_proposal_payload(&root_event_id, &previous_event_id), + &previous_event_id, + RadrootsOrderRevisionOutcome::Accepted, + ), + created_at, + ), + Err(RadrootsSdkError::UnauthorizedActor { .. }) + )); + assert!(matches!( + order_cancellation_plan( + &seller_actor, + ptr(root_event_id.as_str().to_owned()), + ptr(previous_event_id.as_str().to_owned()), + cancellation_payload(), + created_at, + ), + Err(RadrootsSdkError::UnauthorizedActor { .. }) + )); +} + +#[test] +fn order_evidence_parses_all_lifecycle_event_kinds() { + let request_event = request_event(); + let request_evidence = parse_order_evidence(&request_event).expect("request evidence"); + assert_eq!(request_evidence.event_kind, KIND_ORDER_REQUEST); + + let root_event_id = request_evidence.event_id.clone(); + let decision = order_decision_payload(); + let decision_event = event_from_parts( + order::build_order_decision_draft(&root_event_id, &root_event_id, &decision) + .expect("decision draft") + .into_wire_parts(), + ORDER_DECISION_CONTRACT_ID, + &decision.seller_pubkey, + ); + assert_eq!( + parse_order_evidence(&decision_event) + .expect("decision evidence") + .event_kind, + KIND_ORDER_DECISION + ); + + let decision_event_id = + RadrootsEventId::parse(decision_event.id.as_str()).expect("decision event id"); + let proposal = revision_proposal_payload(&root_event_id, &decision_event_id); + let proposal_event = event_from_parts( + order::build_order_revision_proposal_draft(&root_event_id, &decision_event_id, &proposal) + .expect("proposal draft") + .into_wire_parts(), + ORDER_REVISION_PROPOSAL_CONTRACT_ID, + &proposal.seller_pubkey, + ); + assert_eq!( + parse_order_evidence(&proposal_event) + .expect("proposal evidence") + .event_kind, + KIND_ORDER_REVISION_PROPOSAL + ); + + let proposal_event_id = + RadrootsEventId::parse(proposal_event.id.as_str()).expect("proposal event id"); + let revision_decision = revision_decision_payload( + &proposal, + &proposal_event_id, + RadrootsOrderRevisionOutcome::Accepted, + ); + let revision_decision_event = event_from_parts( + order::build_order_revision_decision_draft( + &root_event_id, + &proposal_event_id, + &revision_decision, + ) + .expect("revision decision draft") + .into_wire_parts(), + ORDER_REVISION_DECISION_CONTRACT_ID, + &revision_decision.buyer_pubkey, + ); + assert_eq!( + parse_order_evidence(&revision_decision_event) + .expect("revision decision evidence") + .event_kind, + KIND_ORDER_REVISION_DECISION + ); + + let cancellation = cancellation_payload(); + let cancellation_event = event_from_parts( + order::build_order_cancellation_draft(&root_event_id, &decision_event_id, &cancellation) + .expect("cancellation draft") + .into_wire_parts(), + ORDER_CANCELLATION_CONTRACT_ID, + &cancellation.buyer_pubkey, + ); + assert_eq!( + parse_order_evidence(&cancellation_event) + .expect("cancellation evidence") + .event_kind, + KIND_ORDER_CANCELLATION + ); +} + +#[test] +fn order_request_evidence_parses_and_rejects_malformed_envelopes() { + let event = request_event(); + let evidence = parse_order_request_evidence(&event).expect("request evidence"); + assert_eq!(evidence.order_id, order_id()); + assert_eq!(evidence.buyer_pubkey, pubkey('c')); + assert_eq!(evidence.seller_pubkey, pubkey('d')); + + let mut invalid_id = event.clone(); + invalid_id.id = "not-hex".to_owned(); + assert!( + invalid_request_message(order_request_evidence_error(parse_order_request_evidence( + &invalid_id, + ))) + .contains("event id is invalid") + ); + + let mut invalid_author = event.clone(); + invalid_author.author = "not-hex".to_owned(); + assert!( + invalid_request_message(order_request_evidence_error(parse_order_request_evidence( + &invalid_author, + ))) + .contains("decode failed") + ); + + let request = order_request_payload(); + let author_mismatch = event_from_parts( + order::build_order_request_draft(&ptr(event_id('a').as_str().to_owned()), &request) + .expect("request draft") + .into_wire_parts(), + ORDER_REQUEST_CONTRACT_ID, + &pubkey('d'), + ); + assert!( + invalid_request_message(order_request_evidence_error(parse_order_request_evidence( + &author_mismatch, + ))) + .contains("decode failed") + ); + + let mut decode_failure = event.clone(); + decode_failure.content = "{}".to_owned(); + assert!( + invalid_request_message(order_request_evidence_error(parse_order_request_evidence( + &decode_failure, + ))) + .contains("decode failed") + ); + + let mut envelope = serde_json::from_str::<serde_json::Value>(event.content.as_str()) + .expect("request envelope"); + envelope["order_id"] = serde_json::Value::String("other-order".to_owned()); + let mut order_mismatch = event.clone(); + order_mismatch.content = serde_json::to_string(&envelope).expect("mismatched envelope"); + assert!( + invalid_request_message(order_request_evidence_error(parse_order_request_evidence( + &order_mismatch, + ))) + .contains("decode failed") + ); + + let mut envelope = serde_json::from_str::<serde_json::Value>(event.content.as_str()) + .expect("request envelope"); + envelope["listing_addr"] = serde_json::Value::String(format!("30402:{}:other", hex_64('d'))); + let mut listing_mismatch = event; + listing_mismatch.content = serde_json::to_string(&envelope).expect("mismatched envelope"); + assert!( + invalid_request_message(order_request_evidence_error(parse_order_request_evidence( + &listing_mismatch, + ))) + .contains("decode failed") + ); +} + +#[test] +fn decision_request_evidence_preflight_covers_all_rejection_branches() { + let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000); + let request_event_id = event_id('a'); + let plan = order_decision_plan( + &seller_actor(), + ptr(request_event_id.as_str().to_owned()), + order_decision_payload(), + created_at, + ) + .expect("decision plan"); + let mut projection = projection( + &plan.order_id, + &plan.listing_addr, + &plan.buyer_pubkey, + &plan.seller_pubkey, + &request_event_id, + &request_event_id, + ); + assert!(require_decision_request_evidence(&plan, &projection).is_ok()); + + projection.request_event_id = Some(event_id('b')); + assert!( + invalid_request_message(require_decision_request_evidence(&plan, &projection).unwrap_err()) + .contains("does not match local request") + ); + + projection.request_event_id = Some(request_event_id.clone()); + projection.status = RadrootsOrderStatus::Accepted; + assert!( + invalid_request_message(require_decision_request_evidence(&plan, &projection).unwrap_err()) + .contains("requires requested local state") + ); + + projection.status = RadrootsOrderStatus::Requested; + projection.pending_revision_event_id = Some(event_id('c')); + assert!( + invalid_request_message(require_decision_request_evidence(&plan, &projection).unwrap_err()) + .contains("cannot follow pending revision") + ); + + projection.pending_revision_event_id = None; + projection.issues = vec![RadrootsOrderIssue::MissingRequest]; + assert!( + invalid_request_message(require_decision_request_evidence(&plan, &projection).unwrap_err()) + .contains("reducer issue") + ); + + projection.issues.clear(); + projection.seller_pubkey = None; + assert!( + invalid_request_message(require_decision_request_evidence(&plan, &projection).unwrap_err()) + .contains("missing seller_pubkey") + ); + + projection.seller_pubkey = Some(plan.seller_pubkey.clone()); + projection.listing_addr = Some(listing_addr(&pubkey('e'))); + assert!( + invalid_request_message(require_decision_request_evidence(&plan, &projection).unwrap_err()) + .contains("listing_addr") + ); + + projection.listing_addr = Some(plan.listing_addr.clone()); + projection.buyer_pubkey = Some(pubkey('e')); + assert!( + invalid_request_message(require_decision_request_evidence(&plan, &projection).unwrap_err()) + .contains("buyer_pubkey") + ); +} + +#[test] +fn order_status_next_action_covers_revision_and_fallback_terminal_paths() { + let order_id = order_id(); + let root_event_id = event_id('a'); + let previous_event_id = event_id('b'); + let buyer_pubkey = pubkey('c'); + let seller_pubkey = pubkey('d'); + let listing_addr = listing_addr(&seller_pubkey); + let mut projection = projection( + &order_id, + &listing_addr, + &buyer_pubkey, + &seller_pubkey, + &root_event_id, + &previous_event_id, + ); + projection.pending_revision_event_id = Some(previous_event_id.clone()); + + let eligibility = OrderStatusEligibility::from_projection(&projection); + assert!(eligibility.can_decide_revision); + assert_eq!( + OrderStatusNextActionKind::from_projection(&projection, &eligibility), + OrderStatusNextActionKind::DecideRevision + ); + + let receipt = OrderStatusReceipt::from_query_result(RadrootsOrderProjectionQueryResult { + projection: projection.clone(), + event_count: 2, + limit_applied: 10, + event_ids: vec![root_event_id.clone(), previous_event_id.clone()], + }); + assert_eq!( + receipt.next_action, + OrderStatusNextActionKind::DecideRevision + ); + assert!(receipt.evidence.has_pending_revision); + + projection.pending_revision_event_id = None; + let fallback = OrderStatusNextActionKind::from_projection( + &projection, + &OrderStatusEligibility { + can_decide: false, + can_propose_revision: true, + can_decide_revision: false, + can_cancel: true, + }, + ); + assert_eq!(fallback, OrderStatusNextActionKind::Terminal); + + projection.lifecycle_terminal = true; + let terminal_eligibility = OrderStatusEligibility::from_projection(&projection); + assert_eq!( + OrderStatusNextActionKind::from_projection(&projection, &terminal_eligibility), + OrderStatusNextActionKind::Terminal + ); + + projection.lifecycle_terminal = false; + projection.issues = vec![RadrootsOrderIssue::ForkedLifecycle { + event_ids: vec![event_id('e')], + }]; + let issue_eligibility = OrderStatusEligibility::from_projection(&projection); + assert_eq!( + OrderStatusNextActionKind::from_projection(&projection, &issue_eligibility), + OrderStatusNextActionKind::InspectEvidenceIssues + ); +} + +#[test] +fn order_issue_mapping_covers_every_trade_issue_variant() { + let one = event_id('a'); + let two = event_id('b'); + let many = vec![one.clone(), two.clone()]; + + macro_rules! single { + ($issue:ident, $kind:ident) => { + ( + RadrootsOrderIssue::$issue { + event_id: one.clone(), + }, + SdkOrderStatusIssueKind::$kind, + 1, + ) + }; + } + + let cases = vec![ + ( + RadrootsOrderIssue::MissingRequest, + SdkOrderStatusIssueKind::MissingRequest, + 0, + ), + ( + RadrootsOrderIssue::MultipleRequests { + event_ids: many.clone(), + }, + SdkOrderStatusIssueKind::MultipleRequests, + 2, + ), + single!(RequestPayloadInvalid, RequestPayloadInvalid), + single!(RequestOrderIdMismatch, RequestOrderIdMismatch), + single!(RequestAuthorMismatch, RequestAuthorMismatch), + single!(RequestListingAddressInvalid, RequestListingAddressInvalid), + single!(RequestSellerListingMismatch, RequestSellerListingMismatch), + single!(DecisionPayloadInvalid, DecisionPayloadInvalid), + single!(DecisionOrderIdMismatch, DecisionOrderIdMismatch), + single!(DecisionAuthorMismatch, DecisionAuthorMismatch), + single!(DecisionCounterpartyMismatch, DecisionCounterpartyMismatch), + single!(DecisionBuyerMismatch, DecisionBuyerMismatch), + single!(DecisionSellerMismatch, DecisionSellerMismatch), + single!(DecisionListingAddressInvalid, DecisionListingAddressInvalid), + single!(DecisionListingMismatch, DecisionListingMismatch), + single!(DecisionRootMismatch, DecisionRootMismatch), + single!(DecisionPreviousMismatch, DecisionPreviousMismatch), + single!( + DecisionMissingInventoryCommitments, + DecisionMissingInventoryCommitments + ), + single!( + DecisionInventoryCommitmentMismatch, + DecisionInventoryCommitmentMismatch + ), + single!(DecisionMissingReason, DecisionMissingReason), + ( + RadrootsOrderIssue::ConflictingDecisions { + event_ids: many.clone(), + }, + SdkOrderStatusIssueKind::ConflictingDecisions, + 2, + ), + single!( + RevisionProposalPayloadInvalid, + RevisionProposalPayloadInvalid + ), + single!( + RevisionProposalOrderIdMismatch, + RevisionProposalOrderIdMismatch + ), + single!( + RevisionProposalAuthorMismatch, + RevisionProposalAuthorMismatch + ), + single!( + RevisionProposalCounterpartyMismatch, + RevisionProposalCounterpartyMismatch + ), + single!(RevisionProposalBuyerMismatch, RevisionProposalBuyerMismatch), + single!( + RevisionProposalSellerMismatch, + RevisionProposalSellerMismatch + ), + single!( + RevisionProposalListingAddressInvalid, + RevisionProposalListingAddressInvalid + ), + single!( + RevisionProposalListingMismatch, + RevisionProposalListingMismatch + ), + single!(RevisionProposalRootMismatch, RevisionProposalRootMismatch), + single!( + RevisionProposalPreviousMismatch, + RevisionProposalPreviousMismatch + ), + single!( + RevisionDecisionWithoutProposal, + RevisionDecisionWithoutProposal + ), + single!( + RevisionDecisionPayloadInvalid, + RevisionDecisionPayloadInvalid + ), + single!( + RevisionDecisionOrderIdMismatch, + RevisionDecisionOrderIdMismatch + ), + single!( + RevisionDecisionAuthorMismatch, + RevisionDecisionAuthorMismatch + ), + single!( + RevisionDecisionCounterpartyMismatch, + RevisionDecisionCounterpartyMismatch + ), + single!(RevisionDecisionBuyerMismatch, RevisionDecisionBuyerMismatch), + single!( + RevisionDecisionSellerMismatch, + RevisionDecisionSellerMismatch + ), + single!( + RevisionDecisionListingAddressInvalid, + RevisionDecisionListingAddressInvalid + ), + single!( + RevisionDecisionListingMismatch, + RevisionDecisionListingMismatch + ), + single!(RevisionDecisionRootMismatch, RevisionDecisionRootMismatch), + single!( + RevisionDecisionPreviousMismatch, + RevisionDecisionPreviousMismatch + ), + single!( + RevisionDecisionRevisionIdMismatch, + RevisionDecisionRevisionIdMismatch + ), + single!( + CancellationWithoutCancellableOrder, + CancellationWithoutCancellableOrder + ), + single!(CancellationPayloadInvalid, CancellationPayloadInvalid), + single!(CancellationOrderIdMismatch, CancellationOrderIdMismatch), + single!(CancellationAuthorMismatch, CancellationAuthorMismatch), + single!( + CancellationCounterpartyMismatch, + CancellationCounterpartyMismatch + ), + single!(CancellationBuyerMismatch, CancellationBuyerMismatch), + single!(CancellationSellerMismatch, CancellationSellerMismatch), + single!( + CancellationListingAddressInvalid, + CancellationListingAddressInvalid + ), + single!(CancellationListingMismatch, CancellationListingMismatch), + single!(CancellationRootMismatch, CancellationRootMismatch), + single!(CancellationPreviousMismatch, CancellationPreviousMismatch), + ( + RadrootsOrderIssue::ForkedLifecycle { event_ids: many }, + SdkOrderStatusIssueKind::ForkedLifecycle, + 2, + ), + ]; + + for (issue, expected_kind, expected_event_count) in cases { + let sdk_issue = SdkOrderStatusIssue::from(issue); + assert_eq!(sdk_issue.kind, expected_kind); + assert_eq!(sdk_issue.event_ids.len(), expected_event_count); + assert_eq!(sdk_issue.code(), expected_kind.code()); + } +} + +#[test] +fn lifecycle_projection_helpers_reject_unclean_mismatched_and_stale_state() { + let order_id = order_id(); + let root_event_id = event_id('a'); + let previous_event_id = event_id('b'); + let buyer_pubkey = pubkey('c'); + let seller_pubkey = pubkey('d'); + let listing_addr = listing_addr(&seller_pubkey); + let refs = refs( + &order_id, + &listing_addr, + &buyer_pubkey, + &seller_pubkey, + &root_event_id, + &previous_event_id, + ); + let clean = projection( + &order_id, + &listing_addr, + &buyer_pubkey, + &seller_pubkey, + &root_event_id, + &previous_event_id, + ); + assert!(require_clean_lifecycle_projection(refs, &clean).is_ok()); + assert!(require_lifecycle_status(&refs, &clean, RadrootsOrderStatus::Requested).is_ok()); + assert!(require_no_lifecycle_terminal(&refs, &clean).is_ok()); + assert!(require_no_pending_revision(&refs, &clean).is_ok()); + assert!(require_lifecycle_previous_is_current(&refs, &clean).is_ok()); + + let mut missing_request = clean.clone(); + missing_request.request_event_id = None; + assert!( + invalid_request_message( + require_clean_lifecycle_projection(refs, &missing_request).unwrap_err() + ) + .contains("requires local order request evidence") + ); + + let mut wrong_request = clean.clone(); + wrong_request.request_event_id = Some(event_id('e')); + assert!( + invalid_request_message( + require_clean_lifecycle_projection(refs, &wrong_request).unwrap_err() + ) + .contains("root event") + ); + + let mut issued = clean.clone(); + issued.issues = vec![RadrootsOrderIssue::ForkedLifecycle { + event_ids: vec![event_id('f')], + }]; + assert!( + invalid_request_message(require_clean_lifecycle_projection(refs, &issued).unwrap_err()) + .contains("reducer issue") + ); + + let mut missing_listing = clean.clone(); + missing_listing.listing_addr = None; + assert!( + invalid_request_message( + require_clean_lifecycle_projection(refs, &missing_listing).unwrap_err() + ) + .contains("missing listing_addr") + ); + + let mut wrong_buyer = clean.clone(); + wrong_buyer.buyer_pubkey = Some(pubkey('e')); + assert!( + invalid_request_message( + require_clean_lifecycle_projection(refs, &wrong_buyer).unwrap_err() + ) + .contains("buyer_pubkey") + ); + + let mut accepted = clean.clone(); + accepted.status = RadrootsOrderStatus::Accepted; + assert!( + invalid_request_message( + require_lifecycle_status(&refs, &accepted, RadrootsOrderStatus::Requested).unwrap_err() + ) + .contains("requires Requested") + ); + + let mut terminal = clean.clone(); + terminal.lifecycle_terminal = true; + assert!( + invalid_request_message(require_no_lifecycle_terminal(&refs, &terminal).unwrap_err()) + .contains("non-terminal") + ); + + assert!( + invalid_request_message(require_pending_revision(&refs, &clean).unwrap_err()) + .contains("requires pending revision proposal") + ); + + let mut wrong_pending = clean.clone(); + wrong_pending.pending_revision_event_id = Some(event_id('e')); + assert!( + invalid_request_message(require_pending_revision(&refs, &wrong_pending).unwrap_err()) + .contains("does not match pending revision") + ); + + let mut pending = clean.clone(); + pending.pending_revision_event_id = Some(previous_event_id.clone()); + assert!(require_pending_revision(&refs, &pending).is_ok()); + assert!( + invalid_request_message(require_no_pending_revision(&refs, &pending).unwrap_err()) + .contains("cannot follow pending revision") + ); + + let mut wrong_previous = clean.clone(); + wrong_previous.last_event_id = Some(event_id('e')); + assert!( + invalid_request_message( + require_lifecycle_previous_is_current(&refs, &wrong_previous).unwrap_err() + ) + .contains("does not match current lifecycle event") + ); + + let mut missing_previous = clean; + missing_previous.last_event_id = None; + assert!( + invalid_request_message( + require_lifecycle_previous_is_current(&refs, &missing_previous).unwrap_err() + ) + .contains("requires current lifecycle event evidence") + ); +} + +#[test] +fn lifecycle_plan_state_wrappers_reject_invalid_status_terminal_and_pending_state() { + let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000); + let buyer_actor = buyer_actor(); + let seller_actor = seller_actor(); + let listing_event_id = event_id('a'); + let submit_plan = order_submit_plan( + &buyer_actor, + ptr(listing_event_id.as_str().to_owned()), + order_request_payload(), + created_at, + ) + .expect("submit plan"); + let decision_plan = order_decision_plan( + &seller_actor, + ptr(submit_plan.expected_event_id.as_str().to_owned()), + order_decision_payload(), + created_at, + ) + .expect("decision plan"); + let proposal_payload = revision_proposal_payload( + &submit_plan.expected_event_id, + &decision_plan.expected_event_id, + ); + let proposal_plan = order_revision_proposal_plan( + &seller_actor, + ptr(submit_plan.expected_event_id.as_str().to_owned()), + ptr(decision_plan.expected_event_id.as_str().to_owned()), + proposal_payload.clone(), + created_at, + ) + .expect("proposal plan"); + let revision_plan = order_revision_decision_plan( + &buyer_actor, + ptr(submit_plan.expected_event_id.as_str().to_owned()), + ptr(proposal_plan.expected_event_id.as_str().to_owned()), + revision_decision_payload( + &proposal_payload, + &proposal_plan.expected_event_id, + RadrootsOrderRevisionOutcome::Accepted, + ), + created_at, + ) + .expect("revision decision plan"); + let cancellation_plan = order_cancellation_plan( + &buyer_actor, + ptr(submit_plan.expected_event_id.as_str().to_owned()), + ptr(decision_plan.expected_event_id.as_str().to_owned()), + cancellation_payload(), + created_at, + ) + .expect("cancellation plan"); + let base = projection( + &proposal_plan.order_id, + &proposal_plan.listing_addr, + &proposal_plan.buyer_pubkey, + &proposal_plan.seller_pubkey, + &submit_plan.expected_event_id, + &decision_plan.expected_event_id, + ); + + assert!(require_revision_proposal_state(&proposal_plan, &base).is_ok()); + + let mut accepted = base.clone(); + accepted.status = RadrootsOrderStatus::Accepted; + assert!( + invalid_request_message( + require_revision_proposal_state(&proposal_plan, &accepted).unwrap_err() + ) + .contains("requires Requested") + ); + + let mut terminal = base.clone(); + terminal.lifecycle_terminal = true; + assert!( + invalid_request_message( + require_revision_proposal_state(&proposal_plan, &terminal).unwrap_err() + ) + .contains("non-terminal") + ); + + let mut revision_ready = base.clone(); + revision_ready.last_event_id = Some(proposal_plan.expected_event_id.clone()); + revision_ready.pending_revision_event_id = Some(proposal_plan.expected_event_id.clone()); + assert!(require_revision_decision_state(&revision_plan, &revision_ready).is_ok()); + + let mut revision_terminal = revision_ready.clone(); + revision_terminal.lifecycle_terminal = true; + assert!( + invalid_request_message( + require_revision_decision_state(&revision_plan, &revision_terminal).unwrap_err() + ) + .contains("non-terminal") + ); + + let mut revision_without_pending = revision_ready; + revision_without_pending.pending_revision_event_id = None; + assert!( + invalid_request_message( + require_revision_decision_state(&revision_plan, &revision_without_pending).unwrap_err() + ) + .contains("requires pending revision proposal") + ); + + assert!(require_cancellation_state(&cancellation_plan, &base).is_ok()); + + let mut cancellation_accepted = base.clone(); + cancellation_accepted.status = RadrootsOrderStatus::Accepted; + assert!( + invalid_request_message( + require_cancellation_state(&cancellation_plan, &cancellation_accepted).unwrap_err() + ) + .contains("cancellation requires requested") + ); + + let mut cancellation_terminal = base.clone(); + cancellation_terminal.lifecycle_terminal = true; + assert!( + invalid_request_message( + require_cancellation_state(&cancellation_plan, &cancellation_terminal).unwrap_err() + ) + .contains("non-terminal") + ); + + let mut cancellation_pending = base; + cancellation_pending.pending_revision_event_id = Some(proposal_plan.expected_event_id); + assert!( + invalid_request_message( + require_cancellation_state(&cancellation_plan, &cancellation_pending).unwrap_err() + ) + .contains("cannot follow pending revision") + ); +} + +#[test] +fn evidence_reference_helpers_reject_invalid_ids_and_payload_mismatches() { + let root_event_id = event_id('a'); + let previous_event_id = event_id('b'); + let alternate_event_id = event_id('c'); + + assert_eq!( + request_event_id(&ptr(root_event_id.as_str().to_owned())).expect("request event"), + root_event_id + ); + assert_eq!( + order_reference_event_id(&ptr(previous_event_id.as_str().to_owned()), "decision") + .expect("reference event"), + previous_event_id + ); + assert!( + invalid_request_message(request_event_id(&ptr("not-hex".to_owned())).unwrap_err()) + .contains("order request evidence event id is invalid") + ); + assert!( + invalid_request_message( + order_reference_event_id(&ptr("not-hex".to_owned()), "decision").unwrap_err() + ) + .contains("order decision evidence event id is invalid") + ); + + assert!( + require_payload_event_refs( + "order decision", + &root_event_id, + &previous_event_id, + &root_event_id, + &previous_event_id, + ) + .is_ok() + ); + assert!( + invalid_request_message( + require_payload_event_refs( + "order decision", + &alternate_event_id, + &previous_event_id, + &root_event_id, + &previous_event_id, + ) + .unwrap_err() + ) + .contains("root_event_id") + ); + assert!( + invalid_request_message( + require_payload_event_refs( + "order decision", + &root_event_id, + &alternate_event_id, + &root_event_id, + &previous_event_id, + ) + .unwrap_err() + ) + .contains("prev_event_id") + ); +} + +#[test] +fn parse_order_evidence_reports_invalid_ids_unsupported_kinds_and_decode_errors() { + assert!( + invalid_request_message(parsed_order_evidence_error(parse_order_evidence( + &nostr_event("not-hex".to_owned(), KIND_ORDER_REQUEST,) + ))) + .contains("order evidence event id is invalid") + ); + assert!( + invalid_request_message(parsed_order_evidence_error(parse_order_evidence( + &nostr_event(hex_64('a'), 1), + ))) + .contains("order evidence event kind 1 is not supported") + ); + assert!( + invalid_request_message(parsed_order_evidence_error(parse_order_evidence( + &nostr_event(hex_64('a'), KIND_ORDER_REQUEST), + ))) + .contains("order evidence event is invalid") + ); + for kind in [ + KIND_ORDER_DECISION, + KIND_ORDER_REVISION_PROPOSAL, + KIND_ORDER_REVISION_DECISION, + KIND_ORDER_CANCELLATION, + ] { + assert!( + invalid_request_message(parsed_order_evidence_error(parse_order_evidence( + &nostr_event(hex_64('a'), kind), + ))) + .contains("order evidence event is invalid") + ); + } +} + +#[test] +fn projection_error_maps_store_tag_and_decode_errors() { + assert_eq!( + projection_message(projection_error(RadrootsOrderStoreQueryError::Store( + RadrootsEventStoreError::MissingEvent("event-a".to_owned()) + ))), + "order status store query failed" + ); + assert_eq!( + projection_message(projection_error( + RadrootsOrderStoreQueryError::InvalidStoredTagsJson { + event_id: "event-a".to_owned(), + source: serde_json::from_str::<serde_json::Value>("{").unwrap_err(), + } + )), + "stored order event tags could not be decoded" + ); + assert_eq!( + projection_message(projection_error(RadrootsOrderStoreQueryError::Decode { + event_id: "event-a".to_owned(), + source: RadrootsOrderEventDecodeError::UnsupportedKind { kind: 999 }, + })), + "stored order event could not decode as order record" + ); +} + +#[test] +fn order_enqueue_request_mutators_reject_invalid_relays_and_idempotency_keys() { + let buyer = buyer_actor(); + let seller = seller_actor(); + let listing_event = ptr(event_id('a').as_str().to_owned()); + let request_event = ptr(event_id('b').as_str().to_owned()); + let previous_event = ptr(event_id('c').as_str().to_owned()); + let submit_payload = order_request_payload(); + let decision_payload = order_decision_payload(); + let proposal_payload = revision_proposal_payload(&event_id('b'), &event_id('c')); + let revision_decision_payload = revision_decision_payload( + &proposal_payload, + &event_id('d'), + RadrootsOrderRevisionOutcome::Accepted, + ); + let cancellation_payload = cancellation_payload(); + let policy = SdkRelayTargetPolicy::UseConfiguredRelays; + + assert_error_display( + OrderSubmitEnqueueRequest::new( + buyer.clone(), + listing_event.clone(), + submit_payload.clone(), + policy.clone(), + ) + .try_with_target_relays(Vec::<String>::new(), SdkRelayUrlPolicy::Public), + "target relays", + ); + assert_error_display( + OrderSubmitEnqueueRequest::new( + buyer.clone(), + listing_event, + submit_payload, + policy.clone(), + ) + .try_with_idempotency_key(""), + "idempotency key", + ); + + assert_error_display( + OrderDecisionEnqueueRequest::new( + seller.clone(), + request_event.clone(), + decision_payload.clone(), + policy.clone(), + ) + .try_with_target_relays(Vec::<String>::new(), SdkRelayUrlPolicy::Public), + "target relays", + ); + assert_error_display( + OrderDecisionEnqueueRequest::new( + seller.clone(), + request_event.clone(), + decision_payload, + policy.clone(), + ) + .try_with_idempotency_key(" leading"), + "idempotency key", + ); + + assert_error_display( + OrderRevisionProposalEnqueueRequest::new( + seller.clone(), + request_event.clone(), + previous_event.clone(), + proposal_payload.clone(), + policy.clone(), + ) + .try_with_target_relays(Vec::<String>::new(), SdkRelayUrlPolicy::Public), + "target relays", + ); + assert_error_display( + OrderRevisionProposalEnqueueRequest::new( + seller.clone(), + request_event.clone(), + previous_event.clone(), + proposal_payload.clone(), + policy.clone(), + ) + .try_with_idempotency_key("trailing "), + "idempotency key", + ); + + assert_error_display( + OrderRevisionDecisionEnqueueRequest::new( + buyer.clone(), + request_event.clone(), + previous_event.clone(), + revision_decision_payload.clone(), + policy.clone(), + ) + .try_with_target_relays(Vec::<String>::new(), SdkRelayUrlPolicy::Public), + "target relays", + ); + assert_error_display( + OrderRevisionDecisionEnqueueRequest::new( + buyer.clone(), + request_event.clone(), + previous_event.clone(), + revision_decision_payload, + policy.clone(), + ) + .try_with_idempotency_key("invalid\nkey"), + "idempotency key", + ); + + assert_error_display( + OrderCancellationEnqueueRequest::new( + buyer.clone(), + request_event.clone(), + previous_event.clone(), + cancellation_payload.clone(), + policy.clone(), + ) + .try_with_target_relays(Vec::<String>::new(), SdkRelayUrlPolicy::Public), + "target relays", + ); + assert_error_display( + OrderCancellationEnqueueRequest::new( + buyer, + request_event, + previous_event, + cancellation_payload, + policy, + ) + .try_with_idempotency_key(""), + "idempotency key", + ); +} + +#[tokio::test] +async fn orders_client_prepare_methods_resolve_request_created_at() { + let sdk = crate::RadrootsSdk::builder() + .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_010)) + .build() + .await + .expect("sdk"); + let root_event_id = event_id('a'); + let previous_event_id = event_id('b'); + let root_event = ptr(root_event_id.as_str().to_owned()); + let previous_event = ptr(previous_event_id.as_str().to_owned()); + + assert_eq!( + sdk.orders() + .prepare_submit(OrderSubmitPrepareRequest::new( + buyer_actor(), + root_event.clone(), + order_request_payload(), + )) + .expect("submit plan") + .created_at, + RadrootsSdkTimestamp::from_unix_seconds(1_700_000_010) + ); + assert_eq!( + sdk.orders() + .prepare_decision( + OrderDecisionPrepareRequest::new( + seller_actor(), + root_event.clone(), + order_decision_payload(), + ) + .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_011)), + ) + .expect("decision plan") + .created_at, + RadrootsSdkTimestamp::from_unix_seconds(1_700_000_011) + ); + + let proposal = revision_proposal_payload(&root_event_id, &previous_event_id); + assert_eq!( + sdk.orders() + .prepare_revision_proposal( + OrderRevisionProposalPrepareRequest::new( + seller_actor(), + root_event.clone(), + previous_event.clone(), + proposal.clone(), + ) + .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_012)), + ) + .expect("proposal plan") + .created_at, + RadrootsSdkTimestamp::from_unix_seconds(1_700_000_012) + ); + assert_eq!( + sdk.orders() + .prepare_revision_decision( + OrderRevisionDecisionPrepareRequest::new( + buyer_actor(), + root_event.clone(), + ptr(event_id('c').as_str().to_owned()), + revision_decision_payload( + &proposal, + &event_id('c'), + RadrootsOrderRevisionOutcome::Accepted, + ), + ) + .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_013)), + ) + .expect("revision decision plan") + .created_at, + RadrootsSdkTimestamp::from_unix_seconds(1_700_000_013) + ); + assert_eq!( + sdk.orders() + .prepare_cancellation( + OrderCancellationPrepareRequest::new( + buyer_actor(), + root_event, + previous_event, + cancellation_payload(), + ) + .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_014)), + ) + .expect("cancellation plan") + .created_at, + RadrootsSdkTimestamp::from_unix_seconds(1_700_000_014) + ); +} + +#[tokio::test] +async fn prepared_submit_and_decision_enqueue_cover_source_attached_success_path() { + let sdk = prepared_order_sdk().await; + let submit = enqueue_fixture_submit(&sdk, "order-prepared-decision"); + let submit = submit.await; + assert_eq!(submit.signed_event_id, submit.expected_event_id); + assert_eq!(submit.workflow.kind, OrderWorkflowKind::Submit); + + let seller = fixture_seller_actor(); + let decision_plan = sdk + .orders() + .prepare_decision(OrderDecisionPrepareRequest::new( + seller.clone(), + fixture_order_event_ptr(&submit.signed_event_id), + fixture_order_decision("order-prepared-decision"), + )) + .expect("decision plan"); + let decision = sdk + .orders() + .enqueue_prepared_decision( + &seller, + decision_plan, + fixture_target_relays(), + Some(SdkIdempotencyKey::new("prepared-decision").expect("idempotency")), + &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue decision"); + + assert_eq!(decision.signed_event_id, decision.expected_event_id); + assert_eq!(decision.workflow.kind, OrderWorkflowKind::Decision); + assert_eq!(decision.request_event_id, submit.signed_event_id); +} + +#[tokio::test] +async fn prepared_revision_lifecycle_enqueue_cover_source_attached_success_paths() { + let sdk = prepared_order_sdk().await; + let submit = enqueue_fixture_submit(&sdk, "order-prepared-revision").await; + let seller = fixture_seller_actor(); + let proposal_payload = fixture_revision_proposal( + "order-prepared-revision", + &submit.signed_event_id, + &submit.signed_event_id, + ); + let proposal_plan = sdk + .orders() + .prepare_revision_proposal(OrderRevisionProposalPrepareRequest::new( + seller.clone(), + fixture_order_event_ptr(&submit.signed_event_id), + fixture_order_event_ptr(&submit.signed_event_id), + proposal_payload.clone(), + )) + .expect("proposal plan"); + let proposal = sdk + .orders() + .enqueue_prepared_revision_proposal( + &seller, + proposal_plan, + fixture_target_relays(), + Some(SdkIdempotencyKey::new("prepared-proposal").expect("idempotency")), + &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue proposal"); + + assert_eq!(proposal.signed_event_id, proposal.expected_event_id); + assert_eq!(proposal.workflow.kind, OrderWorkflowKind::RevisionProposal); + assert_eq!(proposal.root_event_id, submit.signed_event_id); + assert_eq!(proposal.previous_event_id, submit.signed_event_id); + + let buyer = fixture_buyer_actor(); + let revision_decision = fixture_revision_decision(&proposal_payload, &proposal.signed_event_id); + let revision_decision_plan = sdk + .orders() + .prepare_revision_decision(OrderRevisionDecisionPrepareRequest::new( + buyer.clone(), + fixture_order_event_ptr(&submit.signed_event_id), + fixture_order_event_ptr(&proposal.signed_event_id), + revision_decision, + )) + .expect("revision decision plan"); + let revision = sdk + .orders() + .enqueue_prepared_revision_decision( + &buyer, + revision_decision_plan, + fixture_target_relays(), + None, + &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue revision decision"); + + assert_eq!(revision.signed_event_id, revision.expected_event_id); + assert_eq!(revision.workflow.kind, OrderWorkflowKind::RevisionDecision); + assert_eq!(revision.root_event_id, submit.signed_event_id); + assert_eq!(revision.previous_event_id, proposal.signed_event_id); +} + +#[tokio::test] +async fn prepared_cancellation_enqueue_covers_source_attached_success_path() { + let sdk = prepared_order_sdk().await; + let submit = enqueue_fixture_submit(&sdk, "order-prepared-cancellation").await; + let buyer = fixture_buyer_actor(); + let cancellation_plan = sdk + .orders() + .prepare_cancellation(OrderCancellationPrepareRequest::new( + buyer.clone(), + fixture_order_event_ptr(&submit.signed_event_id), + fixture_order_event_ptr(&submit.signed_event_id), + fixture_cancellation("order-prepared-cancellation"), + )) + .expect("cancellation plan"); + let cancellation = sdk + .orders() + .enqueue_prepared_cancellation( + &buyer, + cancellation_plan, + fixture_target_relays(), + Some(SdkIdempotencyKey::new("prepared-cancellation").expect("idempotency")), + &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue cancellation"); + + assert_eq!(cancellation.signed_event_id, cancellation.expected_event_id); + assert_eq!(cancellation.workflow.kind, OrderWorkflowKind::Cancellation); + assert_eq!(cancellation.root_event_id, submit.signed_event_id); + assert_eq!(cancellation.previous_event_id, submit.signed_event_id); +} + +#[tokio::test] +async fn convenience_order_enqueue_methods_cover_source_attached_wrappers() { + let sdk = prepared_order_sdk().await; + let decision_submit = sdk + .orders() + .enqueue_submit( + OrderSubmitEnqueueRequest::new( + fixture_buyer_actor(), + fixture_event_ptr('b'), + fixture_order_request("order-wrapper-decision"), + fixture_target_relays(), + ), + &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue submit"); + let decision = sdk + .orders() + .enqueue_decision( + OrderDecisionEnqueueRequest::new( + fixture_seller_actor(), + fixture_order_event_ptr(&decision_submit.signed_event_id), + fixture_order_decision("order-wrapper-decision"), + fixture_target_relays(), + ), + &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue decision"); + assert_eq!(decision.request_event_id, decision_submit.signed_event_id); + + let revision_submit = sdk + .orders() + .enqueue_submit( + OrderSubmitEnqueueRequest::new( + fixture_buyer_actor(), + fixture_event_ptr('c'), + fixture_order_request("order-wrapper-revision"), + fixture_target_relays(), + ), + &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue revision submit"); + let proposal_payload = fixture_revision_proposal( + "order-wrapper-revision", + &revision_submit.signed_event_id, + &revision_submit.signed_event_id, + ); + let proposal = sdk + .orders() + .enqueue_revision_proposal( + OrderRevisionProposalEnqueueRequest::new( + fixture_seller_actor(), + fixture_order_event_ptr(&revision_submit.signed_event_id), + fixture_order_event_ptr(&revision_submit.signed_event_id), + proposal_payload.clone(), + fixture_target_relays(), + ), + &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue proposal"); + let revision = sdk + .orders() + .enqueue_revision_decision( + OrderRevisionDecisionEnqueueRequest::new( + fixture_buyer_actor(), + fixture_order_event_ptr(&revision_submit.signed_event_id), + fixture_order_event_ptr(&proposal.signed_event_id), + fixture_revision_decision(&proposal_payload, &proposal.signed_event_id), + fixture_target_relays(), + ), + &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue revision decision"); + assert_eq!(revision.previous_event_id, proposal.signed_event_id); + + let cancellation_submit = sdk + .orders() + .enqueue_submit( + OrderSubmitEnqueueRequest::new( + fixture_buyer_actor(), + fixture_event_ptr('d'), + fixture_order_request("order-wrapper-cancellation"), + fixture_target_relays(), + ), + &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue cancellation submit"); + let cancellation = sdk + .orders() + .enqueue_cancellation( + OrderCancellationEnqueueRequest::new( + fixture_buyer_actor(), + fixture_order_event_ptr(&cancellation_submit.signed_event_id), + fixture_order_event_ptr(&cancellation_submit.signed_event_id), + fixture_cancellation("order-wrapper-cancellation"), + fixture_target_relays(), + ), + &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue cancellation"); + assert_eq!( + cancellation.previous_event_id, + cancellation_submit.signed_event_id + ); +} + +#[tokio::test] +async fn prepared_lifecycle_enqueues_report_missing_and_closed_preflight_errors() { + let sdk = prepared_order_sdk().await; + let buyer = fixture_buyer_actor(); + let seller = fixture_seller_actor(); + let root_event_id = event_id('a'); + let previous_event_id = event_id('b'); + let root = fixture_order_event_ptr(&root_event_id); + let previous = fixture_order_event_ptr(&previous_event_id); + let proposal = + fixture_revision_proposal("order-preflight-errors", &root_event_id, &previous_event_id); + + let decision_plan = sdk + .orders() + .prepare_decision(OrderDecisionPrepareRequest::new( + seller.clone(), + root.clone(), + fixture_order_decision("order-preflight-errors"), + )) + .expect("decision plan"); + let decision_missing = sdk + .orders() + .enqueue_prepared_decision( + &seller, + decision_plan, + fixture_target_relays(), + None, + &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect_err("missing decision evidence"); + assert!(matches!( + decision_missing, + RadrootsSdkError::InvalidRequest { .. } + )); + + let proposal_plan = sdk + .orders() + .prepare_revision_proposal(OrderRevisionProposalPrepareRequest::new( + seller.clone(), + root.clone(), + previous.clone(), + proposal.clone(), + )) + .expect("proposal plan"); + let proposal_missing = sdk + .orders() + .enqueue_prepared_revision_proposal( + &seller, + proposal_plan, + fixture_target_relays(), + None, + &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect_err("missing proposal evidence"); + assert!(matches!( + proposal_missing, + RadrootsSdkError::InvalidRequest { .. } + )); + + let revision_decision_plan = sdk + .orders() + .prepare_revision_decision(OrderRevisionDecisionPrepareRequest::new( + buyer.clone(), + root.clone(), + previous.clone(), + fixture_revision_decision(&proposal, &previous_event_id), + )) + .expect("revision decision plan"); + let revision_missing = sdk + .orders() + .enqueue_prepared_revision_decision( + &buyer, + revision_decision_plan, + fixture_target_relays(), + None, + &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await + .expect_err("missing revision evidence"); + assert!(matches!( + revision_missing, + RadrootsSdkError::InvalidRequest { .. } + )); + + let cancellation_plan = sdk + .orders() + .prepare_cancellation(OrderCancellationPrepareRequest::new( + buyer.clone(), + root, + previous, + fixture_cancellation("order-preflight-errors"), + )) + .expect("cancellation plan"); + let cancellation_missing = sdk + .orders() + .enqueue_prepared_cancellation( + &buyer, + cancellation_plan, + fixture_target_relays(), + None, + &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await + .expect_err("missing cancellation evidence"); + assert!(matches!( + cancellation_missing, + RadrootsSdkError::InvalidRequest { .. } + )); + + let closed_sdk = prepared_order_sdk().await; + let closed_root_event_id = event_id('c'); + let closed_previous_event_id = event_id('d'); + let closed_root = fixture_order_event_ptr(&closed_root_event_id); + let closed_previous = fixture_order_event_ptr(&closed_previous_event_id); + let closed_proposal = fixture_revision_proposal( + "order-closed-preflight", + &closed_root_event_id, + &closed_previous_event_id, + ); + let closed_plan = closed_sdk + .orders() + .prepare_decision(OrderDecisionPrepareRequest::new( + seller.clone(), + closed_root.clone(), + fixture_order_decision("order-closed-preflight"), + )) + .expect("closed decision plan"); + let closed_proposal_plan = closed_sdk + .orders() + .prepare_revision_proposal(OrderRevisionProposalPrepareRequest::new( + seller.clone(), + closed_root.clone(), + closed_previous.clone(), + closed_proposal.clone(), + )) + .expect("closed proposal plan"); + let closed_revision_plan = closed_sdk + .orders() + .prepare_revision_decision(OrderRevisionDecisionPrepareRequest::new( + buyer.clone(), + closed_root.clone(), + closed_previous.clone(), + fixture_revision_decision(&closed_proposal, &closed_previous_event_id), + )) + .expect("closed revision decision plan"); + let closed_cancellation_plan = closed_sdk + .orders() + .prepare_cancellation(OrderCancellationPrepareRequest::new( + buyer.clone(), + closed_root, + closed_previous, + fixture_cancellation("order-closed-preflight"), + )) + .expect("closed cancellation plan"); + closed_sdk._event_store.pool().close().await; + let closed_error = closed_sdk + .orders() + .enqueue_prepared_decision( + &seller, + closed_plan, + fixture_target_relays(), + None, + &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect_err("closed preflight"); + assert!(matches!(closed_error, RadrootsSdkError::EventStore { .. })); + let closed_proposal_error = closed_sdk + .orders() + .enqueue_prepared_revision_proposal( + &seller, + closed_proposal_plan, + fixture_target_relays(), + None, + &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect_err("closed proposal preflight"); + assert!(matches!( + closed_proposal_error, + RadrootsSdkError::EventStore { .. } + )); + let closed_revision_error = closed_sdk + .orders() + .enqueue_prepared_revision_decision( + &buyer, + closed_revision_plan, + fixture_target_relays(), + None, + &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await + .expect_err("closed revision preflight"); + assert!(matches!( + closed_revision_error, + RadrootsSdkError::EventStore { .. } + )); + let closed_cancellation_error = closed_sdk + .orders() + .enqueue_prepared_cancellation( + &buyer, + closed_cancellation_plan, + fixture_target_relays(), + None, + &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await + .expect_err("closed cancellation preflight"); + assert!(matches!( + closed_cancellation_error, + RadrootsSdkError::EventStore { .. } + )); +} + +#[tokio::test] +async fn lifecycle_preflight_helpers_map_projection_query_failures() { + let sdk = prepared_order_sdk().await; + let client = sdk.orders(); + let buyer = fixture_buyer_actor(); + let seller = fixture_seller_actor(); + let root_event_id = event_id('e'); + let previous_event_id = event_id('f'); + let root = fixture_order_event_ptr(&root_event_id); + let previous = fixture_order_event_ptr(&previous_event_id); + let proposal = fixture_revision_proposal( + "order-preflight-query-failure", + &root_event_id, + &previous_event_id, + ); + + let decision_plan = client + .prepare_decision(OrderDecisionPrepareRequest::new( + seller.clone(), + root.clone(), + fixture_order_decision("order-preflight-query-failure"), + )) + .expect("decision plan"); + let proposal_plan = client + .prepare_revision_proposal(OrderRevisionProposalPrepareRequest::new( + seller, + root.clone(), + previous.clone(), + proposal.clone(), + )) + .expect("proposal plan"); + let revision_plan = client + .prepare_revision_decision(OrderRevisionDecisionPrepareRequest::new( + buyer.clone(), + root.clone(), + previous.clone(), + fixture_revision_decision(&proposal, &previous_event_id), + )) + .expect("revision plan"); + let cancellation_plan = client + .prepare_cancellation(OrderCancellationPrepareRequest::new( + buyer, + root, + previous, + fixture_cancellation("order-preflight-query-failure"), + )) + .expect("cancellation plan"); + + sdk._event_store.pool().close().await; + assert!(matches!( + client.require_decision_preflight(&decision_plan).await, + Err(RadrootsSdkError::Projection { .. }) + )); + assert!(matches!( + client + .require_revision_proposal_preflight(&proposal_plan) + .await, + Err(RadrootsSdkError::Projection { .. }) + )); + assert!(matches!( + client + .require_revision_decision_preflight(&revision_plan) + .await, + Err(RadrootsSdkError::Projection { .. }) + )); + assert!(matches!( + client + .require_cancellation_preflight(&cancellation_plan) + .await, + Err(RadrootsSdkError::Projection { .. }) + )); +} + +#[tokio::test] +async fn prepared_lifecycle_enqueues_report_closed_outbox_after_preflight() { + let proposal_sdk = prepared_order_sdk().await; + let proposal_submit = + enqueue_fixture_submit(&proposal_sdk, "order-closed-outbox-proposal").await; + let seller = fixture_seller_actor(); + let proposal_payload = fixture_revision_proposal( + "order-closed-outbox-proposal", + &proposal_submit.signed_event_id, + &proposal_submit.signed_event_id, + ); + let proposal_plan = proposal_sdk + .orders() + .prepare_revision_proposal(OrderRevisionProposalPrepareRequest::new( + seller.clone(), + fixture_order_event_ptr(&proposal_submit.signed_event_id), + fixture_order_event_ptr(&proposal_submit.signed_event_id), + proposal_payload, + )) + .expect("proposal plan"); + proposal_sdk._outbox.pool().close().await; + let proposal_error = proposal_sdk + .orders() + .enqueue_prepared_revision_proposal( + &seller, + proposal_plan, + fixture_target_relays(), + None, + &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect_err("closed outbox proposal"); + assert_partial_outbox_enqueue(proposal_error, ORDER_REVISION_PROPOSAL_OPERATION_KIND); + + let revision_sdk = prepared_order_sdk().await; + let revision_submit = + enqueue_fixture_submit(&revision_sdk, "order-closed-outbox-revision").await; + let proposal_payload = fixture_revision_proposal( + "order-closed-outbox-revision", + &revision_submit.signed_event_id, + &revision_submit.signed_event_id, + ); + let proposal_plan = revision_sdk + .orders() + .prepare_revision_proposal(OrderRevisionProposalPrepareRequest::new( + seller.clone(), + fixture_order_event_ptr(&revision_submit.signed_event_id), + fixture_order_event_ptr(&revision_submit.signed_event_id), + proposal_payload.clone(), + )) + .expect("revision proposal plan"); + let proposal = revision_sdk + .orders() + .enqueue_prepared_revision_proposal( + &seller, + proposal_plan, + fixture_target_relays(), + None, + &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue proposal"); + let buyer = fixture_buyer_actor(); + let revision_plan = revision_sdk + .orders() + .prepare_revision_decision(OrderRevisionDecisionPrepareRequest::new( + buyer.clone(), + fixture_order_event_ptr(&revision_submit.signed_event_id), + fixture_order_event_ptr(&proposal.signed_event_id), + fixture_revision_decision(&proposal_payload, &proposal.signed_event_id), + )) + .expect("revision plan"); + revision_sdk._outbox.pool().close().await; + let revision_error = revision_sdk + .orders() + .enqueue_prepared_revision_decision( + &buyer, + revision_plan, + fixture_target_relays(), + None, + &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await + .expect_err("closed outbox revision"); + assert_partial_outbox_enqueue(revision_error, ORDER_REVISION_DECISION_OPERATION_KIND); + + let cancellation_sdk = prepared_order_sdk().await; + let cancellation_submit = + enqueue_fixture_submit(&cancellation_sdk, "order-closed-outbox-cancellation").await; + let cancellation_plan = cancellation_sdk + .orders() + .prepare_cancellation(OrderCancellationPrepareRequest::new( + buyer.clone(), + fixture_order_event_ptr(&cancellation_submit.signed_event_id), + fixture_order_event_ptr(&cancellation_submit.signed_event_id), + fixture_cancellation("order-closed-outbox-cancellation"), + )) + .expect("cancellation plan"); + cancellation_sdk._outbox.pool().close().await; + let cancellation_error = cancellation_sdk + .orders() + .enqueue_prepared_cancellation( + &buyer, + cancellation_plan, + fixture_target_relays(), + None, + &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await + .expect_err("closed outbox cancellation"); + assert_partial_outbox_enqueue(cancellation_error, ORDER_CANCELLATION_OPERATION_KIND); +} + +#[tokio::test] +async fn prepared_lifecycle_enqueues_skip_preflight_for_existing_events() { + let decision_sdk = prepared_order_sdk().await; + let decision_submit = enqueue_fixture_submit(&decision_sdk, "order-existing-decision").await; + let seller = fixture_seller_actor(); + let decision_plan = decision_sdk + .orders() + .prepare_decision(OrderDecisionPrepareRequest::new( + seller.clone(), + fixture_order_event_ptr(&decision_submit.signed_event_id), + fixture_order_decision("order-existing-decision"), + )) + .expect("decision plan"); + decision_sdk + .orders() + .enqueue_prepared_decision( + &seller, + decision_plan.clone(), + fixture_target_relays(), + None, + &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue decision"); + let decision_repeat = decision_sdk + .orders() + .enqueue_prepared_decision( + &seller, + decision_plan, + fixture_target_relays(), + None, + &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect("repeat decision replay"); + assert_eq!( + decision_repeat.workflow.state, + SdkMutationState::AlreadyQueued + ); + assert!( + decision_repeat + .workflow + .idempotency + .replayed_existing_operation + ); + + let proposal_sdk = prepared_order_sdk().await; + let proposal_submit = enqueue_fixture_submit(&proposal_sdk, "order-existing-proposal").await; + let proposal_payload = fixture_revision_proposal( + "order-existing-proposal", + &proposal_submit.signed_event_id, + &proposal_submit.signed_event_id, + ); + let proposal_plan = proposal_sdk + .orders() + .prepare_revision_proposal(OrderRevisionProposalPrepareRequest::new( + seller.clone(), + fixture_order_event_ptr(&proposal_submit.signed_event_id), + fixture_order_event_ptr(&proposal_submit.signed_event_id), + proposal_payload, + )) + .expect("proposal plan"); + proposal_sdk + .orders() + .enqueue_prepared_revision_proposal( + &seller, + proposal_plan.clone(), + fixture_target_relays(), + None, + &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue proposal"); + let proposal_repeat = proposal_sdk + .orders() + .enqueue_prepared_revision_proposal( + &seller, + proposal_plan, + fixture_target_relays(), + None, + &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect("repeat proposal replay"); + assert_eq!( + proposal_repeat.workflow.state, + SdkMutationState::AlreadyQueued + ); + assert!( + proposal_repeat + .workflow + .idempotency + .replayed_existing_operation + ); + + let revision_sdk = prepared_order_sdk().await; + let revision_submit = enqueue_fixture_submit(&revision_sdk, "order-existing-revision").await; + let proposal_payload = fixture_revision_proposal( + "order-existing-revision", + &revision_submit.signed_event_id, + &revision_submit.signed_event_id, + ); + let proposal_plan = revision_sdk + .orders() + .prepare_revision_proposal(OrderRevisionProposalPrepareRequest::new( + seller.clone(), + fixture_order_event_ptr(&revision_submit.signed_event_id), + fixture_order_event_ptr(&revision_submit.signed_event_id), + proposal_payload.clone(), + )) + .expect("revision proposal plan"); + let proposal = revision_sdk + .orders() + .enqueue_prepared_revision_proposal( + &seller, + proposal_plan, + fixture_target_relays(), + None, + &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue proposal"); + let buyer = fixture_buyer_actor(); + let revision_plan = revision_sdk + .orders() + .prepare_revision_decision(OrderRevisionDecisionPrepareRequest::new( + buyer.clone(), + fixture_order_event_ptr(&revision_submit.signed_event_id), + fixture_order_event_ptr(&proposal.signed_event_id), + fixture_revision_decision(&proposal_payload, &proposal.signed_event_id), + )) + .expect("revision plan"); + revision_sdk + .orders() + .enqueue_prepared_revision_decision( + &buyer, + revision_plan.clone(), + fixture_target_relays(), + None, + &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await + .expect("enqueue revision"); + let revision_repeat = revision_sdk + .orders() + .enqueue_prepared_revision_decision( + &buyer, + revision_plan, + fixture_target_relays(), + None, + &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await + .expect("repeat revision replay"); + assert_eq!( + revision_repeat.workflow.state, + SdkMutationState::AlreadyQueued + ); + assert!( + revision_repeat + .workflow + .idempotency + .replayed_existing_operation + ); +} + +#[tokio::test] +async fn order_ingest_and_enqueue_wrappers_report_prepare_timestamp_errors() { + let sdk = prepared_order_sdk().await; + let out_of_range = RadrootsSdkTimestamp::from_unix_seconds(u64::MAX); + let buyer = fixture_buyer_actor(); + let seller = fixture_seller_actor(); + + assert!(matches!( + sdk.orders() + .ingest_evidence( + OrderEvidenceIngestRequest::new(request_event()).with_observed_at(out_of_range,) + ) + .await, + Err(RadrootsSdkError::TimestampOutOfRange { .. }) + )); + assert!(matches!( + sdk.orders() + .ingest_request_evidence( + OrderRequestEvidenceIngestRequest::new(request_event()) + .with_observed_at(out_of_range,), + ) + .await, + Err(RadrootsSdkError::TimestampOutOfRange { .. }) + )); + assert!(matches!( + sdk.orders() + .enqueue_submit( + OrderSubmitEnqueueRequest::new( + buyer.clone(), + fixture_event_ptr('a'), + fixture_order_request("order-wrapper-submit-error"), + fixture_target_relays(), + ) + .with_created_at(out_of_range), + &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await, + Err(RadrootsSdkError::TimestampOutOfRange { .. }) + )); + assert!(matches!( + sdk.orders() + .enqueue_decision( + OrderDecisionEnqueueRequest::new( + seller.clone(), + fixture_event_ptr('b'), + fixture_order_decision("order-wrapper-decision-error"), + fixture_target_relays(), + ) + .with_created_at(out_of_range), + &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await, + Err(RadrootsSdkError::TimestampOutOfRange { .. }) + )); + + let root_event_id = event_id('c'); + let previous_event_id = event_id('d'); + let proposal_payload = fixture_revision_proposal( + "order-wrapper-proposal-error", + &root_event_id, + &previous_event_id, + ); + assert!(matches!( + sdk.orders() + .enqueue_revision_proposal( + OrderRevisionProposalEnqueueRequest::new( + seller.clone(), + ptr(root_event_id.as_str().to_owned()), + ptr(previous_event_id.as_str().to_owned()), + proposal_payload.clone(), + fixture_target_relays(), + ) + .with_created_at(out_of_range), + &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX), + ) + .await, + Err(RadrootsSdkError::TimestampOutOfRange { .. }) + )); + assert!(matches!( + sdk.orders() + .enqueue_revision_decision( + OrderRevisionDecisionEnqueueRequest::new( + buyer.clone(), + ptr(root_event_id.as_str().to_owned()), + ptr(previous_event_id.as_str().to_owned()), + fixture_revision_decision(&proposal_payload, &previous_event_id), + fixture_target_relays(), + ) + .with_created_at(out_of_range), + &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await, + Err(RadrootsSdkError::TimestampOutOfRange { .. }) + )); + assert!(matches!( + sdk.orders() + .enqueue_cancellation( + OrderCancellationEnqueueRequest::new( + buyer, + ptr(root_event_id.as_str().to_owned()), + ptr(previous_event_id.as_str().to_owned()), + fixture_cancellation("order-wrapper-cancellation-error"), + fixture_target_relays(), + ) + .with_created_at(out_of_range), + &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX), + ) + .await, + Err(RadrootsSdkError::TimestampOutOfRange { .. }) + )); +} + +#[tokio::test] +async fn order_default_timestamp_paths_report_clock_errors() { + let clock_error_sdk = crate::RadrootsSdk::builder() + .clock(crate::RadrootsSdkClock::BeforeUnixEpoch) + .build() + .await + .expect("sdk"); + let buyer = buyer_actor(); + let seller = seller_actor(); + let root_event_id = event_id('a'); + let previous_event_id = event_id('b'); + let proposal = revision_proposal_payload(&root_event_id, &previous_event_id); + + assert!(matches!( + clock_error_sdk + .orders() + .ingest_evidence(OrderEvidenceIngestRequest::new(request_event())) + .await, + Err(RadrootsSdkError::ClockBeforeUnixEpoch) + )); + assert!(matches!( + clock_error_sdk + .orders() + .ingest_request_evidence(OrderRequestEvidenceIngestRequest::new(request_event())) + .await, + Err(RadrootsSdkError::ClockBeforeUnixEpoch) + )); + assert!(matches!( + clock_error_sdk + .orders() + .prepare_submit(OrderSubmitPrepareRequest::new( + buyer.clone(), + ptr(root_event_id.as_str().to_owned()), + order_request_payload(), + )), + Err(RadrootsSdkError::ClockBeforeUnixEpoch) + )); + assert!(matches!( + clock_error_sdk + .orders() + .prepare_decision(OrderDecisionPrepareRequest::new( + seller.clone(), + ptr(root_event_id.as_str().to_owned()), + order_decision_payload(), + )), + Err(RadrootsSdkError::ClockBeforeUnixEpoch) + )); + assert!(matches!( + clock_error_sdk.orders().prepare_revision_proposal( + OrderRevisionProposalPrepareRequest::new( + seller, + ptr(root_event_id.as_str().to_owned()), + ptr(previous_event_id.as_str().to_owned()), + proposal.clone(), + ), + ), + Err(RadrootsSdkError::ClockBeforeUnixEpoch) + )); + assert!(matches!( + clock_error_sdk.orders().prepare_revision_decision( + OrderRevisionDecisionPrepareRequest::new( + buyer.clone(), + ptr(root_event_id.as_str().to_owned()), + ptr(previous_event_id.as_str().to_owned()), + revision_decision_payload( + &proposal, + &previous_event_id, + RadrootsOrderRevisionOutcome::Accepted, + ), + ), + ), + Err(RadrootsSdkError::ClockBeforeUnixEpoch) + )); + assert!(matches!( + clock_error_sdk + .orders() + .prepare_cancellation(OrderCancellationPrepareRequest::new( + buyer, + ptr(root_event_id.as_str().to_owned()), + ptr(previous_event_id.as_str().to_owned()), + cancellation_payload(), + )), + Err(RadrootsSdkError::ClockBeforeUnixEpoch) + )); +} + +#[test] +fn order_runtime_request_builders_and_serializers_cover_source_attached_paths() { + let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_321); + let root_event_id = event_id('a'); + let previous_event_id = event_id('b'); + let root_event = ptr(root_event_id.as_str().to_owned()); + let previous_event = ptr(previous_event_id.as_str().to_owned()); + let proposal = revision_proposal_payload(&root_event_id, &previous_event_id); + let revision_decision = revision_decision_payload( + &proposal, + &event_id('c'), + RadrootsOrderRevisionOutcome::Declined { + reason: "not workable".to_owned(), + }, + ); + let policy = SdkRelayTargetPolicy::UseConfiguredRelays; + + let submit_prepare = + OrderSubmitPrepareRequest::new(buyer_actor(), root_event.clone(), order_request_payload()) + .with_created_at(created_at); + assert_struct_serialize_error_paths(&submit_prepare, 4); + assert_eq!( + serde_json::to_value(&submit_prepare).expect("submit prepare json")["created_at"], + 1_700_000_321 + ); + + let submit_enqueue = OrderSubmitEnqueueRequest::new( + buyer_actor(), + root_event.clone(), + order_request_payload(), + policy.clone(), + ) + .try_with_target_relays(["wss://relay-a.radroots.test"], SdkRelayUrlPolicy::Public) + .expect("submit relays") + .with_idempotency_key(SdkIdempotencyKey::new("submit-unit-key").expect("key")) + .with_created_at(created_at); + assert_struct_serialize_error_paths(&submit_enqueue, 6); + + let request_ingest = + OrderRequestEvidenceIngestRequest::new(request_event()).with_observed_at(created_at); + assert_struct_serialize_error_paths(&request_ingest, 2); + let evidence_ingest = + OrderEvidenceIngestRequest::new(request_event()).with_observed_at(created_at); + assert_struct_serialize_error_paths(&evidence_ingest, 2); + + let decision_prepare = OrderDecisionPrepareRequest::new( + seller_actor(), + root_event.clone(), + order_decision_payload(), + ) + .with_created_at(created_at); + assert_struct_serialize_error_paths(&decision_prepare, 4); + + let decision_enqueue = OrderDecisionEnqueueRequest::new( + seller_actor(), + root_event.clone(), + order_decision_payload(), + policy.clone(), + ) + .try_with_target_relays(["wss://relay-b.radroots.test"], SdkRelayUrlPolicy::Public) + .expect("decision relays") + .with_idempotency_key(SdkIdempotencyKey::new("decision-unit-key").expect("key")) + .with_created_at(created_at); + assert_struct_serialize_error_paths(&decision_enqueue, 6); + + let proposal_prepare = OrderRevisionProposalPrepareRequest::new( + seller_actor(), + root_event.clone(), + previous_event.clone(), + proposal.clone(), + ) + .with_created_at(created_at); + assert_struct_serialize_error_paths(&proposal_prepare, 5); + + let proposal_enqueue = OrderRevisionProposalEnqueueRequest::new( + seller_actor(), + root_event.clone(), + previous_event.clone(), + proposal.clone(), + policy.clone(), + ) + .try_with_target_relays(["wss://relay-c.radroots.test"], SdkRelayUrlPolicy::Public) + .expect("proposal relays") + .with_idempotency_key(SdkIdempotencyKey::new("proposal-unit-key").expect("key")) + .with_created_at(created_at); + assert_struct_serialize_error_paths(&proposal_enqueue, 7); + + let revision_decision_prepare = OrderRevisionDecisionPrepareRequest::new( + buyer_actor(), + root_event.clone(), + previous_event.clone(), + revision_decision.clone(), + ) + .with_created_at(created_at); + assert_struct_serialize_error_paths(&revision_decision_prepare, 5); + + let revision_decision_enqueue = OrderRevisionDecisionEnqueueRequest::new( + buyer_actor(), + root_event.clone(), + previous_event.clone(), + revision_decision, + policy.clone(), + ) + .try_with_target_relays(["wss://relay-d.radroots.test"], SdkRelayUrlPolicy::Public) + .expect("revision decision relays") + .with_idempotency_key(SdkIdempotencyKey::new("revision-decision-unit-key").expect("key")) + .with_created_at(created_at); + assert_struct_serialize_error_paths(&revision_decision_enqueue, 7); + + let cancellation_prepare = OrderCancellationPrepareRequest::new( + buyer_actor(), + root_event.clone(), + previous_event.clone(), + cancellation_payload(), + ) + .with_created_at(created_at); + assert_struct_serialize_error_paths(&cancellation_prepare, 5); + + let cancellation_enqueue = OrderCancellationEnqueueRequest::new( + buyer_actor(), + root_event, + previous_event, + cancellation_payload(), + policy, + ) + .try_with_target_relays(["wss://relay-e.radroots.test"], SdkRelayUrlPolicy::Public) + .expect("cancellation relays") + .with_idempotency_key(SdkIdempotencyKey::new("cancellation-unit-key").expect("key")) + .with_created_at(created_at); + assert_struct_serialize_error_paths(&cancellation_enqueue, 7); + + let parsed_status = OrderStatusRequest::parse(order_id().as_str()) + .expect("status request") + .with_limit(ORDER_STATUS_DEFAULT_LIMIT); + parsed_status.validate().expect("status validates"); + assert_eq!( + serde_json::to_value(&parsed_status).expect("status json")["limit"], + ORDER_STATUS_DEFAULT_LIMIT + ); + + let issue = + SdkOrderStatusIssue::single(SdkOrderStatusIssueKind::ForkedLifecycle, event_id('f')); + assert_eq!(issue.code(), "forked_lifecycle"); + assert_struct_serialize_error_paths(&issue, 3); +} + +#[tokio::test] +async fn closed_event_store_errors_are_mapped_for_ingest_and_prepared_lookup() { + let sdk = crate::RadrootsSdk::builder().build().await.expect("sdk"); + sdk._event_store.pool().close().await; + let ingest_error = sdk + .orders() + .ingest_evidence(OrderEvidenceIngestRequest::new(request_event())) + .await + .expect_err("closed ingest evidence"); + assert!(matches!(ingest_error, RadrootsSdkError::EventStore { .. })); + + let sdk = crate::RadrootsSdk::builder().build().await.expect("sdk"); + sdk._event_store.pool().close().await; + let request_ingest_error = sdk + .orders() + .ingest_request_evidence(OrderRequestEvidenceIngestRequest::new(request_event())) + .await + .expect_err("closed request evidence ingest"); + assert!(matches!( + request_ingest_error, + RadrootsSdkError::EventStore { .. } + )); + + let sdk = crate::RadrootsSdk::builder().build().await.expect("sdk"); + sdk._event_store.pool().close().await; + let lookup_error = sdk + .orders() + .prepared_order_event_exists(&event_id('a')) + .await + .expect_err("closed prepared lookup"); + assert!(matches!(lookup_error, RadrootsSdkError::EventStore { .. })); +} + +#[tokio::test] +async fn order_status_and_evidence_ingest_cover_source_attached_success_paths() { + let sdk = prepared_order_sdk().await; + let request_event = request_event(); + let request_receipt = sdk + .orders() + .ingest_request_evidence(OrderRequestEvidenceIngestRequest::new( + request_event.clone(), + )) + .await + .expect("request evidence ingest"); + assert!(request_receipt.inserted); + assert_eq!(request_receipt.order_id, order_id()); + + let submit = enqueue_fixture_submit(&sdk, "order-status-source-attached").await; + let status = sdk + .orders() + .status(OrderStatusRequest::parse(submit.order_id.as_str()).expect("status request")) + .await + .expect("order status"); + assert_eq!(status.status, OrderStatusKind::Requested); + assert!(status.evidence.has_request); + + let duplicate_receipt = sdk + .orders() + .ingest_evidence(OrderEvidenceIngestRequest::new(request_event)) + .await + .expect("order evidence ingest"); + assert!(!duplicate_receipt.inserted); + assert_eq!( + duplicate_receipt.local_event_seq, + request_receipt.local_event_seq + ); +} diff --git a/crates/sdk/tests/unit/relay_targets_tests.rs b/crates/sdk/tests/unit/relay_targets_tests.rs @@ -0,0 +1,132 @@ +use super::{SdkRelayTargetPolicy, SdkRelayTargetSet, SdkRelayUrlPolicy}; +use crate::{RadrootsSdkError, SDK_RELAY_TARGET_MAX_COUNT}; + +#[path = "../support/serializer_failure.rs"] +mod serializer_failure; + +use serializer_failure::assert_struct_serialize_error_paths; + +fn is_local_ws_relay(value: &str) -> bool { + let Some(rest) = value.strip_prefix("ws://") else { + return false; + }; + let authority = rest + .split_once('/') + .map(|(authority, _)| authority) + .unwrap_or(rest); + let host = relay_authority_host(authority); + matches!(host.as_deref(), Some("localhost" | "127.0.0.1" | "[::1]")) +} + +fn relay_authority_host(authority: &str) -> Option<String> { + if let Some(after_open) = authority.strip_prefix('[') { + let close_index = after_open.find(']')?; + return Some(format!("[{}]", &after_open[..close_index])); + } + Some( + authority + .split_once(':') + .map(|(host, _)| host) + .unwrap_or(authority) + .to_owned(), + ) +} + +#[test] +fn use_configured_policy_serializes_as_kind_only() { + let policy = SdkRelayTargetPolicy::UseConfiguredRelays; + assert_eq!( + serde_json::to_value(&policy).expect("json"), + serde_json::json!({ "kind": "use_configured_relays" }) + ); + assert_struct_serialize_error_paths(&policy, 1); +} + +#[test] +fn target_set_accessors_and_configured_relays_cover_empty_and_dedupe_paths() { + assert_eq!( + SdkRelayTargetSet::from_configured_relays(Vec::<String>::new(), SdkRelayUrlPolicy::Public) + .expect("empty configured"), + Vec::<String>::new() + ); + + let targets = SdkRelayTargetSet::from_normalized_relays(vec![ + "wss://relay-a.example.com".to_owned(), + "wss://relay-a.example.com".to_owned(), + "wss://relay-b.example.com".to_owned(), + ]) + .expect("targets"); + + assert_eq!(targets.len(), 2); + assert!(!targets.is_empty()); + assert_struct_serialize_error_paths(&targets, 2); + assert_struct_serialize_error_paths(&SdkRelayTargetPolicy::explicit(targets.clone()), 3); + assert_eq!( + serde_json::to_value(&targets).expect("targets json"), + serde_json::json!({ + "relays": ["wss://relay-a.example.com", "wss://relay-b.example.com"], + "canonical_relays": ["wss://relay-a.example.com", "wss://relay-b.example.com"] + }) + ); + assert_eq!( + targets.into_vec(), + vec![ + "wss://relay-a.example.com".to_owned(), + "wss://relay-b.example.com".to_owned() + ] + ); + + assert_eq!( + SdkRelayTargetPolicy::try_explicit( + vec!["wss://relay-c.example.com".to_owned()], + SdkRelayUrlPolicy::Public, + ) + .expect("explicit policy"), + SdkRelayTargetPolicy::Explicit( + SdkRelayTargetSet::new(["wss://relay-c.example.com"], SdkRelayUrlPolicy::Public) + .expect("target set"), + ) + ); +} + +#[test] +fn normalized_relays_reject_empty_and_over_limit_sets() { + assert!(matches!( + SdkRelayTargetSet::from_normalized_relays(Vec::new()), + Err(RadrootsSdkError::EmptyTargetRelays { .. }) + )); + + let too_many = (0..=SDK_RELAY_TARGET_MAX_COUNT) + .map(|index| format!("wss://relay-{index}.example.com")) + .collect::<Vec<_>>(); + assert!(matches!( + SdkRelayTargetSet::from_normalized_relays(too_many), + Err(RadrootsSdkError::RelayTargetLimitExceeded { actual, .. }) + if actual == SDK_RELAY_TARGET_MAX_COUNT + 1 + )); +} + +#[test] +fn local_ws_authority_parser_handles_ipv6_ports_and_non_ws_values() { + assert!(is_local_ws_relay("ws://localhost:8080/path")); + assert!(is_local_ws_relay("ws://127.0.0.1:8080")); + assert!(is_local_ws_relay("ws://[::1]:8080")); + assert!(!is_local_ws_relay("wss://relay.example.com")); + assert!(!is_local_ws_relay("ws://relay.example.com")); + assert!(matches!( + SdkRelayTargetSet::new(["ws://relay.example.com"], SdkRelayUrlPolicy::Localhost), + Err(RadrootsSdkError::InvalidRelayUrl { reason, .. }) + if reason.contains("localhost") + )); + assert!(matches!( + SdkRelayTargetSet::new(["ws://relay.example.com"], SdkRelayUrlPolicy::Public), + Err(RadrootsSdkError::InvalidRelayUrl { reason, .. }) + if reason.contains("localhost") + )); + assert_eq!(relay_authority_host("[::1]:8080"), Some("[::1]".to_owned())); + assert_eq!( + relay_authority_host("relay.example.com:443"), + Some("relay.example.com".to_owned()) + ); + assert_eq!(relay_authority_host("[::1"), None); +} diff --git a/crates/sdk/tests/unit/runtime_tests.rs b/crates/sdk/tests/unit/runtime_tests.rs @@ -0,0 +1,1122 @@ +use super::*; +use std::time::{Duration, SystemTime}; + +fn invalid_request_message<T>(result: Result<T, RadrootsSdkError>) -> String { + match result.err().expect("expected invalid request error") { + RadrootsSdkError::InvalidRequest { message } => message, + other => panic!("expected invalid request error, got {other:?}"), + } +} + +fn io_message<T>(result: Result<T, RadrootsSdkError>) -> String { + match result.err().expect("expected io error") { + RadrootsSdkError::Io { message, .. } => message, + other => panic!("expected io error, got {other:?}"), + } +} + +fn assert_event_store_error<T>(result: Result<T, RadrootsSdkError>) { + match result { + Err(RadrootsSdkError::EventStore { .. }) => {} + Err(other) => panic!("expected event store error, got {other:?}"), + Ok(_) => panic!("expected event store error"), + } +} + +fn assert_outbox_error<T>(result: Result<T, RadrootsSdkError>) { + match result { + Err(RadrootsSdkError::Outbox { .. }) => {} + Err(other) => panic!("expected outbox error, got {other:?}"), + Ok(_) => panic!("expected outbox error"), + } +} + +fn sqlite_status() -> SdkSqliteStoreStatus { + SdkSqliteStoreStatus { + schema_version: 1, + journal_mode: "wal".to_owned(), + foreign_keys_enabled: true, + busy_timeout_ms: 5_000, + integrity_ok: true, + integrity_result: "ok".to_owned(), + } +} + +fn storage_status() -> StorageStatusReceipt { + StorageStatusReceipt { + storage: SdkStorageKind::Memory, + paths: None, + event_store: SdkEventStoreStorageStatus { + store: sqlite_status(), + total_events: 0, + projection_eligible_events: 0, + relay_observations: 0, + last_event_seq: None, + last_event_updated_at_ms: None, + }, + outbox: SdkOutboxStorageStatus { + store: sqlite_status(), + total_events: 0, + pending_events: 0, + retryable_events: 0, + terminal_events: 0, + failed_terminal_events: 0, + ready_signed_events: 0, + publishing_events: 0, + last_attempt_at_ms: None, + last_error: None, + }, + } +} + +fn verification(event_store_ok: bool, outbox_ok: bool) -> SdkBackupVerification { + SdkBackupVerification { + event_store_ok, + outbox_ok, + event_store_events: 0, + outbox_events: 0, + } +} + +#[cfg(unix)] +fn set_mode(path: &Path, mode: u32) { + use std::os::unix::fs::PermissionsExt; + + let mut permissions = fs::metadata(path).expect("metadata").permissions(); + permissions.set_mode(mode); + fs::set_permissions(path, permissions).expect("permissions"); +} + +#[cfg(unix)] +fn non_utf8_path() -> PathBuf { + use std::{ffi::OsString, os::unix::ffi::OsStringExt}; + + PathBuf::from(OsString::from_vec(b"invalid-\xFF.sqlite".to_vec())) +} + +#[cfg(unix)] +fn nul_path() -> PathBuf { + use std::{ffi::OsString, os::unix::ffi::OsStringExt}; + + PathBuf::from(OsString::from_vec(b"invalid-\0path".to_vec())) +} + +fn manifest() -> SdkBackupManifest { + SdkBackupManifest { + manifest_kind: SDK_STORAGE_MANIFEST_KIND, + manifest_version: SDK_STORAGE_MANIFEST_VERSION, + sdk_version: "0.1.0".to_owned(), + created_at_ms: 1_700_000_000_000, + source_storage: SdkStorageKind::Memory, + source_paths: None, + backup_paths: RadrootsSdkStoragePaths { + event_store_path: PathBuf::from(EVENT_STORE_BACKUP_FILE), + outbox_path: PathBuf::from(OUTBOX_BACKUP_FILE), + }, + source_status: storage_status(), + backup_verification: verification(true, true), + } +} + +#[tokio::test] +async fn open_storage_and_storage_kind_cover_memory_directory_and_file_failures() { + let memory = open_storage(&RadrootsSdkStorageConfig::Memory) + .await + .expect("memory storage"); + assert!(memory.paths.is_none()); + let memory_sdk = RadrootsSdk { + _event_store: memory.event_store, + _outbox: memory.outbox, + storage_paths: None, + clock: RadrootsSdkClock::Fixed(RadrootsSdkTimestamp::from_unix_seconds(1)), + relay_urls: Vec::new(), + }; + assert_eq!(memory_sdk.storage_kind(), SdkStorageKind::Memory); + + let tempdir = tempfile::tempdir().expect("tempdir"); + let directory = tempdir.path().join("sdk"); + let directory_storage = open_storage(&RadrootsSdkStorageConfig::Directory(directory)) + .await + .expect("directory storage"); + let directory_paths = directory_storage.paths.expect("directory paths"); + assert!(directory_paths.event_store_path.exists()); + assert!(directory_paths.outbox_path.exists()); + let directory_sdk = RadrootsSdk { + _event_store: directory_storage.event_store, + _outbox: directory_storage.outbox, + storage_paths: Some(directory_paths), + clock: RadrootsSdkClock::Fixed(RadrootsSdkTimestamp::from_unix_seconds(1)), + relay_urls: Vec::new(), + }; + assert_eq!(directory_sdk.storage_kind(), SdkStorageKind::Directory); + + let file_path = tempdir.path().join("not-directory"); + fs::write(&file_path, b"file").expect("file"); + assert!(!io_message(open_directory_storage(&file_path).await).is_empty()); + + let event_store_directory = tempdir.path().join("event-store-directory"); + fs::create_dir(&event_store_directory).expect("event store dir"); + fs::create_dir(event_store_directory.join(EVENT_STORE_BACKUP_FILE)) + .expect("event store file slot dir"); + assert_event_store_error(open_directory_storage(&event_store_directory).await); + + let outbox_directory = tempdir.path().join("outbox-directory"); + fs::create_dir(&outbox_directory).expect("outbox dir"); + fs::create_dir(outbox_directory.join(OUTBOX_BACKUP_FILE)).expect("outbox file slot dir"); + assert_outbox_error(open_directory_storage(&outbox_directory).await); +} + +#[tokio::test] +async fn runtime_public_surface_covers_builders_status_integrity_backup_and_restore() { + assert_eq!( + RadrootsSdkStorageConfig::default(), + RadrootsSdkStorageConfig::Memory + ); + assert_eq!(RadrootsSdkClock::default(), RadrootsSdkClock::System); + assert!( + RadrootsSdkTimestamp::from_unix_seconds(u64::from(u32::MAX) + 1) + .try_into_nostr_created_at() + .is_err() + ); + + let memory_sdk = RadrootsSdk::builder() + .storage(RadrootsSdkStorageConfig::Memory) + .clock(RadrootsSdkClock::Fixed( + RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000), + )) + .relay_url_policy(SdkRelayUrlPolicy::Localhost) + .relay_url("ws://127.0.0.1:7777") + .build() + .await + .expect("memory sdk"); + assert_eq!( + memory_sdk.now().expect("fixed now").unix_seconds(), + 1_700_000_000 + ); + assert_eq!(memory_sdk.relay_urls(), ["ws://127.0.0.1:7777"]); + assert!(memory_sdk.storage_paths().is_none()); + let _ = memory_sdk.farms(); + let _ = memory_sdk.listings(); + let _ = memory_sdk.orders(); + let _ = memory_sdk.sync(); + let memory_status = memory_sdk + .storage_status(StorageStatusRequest::new()) + .await + .expect("memory status"); + assert_eq!(memory_status.storage, SdkStorageKind::Memory); + let memory_integrity = memory_sdk + .integrity(IntegrityRequest::new()) + .await + .expect("memory integrity"); + assert!(memory_integrity.event_store_ok); + assert!(memory_integrity.outbox_ok); + + let tempdir = tempfile::tempdir().expect("tempdir"); + let directory = tempdir.path().join("sdk"); + let directory_sdk = RadrootsSdk::builder() + .directory_storage(&directory) + .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_001)) + .build() + .await + .expect("directory sdk"); + assert!(directory_sdk.storage_paths().is_some()); + + let backup_destination = tempdir.path().join("backup"); + let backup = directory_sdk + .backup(BackupRequest::new(&backup_destination).with_overwrite(false)) + .await + .expect("backup"); + assert_eq!(backup.state, SdkBackupState::Completed); + assert!( + backup + .manifest_path + .as_ref() + .is_some_and(|path| path.exists()) + ); + + let archive = RadrootsSdk::inspect_restore_archive(&backup_destination) + .await + .expect("restore archive"); + assert_eq!(archive.manifest, backup.manifest); + + let restore_destination = tempdir.path().join("restore"); + let dry_run = RadrootsSdk::restore( + RestoreRequest::new(&backup_destination) + .with_destination(&restore_destination) + .with_overwrite(false) + .with_dry_run(true), + ) + .await + .expect("dry-run restore"); + assert_eq!(dry_run.state, SdkRestoreState::DryRun); + assert!(dry_run.restored_paths.is_none()); + + let restore = RadrootsSdk::restore( + RestoreRequest::new(&backup_destination) + .with_destination(&restore_destination) + .with_overwrite(true), + ) + .await + .expect("restore"); + assert_eq!(restore.state, SdkRestoreState::Completed); + assert!(restore.restored_paths.is_some()); + + let dry_request = RestoreRequest::new(&backup_destination) + .with_destination(tempdir.path().join("restore-dry-helper")) + .dry_run(); + assert!(dry_request.dry_run); +} + +#[tokio::test] +async fn runtime_clock_errors_cover_sdk_now_callers() { + assert!(matches!( + RadrootsSdkClock::BeforeUnixEpoch.now(), + Err(RadrootsSdkError::ClockBeforeUnixEpoch) + )); + let sdk = RadrootsSdk::builder() + .clock(RadrootsSdkClock::BeforeUnixEpoch) + .build() + .await + .expect("sdk"); + assert!(matches!( + sdk_now_ms(&sdk), + Err(RadrootsSdkError::ClockBeforeUnixEpoch) + )); + assert!(matches!( + sdk.storage_status(StorageStatusRequest::new()).await, + Err(RadrootsSdkError::ClockBeforeUnixEpoch) + )); + let tempdir = tempfile::tempdir().expect("tempdir"); + assert!(matches!( + sdk.backup(BackupRequest::new(tempdir.path().join("backup"))) + .await, + Err(RadrootsSdkError::ClockBeforeUnixEpoch) + )); +} + +#[test] +fn system_time_converters_cover_epoch_success_and_failure_edges() { + assert_eq!( + sdk_timestamp_from_system_time(UNIX_EPOCH + Duration::from_secs(42)) + .expect("timestamp") + .unix_seconds(), + 42 + ); + assert!(matches!( + sdk_timestamp_from_system_time(UNIX_EPOCH - Duration::from_secs(1)), + Err(RadrootsSdkError::ClockBeforeUnixEpoch) + )); + assert_eq!( + system_time_nanos_since_unix_epoch(UNIX_EPOCH + Duration::from_nanos(7)).expect("nanos"), + 7 + ); + assert!(matches!( + system_time_nanos_since_unix_epoch(UNIX_EPOCH - Duration::from_nanos(1)), + Err(RadrootsSdkError::ClockBeforeUnixEpoch) + )); +} + +#[tokio::test] +async fn storage_status_integrity_and_backup_map_closed_pool_errors() { + let event_store_closed = RadrootsSdk::builder().build().await.expect("sdk"); + event_store_closed._event_store.pool().close().await; + assert!(matches!( + event_store_closed + .storage_status(StorageStatusRequest::new()) + .await, + Err(RadrootsSdkError::EventStore { .. }) + )); + assert_event_store_error(event_store_sqlite_status(&event_store_closed._event_store).await); + assert_event_store_error(event_store_status_summary(&event_store_closed._event_store).await); + assert!(matches!( + event_store_closed.integrity(IntegrityRequest::new()).await, + Err(RadrootsSdkError::EventStore { .. }) + )); + let tempdir = tempfile::tempdir().expect("tempdir"); + let backup_destination = tempdir.path().join("backup"); + assert!(matches!( + event_store_closed + .backup(BackupRequest::new(backup_destination)) + .await, + Err(RadrootsSdkError::EventStore { .. }) + )); + + let outbox_closed = RadrootsSdk::builder().build().await.expect("sdk"); + outbox_closed._outbox.pool().close().await; + assert!(matches!( + outbox_closed + .storage_status(StorageStatusRequest::new()) + .await, + Err(RadrootsSdkError::Outbox { .. }) + )); + assert_outbox_error(outbox_sqlite_status(&outbox_closed._outbox).await); + assert_outbox_error(outbox_status_summary(&outbox_closed._outbox, 1).await); + assert!(matches!( + outbox_closed.integrity(IntegrityRequest::new()).await, + Err(RadrootsSdkError::EventStore { .. }) + )); +} + +#[test] +fn restore_archive_path_validators_cover_missing_outside_and_manifest_edges() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let source = tempdir.path().join("source"); + fs::create_dir(&source).expect("source"); + let source_root = canonical_restore_directory(&source).expect("canonical source"); + let file_member = source.join(EVENT_STORE_BACKUP_FILE); + fs::write(&file_member, b"sqlite").expect("file member"); + let outside_file = tempdir.path().join("outside.sqlite"); + fs::write(&outside_file, b"sqlite").expect("outside file"); + let dir_member = source.join("dir-member"); + fs::create_dir(&dir_member).expect("dir member"); + + assert!( + validate_relative_archive_path(Path::new(EVENT_STORE_BACKUP_FILE), "event store").is_ok() + ); + assert!( + invalid_request_message(validate_relative_archive_path(Path::new(""), "event store")) + .contains("must not be empty") + ); + assert!( + invalid_request_message(validate_relative_archive_path( + Path::new("../outside.sqlite"), + "event store", + )) + .contains("relative and contained") + ); + assert!(validate_restore_member_path(&source_root, &file_member, "event store").is_ok()); + assert!( + invalid_request_message(validate_restore_member_path( + &source_root, + &dir_member, + "event store", + )) + .contains("regular file") + ); + assert!( + invalid_request_message(validate_restore_member_path( + &source_root, + &outside_file, + "event store", + )) + .contains("inside the backup directory") + ); + assert!( + io_message(validate_restore_member_path( + &source_root, + &source.join("missing.sqlite"), + "event store", + )) + .contains("No such") + ); + assert!( + restore_archive_member_path( + &source_root, + Path::new(EVENT_STORE_BACKUP_FILE), + "event store", + ) + .is_ok() + ); + assert!( + invalid_request_message(restore_archive_member_path( + &source_root, + Path::new("../outside.sqlite"), + "event store", + )) + .contains("relative and contained") + ); + + assert!(write_backup_manifest(&source.join(BACKUP_MANIFEST_FILE), &manifest()).is_ok()); + assert!(!io_message(write_backup_manifest(tempdir.path(), &manifest())).is_empty()); + assert!( + !io_message(canonicalize_restore_path( + &tempdir.path().join("missing-canonical-path"), + )) + .is_empty() + ); + + let mut unsupported_version = manifest(); + unsupported_version.manifest_version = SDK_STORAGE_MANIFEST_VERSION + 1; + assert!( + invalid_request_message(validate_restore_manifest(&unsupported_version)) + .contains("version") + ); + + let ok = verification(true, true); + assert!(validate_restore_verification(&ok, &ok).is_ok()); + assert!( + invalid_request_message(validate_restore_verification( + &verification(false, true), + &ok, + )) + .contains("integrity") + ); + let mismatch = SdkBackupVerification { + event_store_events: 1, + ..ok.clone() + }; + assert!( + invalid_request_message(validate_restore_verification(&mismatch, &ok)) + .contains("does not match manifest") + ); +} + +#[test] +fn restore_destination_preflight_covers_empty_existing_new_and_overlap_paths() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let source = tempdir.path().join("source"); + let parent = tempdir.path().join("parent"); + fs::create_dir(&source).expect("source"); + fs::create_dir(&parent).expect("parent"); + + assert!( + invalid_request_message(preflight_restore_destination(&source, Path::new(""), false)) + .contains("destination must not be empty") + ); + + let new_destination = parent.join("new-destination"); + let paths = + preflight_restore_destination(&source, &new_destination, false).expect("new preflight"); + assert_eq!( + paths.event_store_path, + new_destination.join(EVENT_STORE_BACKUP_FILE) + ); + let relative_destination = PathBuf::from(format!( + "relative-restore-{}", + system_time_nanos_since_unix_epoch(SystemTime::now()).expect("time") + )); + let relative_paths = preflight_restore_destination(&source, &relative_destination, false) + .expect("relative preflight"); + assert_eq!( + relative_paths.event_store_path, + relative_destination.join(EVENT_STORE_BACKUP_FILE) + ); + + let file_source = tempdir.path().join("file-source"); + fs::write(&file_source, b"source file").expect("file source"); + assert!( + invalid_request_message(preflight_restore_destination( + &file_source, + &parent.join("file-source-restore"), + false, + )) + .contains("source must be a directory") + ); + + let empty_directory = parent.join("empty"); + fs::create_dir(&empty_directory).expect("empty dir"); + assert!(preflight_restore_destination(&source, &empty_directory, false).is_ok()); + + let nonempty_directory = parent.join("nonempty"); + fs::create_dir(&nonempty_directory).expect("nonempty dir"); + fs::write(nonempty_directory.join("entry"), b"entry").expect("entry"); + assert!( + invalid_request_message(preflight_restore_destination( + &source, + &nonempty_directory, + false, + )) + .contains("overwrite is false") + ); + assert!(preflight_restore_destination(&source, &nonempty_directory, true).is_ok()); + + let file_destination = parent.join("file-destination"); + fs::write(&file_destination, b"file").expect("file"); + assert!( + invalid_request_message(preflight_restore_destination( + &source, + &file_destination, + false, + )) + .contains("overwrite is false") + ); + assert!(preflight_restore_destination(&source, &file_destination, true).is_ok()); + + let nested_file_destination = source.join("nested-file-destination"); + fs::write(&nested_file_destination, b"nested").expect("nested destination"); + assert!( + invalid_request_message(preflight_restore_destination( + &source, + &nested_file_destination, + true, + )) + .contains("must not overlap") + ); + + #[cfg(unix)] + { + let socket_destination = parent.join("socket-destination"); + let _listener = + std::os::unix::net::UnixListener::bind(&socket_destination).expect("socket"); + assert!( + invalid_request_message(preflight_restore_destination( + &source, + &socket_destination, + true, + )) + .contains("directory path") + ); + } + + assert!( + invalid_request_message(reject_restore_destination_overlap( + &source, + &source.join("nested"), + )) + .contains("must not overlap") + ); + assert!( + invalid_request_message(reject_restore_destination_overlap(tempdir.path(), &source)) + .contains("must not overlap") + ); + assert!( + invalid_request_message(preflight_restore_destination(&source, &source, true)) + .contains("must not overlap") + ); + + let file_parent = tempdir.path().join("file-parent"); + fs::write(&file_parent, b"file").expect("file parent"); + assert!( + !io_message(preflight_restore_destination( + &source, + &file_parent.join("restore"), + false, + )) + .is_empty() + ); +} + +#[test] +fn backup_destination_and_restore_file_helpers_cover_cleanup_and_io_edges() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let new_backup = tempdir.path().join("backup-new"); + prepare_backup_destination(&new_backup, false).expect("new backup destination"); + assert!(new_backup.is_dir()); + assert!( + invalid_request_message(prepare_backup_destination(&new_backup, false)) + .contains("already exists") + ); + + let file_backup = tempdir.path().join("backup-file"); + fs::write(&file_backup, b"file").expect("backup file"); + prepare_backup_destination(&file_backup, true).expect("overwrite file backup"); + assert!(file_backup.is_dir()); + + let directory_backup = tempdir.path().join("backup-directory"); + fs::create_dir(&directory_backup).expect("backup dir"); + fs::write(directory_backup.join("entry"), b"entry").expect("backup dir entry"); + prepare_backup_destination(&directory_backup, true).expect("overwrite dir backup"); + assert!(directory_backup.is_dir()); + assert!( + directory_backup + .join("entry") + .try_exists() + .is_ok_and(|exists| !exists) + ); + + let missing = tempdir.path().join("missing"); + assert!(remove_existing_restore_path(&missing).is_ok()); + assert!(!io_message(remove_existing_restore_path(&nul_path())).is_empty()); + + let restore_file = tempdir.path().join("restore-file"); + fs::write(&restore_file, b"file").expect("restore file"); + remove_existing_restore_path(&restore_file).expect("remove restore file"); + assert!(!restore_file.exists()); + + let restore_dir = tempdir.path().join("restore-dir"); + fs::create_dir(&restore_dir).expect("restore dir"); + fs::write(restore_dir.join("entry"), b"entry").expect("restore dir entry"); + remove_existing_restore_path(&restore_dir).expect("remove restore dir"); + assert!(!restore_dir.exists()); + + assert!( + io_message(copy_restore_file( + &tempdir.path().join("missing-source"), + &tempdir.path().join("copy-destination"), + "event store", + )) + .contains("restore event store copy failed") + ); + assert!( + io_message(rename_restore_path( + &tempdir.path().join("missing-source"), + &tempdir.path().join("rename-destination"), + "previous destination", + )) + .contains("restore previous destination rename failed") + ); + assert!( + invalid_request_message(unique_restore_sidecar_path( + tempdir.path(), + Path::new(""), + "staging", + )) + .contains("directory name") + ); + + let destination = tempdir.path().join("destination"); + let previous = tempdir.path().join("previous"); + fs::write(&destination, b"current").expect("current destination"); + fs::write(&previous, b"previous").expect("previous destination"); + rollback_restore_destination(&destination, &previous, true); + assert_eq!( + fs::read(&destination).expect("rolled back destination"), + b"previous" + ); + rollback_restore_destination(&destination, &previous, false); +} + +#[test] +fn unique_restore_sidecar_path_reserves_after_collisions_and_reports_exhaustion() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let collision = tempdir.path().join(".restore.radroots-restore-staging-7-0"); + fs::write(&collision, b"taken").expect("collision"); + let reserved = unique_restore_sidecar_path_with_nanos(tempdir.path(), "restore", "staging", 7) + .expect("reserved after collision"); + assert!(reserved.ends_with(".restore.radroots-restore-staging-7-1")); + + for attempt in 1..100u8 { + fs::write( + tempdir + .path() + .join(format!(".restore.radroots-restore-staging-7-{attempt}")), + b"taken", + ) + .expect("attempt collision"); + } + assert!( + invalid_request_message(unique_restore_sidecar_path_with_nanos( + tempdir.path(), + "restore", + "staging", + 7, + )) + .contains("could not reserve") + ); + assert!( + !io_message(unique_restore_sidecar_path_with_nanos( + nul_path().as_path(), + "restore", + "staging", + 8, + )) + .is_empty() + ); + + let missing_staging = tempdir.path().join("missing-staging"); + let missing_destination = tempdir.path().join("missing-destination"); + let missing_previous = tempdir.path().join("missing-previous"); + assert!( + !io_message(install_restore_staging( + &missing_staging, + &missing_destination, + &missing_previous, + )) + .is_empty() + ); + assert!(!missing_destination.exists()); + + let rollback_destination = tempdir.path().join("rollback-destination"); + fs::create_dir(&rollback_destination).expect("rollback destination"); + fs::write(rollback_destination.join("old"), b"old").expect("old entry"); + let rollback_previous = tempdir.path().join("rollback-previous"); + assert!( + !io_message(install_restore_staging( + &missing_staging, + &rollback_destination, + &rollback_previous, + )) + .is_empty() + ); + assert!(rollback_destination.join("old").exists()); + assert!(!rollback_previous.exists()); +} + +#[cfg(unix)] +#[test] +fn permission_denied_paths_cover_backup_restore_io_edges() { + let tempdir = tempfile::tempdir().expect("tempdir"); + + let protected_create_parent = tempdir.path().join("protected-create"); + fs::create_dir(&protected_create_parent).expect("protected create parent"); + set_mode(&protected_create_parent, 0o500); + let create_result = prepare_backup_destination(&protected_create_parent.join("backup"), false); + set_mode(&protected_create_parent, 0o700); + assert!(!io_message(create_result).is_empty()); + + let hidden_backup_parent = tempdir.path().join("hidden-backup-parent"); + fs::create_dir(&hidden_backup_parent).expect("hidden backup parent"); + let hidden_backup = hidden_backup_parent.join("backup"); + set_mode(&hidden_backup_parent, 0o000); + let metadata_result = prepare_backup_destination(&hidden_backup, false); + set_mode(&hidden_backup_parent, 0o700); + assert!(!io_message(metadata_result).is_empty()); + + let protected_backup_parent = tempdir.path().join("protected-backup"); + fs::create_dir(&protected_backup_parent).expect("protected backup parent"); + let protected_backup_dir = protected_backup_parent.join("backup-dir"); + fs::create_dir(&protected_backup_dir).expect("protected backup dir"); + set_mode(&protected_backup_parent, 0o500); + let remove_dir_result = prepare_backup_destination(&protected_backup_dir, true); + set_mode(&protected_backup_parent, 0o700); + assert!(!io_message(remove_dir_result).is_empty()); + + let protected_backup_file = protected_backup_parent.join("backup-file"); + fs::write(&protected_backup_file, b"backup").expect("protected backup file"); + set_mode(&protected_backup_parent, 0o500); + let remove_file_result = prepare_backup_destination(&protected_backup_file, true); + set_mode(&protected_backup_parent, 0o700); + assert!(!io_message(remove_file_result).is_empty()); + + let protected_restore_parent = tempdir.path().join("protected-restore"); + fs::create_dir(&protected_restore_parent).expect("protected restore parent"); + let protected_restore_dir = protected_restore_parent.join("restore-dir"); + fs::create_dir(&protected_restore_dir).expect("protected restore dir"); + set_mode(&protected_restore_parent, 0o500); + let remove_restore_dir_result = remove_existing_restore_path(&protected_restore_dir); + set_mode(&protected_restore_parent, 0o700); + assert!(!io_message(remove_restore_dir_result).is_empty()); + + let protected_restore_file = protected_restore_parent.join("restore-file"); + fs::write(&protected_restore_file, b"restore").expect("protected restore file"); + set_mode(&protected_restore_parent, 0o500); + let remove_restore_file_result = remove_existing_restore_path(&protected_restore_file); + set_mode(&protected_restore_parent, 0o700); + assert!(!io_message(remove_restore_file_result).is_empty()); + + let source = tempdir.path().join("source"); + let destination = tempdir.path().join("destination"); + fs::create_dir(&source).expect("source"); + fs::create_dir(&destination).expect("destination"); + + set_mode(&destination, 0o300); + let read_dir_result = preflight_restore_destination(&source, &destination, false); + set_mode(&destination, 0o700); + assert!(!io_message(read_dir_result).is_empty()); + + let no_execute_destination = tempdir.path().join("no-execute-destination"); + fs::create_dir(&no_execute_destination).expect("no execute destination"); + set_mode(&no_execute_destination, 0o200); + let canonicalize_result = + preflight_restore_destination(&source, &no_execute_destination, false); + set_mode(&no_execute_destination, 0o700); + assert!(!io_message(canonicalize_result).is_empty()); + + let hidden_parent = tempdir.path().join("hidden-parent"); + fs::create_dir(&hidden_parent).expect("hidden parent"); + let hidden_destination = hidden_parent.join("destination"); + set_mode(&hidden_parent, 0o000); + let metadata_result = preflight_restore_destination(&source, &hidden_destination, false); + set_mode(&hidden_parent, 0o700); + assert!(!io_message(metadata_result).is_empty()); +} + +#[cfg(unix)] +#[tokio::test] +async fn sqlite_backup_errors_cover_invalid_paths_and_execute_failures() { + let storage = open_storage(&RadrootsSdkStorageConfig::Memory) + .await + .expect("memory storage"); + + assert!( + invalid_request_message( + sqlite_vacuum_into(storage.event_store.pool(), &non_utf8_path(), "event store",).await + ) + .contains("valid UTF-8") + ); + + storage.event_store.pool().close().await; + let tempdir = tempfile::tempdir().expect("tempdir"); + let closed_pool_destination = tempdir.path().join("closed-pool.sqlite"); + let error = sqlite_vacuum_into( + storage.event_store.pool(), + &closed_pool_destination, + "event store", + ) + .await + .err() + .expect("sqlite error"); + assert!(matches!( + error, + RadrootsSdkError::EventStore { message } if message.contains("backup failed") + )); + let backup_paths = RadrootsSdkStoragePaths { + event_store_path: tempdir.path().join("closed-event-store-backup.sqlite"), + outbox_path: tempdir.path().join("closed-event-store-outbox.sqlite"), + }; + assert_event_store_error( + backup_sqlite_stores( + storage.event_store.pool(), + storage.outbox.pool(), + &backup_paths, + ) + .await, + ); + + let outbox_closed_storage = open_storage(&RadrootsSdkStorageConfig::Memory) + .await + .expect("outbox closed storage"); + outbox_closed_storage.outbox.pool().close().await; + let outbox_closed_paths = RadrootsSdkStoragePaths { + event_store_path: tempdir.path().join("open-event-store-backup.sqlite"), + outbox_path: tempdir.path().join("closed-outbox-backup.sqlite"), + }; + assert_event_store_error( + backup_sqlite_stores( + outbox_closed_storage.event_store.pool(), + outbox_closed_storage.outbox.pool(), + &outbox_closed_paths, + ) + .await, + ); + assert!( + !io_message(write_backup_receipt( + tempdir.path().join("receipt-destination"), + RadrootsSdkStoragePaths { + event_store_path: tempdir.path().join(EVENT_STORE_BACKUP_FILE), + outbox_path: tempdir.path().join(OUTBOX_BACKUP_FILE), + }, + tempdir.path().to_path_buf(), + manifest(), + )) + .is_empty() + ); + + let integrity_error = sqlite_integrity_result(storage.event_store.pool()) + .await + .err() + .expect("integrity error"); + assert!(matches!( + integrity_error, + RadrootsSdkError::EventStore { .. } + )); + assert_event_store_error( + sqlite_store_status(storage.event_store.pool(), 1, "wal".to_owned(), true, 5_000).await, + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn restore_archive_private_failures_cover_staging_and_verification_edges() { + let tempdir = tempfile::tempdir().expect("tempdir"); + + let unreadable_source = tempdir.path().join("unreadable-source"); + fs::create_dir(&unreadable_source).expect("unreadable source"); + let unreadable_manifest = unreadable_source.join(BACKUP_MANIFEST_FILE); + fs::write(&unreadable_manifest, b"{}").expect("unreadable manifest"); + set_mode(&unreadable_manifest, 0o000); + let inspect_result = inspect_restore_archive(unreadable_source).await; + set_mode(&unreadable_manifest, 0o600); + assert!(!io_message(inspect_result).is_empty()); + + let missing_archive = RestoreArchive { + source: tempdir.path().join("missing-archive"), + event_store_path: tempdir.path().join("missing-event-store.sqlite"), + outbox_path: tempdir.path().join("missing-outbox.sqlite"), + manifest_path: tempdir.path().join(BACKUP_MANIFEST_FILE), + manifest: manifest(), + verification: verification(true, true), + }; + let staging_paths = RadrootsSdkStoragePaths { + event_store_path: tempdir.path().join("staging-event-store.sqlite"), + outbox_path: tempdir.path().join("staging-outbox.sqlite"), + }; + assert!( + io_message(copy_restore_archive_to_staging(&missing_archive, &staging_paths).await) + .contains("restore event store copy failed") + ); + let partial_archive = RestoreArchive { + event_store_path: staging_paths.event_store_path.clone(), + ..missing_archive.clone() + }; + fs::write(&partial_archive.event_store_path, b"not sqlite").expect("partial event store"); + assert!( + io_message(copy_restore_archive_to_staging(&partial_archive, &staging_paths).await) + .contains("restore outbox copy failed") + ); + let corrupt_archive = RestoreArchive { + event_store_path: tempdir.path().join("corrupt-event-store.sqlite"), + outbox_path: tempdir.path().join("corrupt-outbox.sqlite"), + ..missing_archive.clone() + }; + fs::write(&corrupt_archive.event_store_path, b"not sqlite").expect("corrupt event store"); + fs::write(&corrupt_archive.outbox_path, b"not sqlite").expect("corrupt outbox"); + assert_event_store_error( + copy_restore_archive_to_staging(&corrupt_archive, &staging_paths).await, + ); + assert!( + invalid_request_message( + restore_archive_to_destination(&missing_archive, Path::new(""), &staging_paths).await, + ) + .contains("parent is required") + ); + + let invalid_outbox_member_source = tempdir.path().join("invalid-outbox-member"); + fs::create_dir(&invalid_outbox_member_source).expect("invalid outbox source"); + fs::write( + invalid_outbox_member_source.join(EVENT_STORE_BACKUP_FILE), + b"not sqlite", + ) + .expect("event store member"); + let mut invalid_outbox_manifest = manifest(); + invalid_outbox_manifest.backup_paths.outbox_path = PathBuf::from("../outside.sqlite"); + write_backup_manifest( + &invalid_outbox_member_source.join(BACKUP_MANIFEST_FILE), + &invalid_outbox_manifest, + ) + .expect("invalid outbox manifest"); + assert!( + invalid_request_message(inspect_restore_archive(invalid_outbox_member_source).await) + .contains("outbox archive path") + ); + + let protected_parent = tempdir.path().join("protected-parent"); + fs::create_dir(&protected_parent).expect("protected parent"); + let protected_destination = protected_parent.join("restore"); + let protected_paths = RadrootsSdkStoragePaths { + event_store_path: protected_destination.join(EVENT_STORE_BACKUP_FILE), + outbox_path: protected_destination.join(OUTBOX_BACKUP_FILE), + }; + set_mode(&protected_parent, 0o500); + let protected_result = + restore_archive_to_destination(&missing_archive, &protected_destination, &protected_paths) + .await; + set_mode(&protected_parent, 0o700); + assert!(!io_message(protected_result).is_empty()); + + let sdk = RadrootsSdk::builder() + .directory_storage(tempdir.path().join("sdk")) + .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_001)) + .build() + .await + .expect("directory sdk"); + let backup_destination = tempdir.path().join("backup"); + sdk.backup(BackupRequest::new(&backup_destination)) + .await + .expect("backup"); + let archive = inspect_restore_archive(backup_destination.clone()) + .await + .expect("archive"); + let public_protected_parent = tempdir.path().join("public-protected-parent"); + fs::create_dir(&public_protected_parent).expect("public protected parent"); + let public_protected_destination = public_protected_parent.join("restore"); + set_mode(&public_protected_parent, 0o500); + let public_protected_result = RadrootsSdk::restore( + RestoreRequest::new(&backup_destination).with_destination(&public_protected_destination), + ) + .await; + set_mode(&public_protected_parent, 0o700); + assert!(!io_message(public_protected_result).is_empty()); + let missing_outbox_paths = RadrootsSdkStoragePaths { + event_store_path: archive.event_store_path.clone(), + outbox_path: tempdir.path().to_path_buf(), + }; + assert!(verify_backup_paths(&missing_outbox_paths).await.is_err()); + + let invalid_destination = tempdir.path().join(nul_path()); + let invalid_paths = RadrootsSdkStoragePaths { + event_store_path: invalid_destination.join(EVENT_STORE_BACKUP_FILE), + outbox_path: invalid_destination.join(OUTBOX_BACKUP_FILE), + }; + let invalid_restore_message = io_message( + restore_archive_to_destination(&archive, &invalid_destination, &invalid_paths).await, + ); + assert!(!invalid_restore_message.is_empty()); + + let existing_destination = tempdir.path().join("existing-restore"); + fs::create_dir(&existing_destination).expect("existing restore"); + fs::write(existing_destination.join("old-file"), b"old").expect("old restore file"); + let existing_paths = + preflight_restore_destination(&archive.source, &existing_destination, true) + .expect("existing preflight"); + restore_archive_to_destination(&archive, &existing_destination, &existing_paths) + .await + .expect("overwrite existing restore"); + assert!(existing_destination.join(EVENT_STORE_BACKUP_FILE).exists()); + assert!(existing_destination.join(OUTBOX_BACKUP_FILE).exists()); + + let mut mismatch_archive = archive.clone(); + mismatch_archive.verification.event_store_events += 1; + let mismatch_destination = tempdir.path().join("mismatch-restore"); + let mismatch_paths = + preflight_restore_destination(&mismatch_archive.source, &mismatch_destination, false) + .expect("mismatch preflight"); + assert!( + invalid_request_message( + restore_archive_to_destination( + &mismatch_archive, + &mismatch_destination, + &mismatch_paths, + ) + .await, + ) + .contains("does not match manifest") + ); + + let populated_sdk = RadrootsSdk::builder() + .directory_storage(tempdir.path().join("populated-sdk")) + .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_002)) + .build() + .await + .expect("populated sdk"); + populated_sdk + ._event_store + .ingest_event(radroots_event_store::RadrootsEventIngest::new( + radroots_events::RadrootsNostrEvent { + id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(), + author: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + .to_owned(), + created_at: 1_700_000_002, + kind: 1, + tags: Vec::new(), + content: "{}".to_owned(), + sig: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_owned(), + }, + 1_700_000_002_000, + )) + .await + .expect("populated event"); + let populated_backup_destination = tempdir.path().join("populated-backup"); + populated_sdk + .backup(BackupRequest::new(&populated_backup_destination)) + .await + .expect("populated backup"); + let populated_archive = inspect_restore_archive(populated_backup_destination) + .await + .expect("populated archive"); + assert_ne!(archive.verification, populated_archive.verification); + let verification_mismatch_destination = tempdir.path().join("verification-mismatch-restore"); + let wrong_destination_paths = RadrootsSdkStoragePaths { + event_store_path: populated_archive.event_store_path.clone(), + outbox_path: populated_archive.outbox_path.clone(), + }; + assert!( + invalid_request_message( + restore_archive_to_destination( + &archive, + &verification_mismatch_destination, + &wrong_destination_paths, + ) + .await, + ) + .contains("does not match manifest") + ); + assert!(!verification_mismatch_destination.exists()); + + let bad_verify_destination = tempdir.path().join("bad-verify-restore"); + let bad_verify_paths = + preflight_restore_destination(&archive.source, &bad_verify_destination, false) + .expect("bad verify preflight"); + let mut mismatched_verify_paths = bad_verify_paths.clone(); + mismatched_verify_paths.event_store_path = bad_verify_destination.clone(); + mismatched_verify_paths.outbox_path = bad_verify_destination.join(OUTBOX_BACKUP_FILE); + assert!( + restore_archive_to_destination( + &archive, + &bad_verify_destination, + &mismatched_verify_paths, + ) + .await + .is_err() + ); +} diff --git a/crates/sdk/tests/unit/sync_runtime_tests.rs b/crates/sdk/tests/unit/sync_runtime_tests.rs @@ -0,0 +1,371 @@ +use super::{ + PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, PushOutboxRelayOutcomeKind, + SdkRelayAuthPolicy, SyncEventStoreStatus, SyncOutboxStatus, push_event_final_state, + push_event_receipt, push_outbox_claim_token, +}; +use crate::RadrootsSdkError; +use futures::future::BoxFuture; +use radroots_event_store::RadrootsEventStoreStatusSummary; +use radroots_events::ids::RadrootsEventId; +use radroots_outbox::{RadrootsOutboxEventState, RadrootsOutboxStatusSummary}; +use radroots_relay_transport::{ + RadrootsRelayOutcomeKind, RadrootsRelayPublishAdapter, RadrootsRelayPublishReceipt, + RadrootsRelayPublishRelayReceipt, RadrootsRelayPublishRequest, RadrootsRelayTransportError, +}; +use std::collections::BTreeSet; + +struct UnusedPublishAdapter; + +impl RadrootsRelayPublishAdapter for UnusedPublishAdapter { + fn publish<'a>( + &'a self, + _request: RadrootsRelayPublishRequest, + ) -> BoxFuture<'a, Result<Vec<RadrootsRelayPublishRelayReceipt>, RadrootsRelayTransportError>> + { + Box::pin(async { Ok(Vec::new()) }) + } +} + +#[test] +fn push_outbox_claim_tokens_are_unique_under_immediate_generation() { + let mut tokens = BTreeSet::new(); + for _ in 0..1_024 { + let token = push_outbox_claim_token(); + assert!(token.starts_with("radroots-sdk-sync-")); + assert!(tokens.insert(token)); + } +} + +#[test] +fn push_event_receipt_parses_typed_event_id() { + let event_id = "a".repeat(64); + let receipt = push_event_receipt( + 1, + PushOutboxEventState::Published, + relay_publish_receipt(event_id.as_str()).with_relay(), + ); + + assert_eq!( + receipt.event_id, + RadrootsEventId::parse(event_id).expect("event id") + ); + assert_eq!(receipt.relays.len(), 1); + assert!(receipt.relays[0].attempted); +} + +#[test] +#[should_panic(expected = "relay transport publish receipt uses signed event id")] +fn push_event_receipt_panics_on_invalid_internal_event_id() { + let _ = push_event_receipt( + 1, + PushOutboxEventState::Published, + relay_publish_receipt("not-a-valid-event-id"), + ); +} + +#[test] +fn push_event_final_state_follows_publish_quorum_and_retryability() { + let published = relay_publish_receipt("a".repeat(64).as_str()) + .with_quorum_met(true) + .with_retryable_count(1); + assert_eq!( + push_event_final_state(&published), + PushOutboxEventState::Published + ); + + let retryable = relay_publish_receipt("b".repeat(64).as_str()).with_retryable_count(1); + assert_eq!( + push_event_final_state(&retryable), + PushOutboxEventState::PublishRetryable + ); + + let terminal = relay_publish_receipt("c".repeat(64).as_str()); + assert_eq!( + push_event_final_state(&terminal), + PushOutboxEventState::FailedTerminal + ); +} + +#[test] +fn auth_policy_defaults_and_outbox_state_mappings_cover_all_public_states() { + assert_eq!( + SdkRelayAuthPolicy::default(), + SdkRelayAuthPolicy::DetectOnly + ); + + assert_eq!( + PushOutboxEventState::from(RadrootsOutboxEventState::DraftQueued), + PushOutboxEventState::DraftQueued + ); + assert_eq!( + PushOutboxEventState::from(RadrootsOutboxEventState::Signing), + PushOutboxEventState::Signing + ); + assert_eq!( + PushOutboxEventState::from(RadrootsOutboxEventState::Signed), + PushOutboxEventState::Signed + ); + assert_eq!( + PushOutboxEventState::from(RadrootsOutboxEventState::Publishing), + PushOutboxEventState::Publishing + ); + assert_eq!( + PushOutboxEventState::from(RadrootsOutboxEventState::Published), + PushOutboxEventState::Published + ); + assert_eq!( + PushOutboxEventState::from(RadrootsOutboxEventState::SignRetryable), + PushOutboxEventState::SignRetryable + ); + assert_eq!( + PushOutboxEventState::from(RadrootsOutboxEventState::PublishRetryable), + PushOutboxEventState::PublishRetryable + ); + assert_eq!( + PushOutboxEventState::from(RadrootsOutboxEventState::FailedTerminal), + PushOutboxEventState::FailedTerminal + ); + assert_eq!( + PushOutboxEventState::from(RadrootsOutboxEventState::Cancelled), + PushOutboxEventState::Cancelled + ); + + let mut receipt = PushOutboxReceipt::default(); + receipt.push_event(push_receipt(PushOutboxEventState::Published)); + receipt.push_event(push_receipt(PushOutboxEventState::PublishRetryable)); + receipt.push_event(push_receipt(PushOutboxEventState::FailedTerminal)); + receipt.push_event(push_receipt(PushOutboxEventState::Cancelled)); + assert_eq!(receipt.attempted_events, 4); + assert_eq!(receipt.terminal_events, 1); + assert_eq!(receipt.published_events, 1); + assert_eq!(receipt.retryable_events, 1); +} + +#[test] +fn sync_status_summary_conversions_preserve_all_fields() { + let event_summary = RadrootsEventStoreStatusSummary { + total_events: 11, + projection_eligible_events: 7, + relay_observations: 3, + last_event_seq: Some(9), + last_event_updated_at_ms: Some(1_700_000_000_000), + }; + let event_status = SyncEventStoreStatus::from(event_summary); + assert_eq!(event_status.total_events, 11); + assert_eq!(event_status.projection_eligible_events, 7); + assert_eq!(event_status.relay_observations, 3); + assert_eq!(event_status.last_event_seq, Some(9)); + assert_eq!( + event_status.last_event_updated_at_ms, + Some(1_700_000_000_000) + ); + + let outbox_summary = RadrootsOutboxStatusSummary { + total_events: 13, + pending_events: 5, + retryable_events: 4, + terminal_events: 2, + failed_terminal_events: 1, + ready_signed_events: 6, + publishing_events: 8, + last_attempt_at_ms: Some(1_700_000_000_001), + last_error: Some("relay offline".to_owned()), + }; + let outbox_status = SyncOutboxStatus::from(outbox_summary); + assert_eq!(outbox_status.total_events, 13); + assert_eq!(outbox_status.pending_events, 5); + assert_eq!(outbox_status.retryable_events, 4); + assert_eq!(outbox_status.terminal_events, 2); + assert_eq!(outbox_status.failed_terminal_events, 1); + assert_eq!(outbox_status.ready_signed_events, 6); + assert_eq!(outbox_status.publishing_events, 8); + assert_eq!(outbox_status.last_attempt_at_ms, Some(1_700_000_000_001)); + assert_eq!(outbox_status.last_error.as_deref(), Some("relay offline")); +} + +#[test] +fn push_outbox_request_builders_validate_all_bounds() { + let request = super::PushOutboxRequest::new() + .with_limit(2) + .republish_accepted_relays(true) + .with_relay_url_policy(crate::SdkRelayUrlPolicy::Localhost) + .with_auth_policy(SdkRelayAuthPolicy::DetectOnly) + .with_claim_ttl_ms(7) + .with_next_attempt_delay_ms(11); + assert_eq!(request.limit, 2); + assert!(request.republish_accepted_relays); + request.validate().expect("valid request"); + + assert!(matches!( + super::PushOutboxRequest::new().with_limit(0).validate(), + Err(RadrootsSdkError::InvalidRequest { message }) if message.contains("limit") + )); + assert!(matches!( + super::PushOutboxRequest::new() + .with_limit(super::PUSH_OUTBOX_MAX_LIMIT + 1) + .validate(), + Err(RadrootsSdkError::InvalidRequest { message }) if message.contains("limit") + )); + assert!(matches!( + super::PushOutboxRequest::new() + .with_claim_ttl_ms(0) + .validate(), + Err(RadrootsSdkError::InvalidRequest { message }) if message.contains("TTL") + )); + assert!(matches!( + super::PushOutboxRequest::new() + .with_next_attempt_delay_ms(0) + .validate(), + Err(RadrootsSdkError::InvalidRequest { message }) if message.contains("next attempt") + )); +} + +#[test] +fn relay_outcome_kind_mapping_covers_all_transport_outcomes() { + assert_eq!( + PushOutboxRelayOutcomeKind::from(RadrootsRelayOutcomeKind::Accepted), + PushOutboxRelayOutcomeKind::Accepted + ); + assert_eq!( + PushOutboxRelayOutcomeKind::from(RadrootsRelayOutcomeKind::DuplicateAccepted), + PushOutboxRelayOutcomeKind::DuplicateAccepted + ); + assert_eq!( + PushOutboxRelayOutcomeKind::from(RadrootsRelayOutcomeKind::Blocked), + PushOutboxRelayOutcomeKind::Blocked + ); + assert_eq!( + PushOutboxRelayOutcomeKind::from(RadrootsRelayOutcomeKind::RateLimited), + PushOutboxRelayOutcomeKind::RateLimited + ); + assert_eq!( + PushOutboxRelayOutcomeKind::from(RadrootsRelayOutcomeKind::Invalid), + PushOutboxRelayOutcomeKind::Invalid + ); + assert_eq!( + PushOutboxRelayOutcomeKind::from(RadrootsRelayOutcomeKind::PowRequired), + PushOutboxRelayOutcomeKind::PowRequired + ); + assert_eq!( + PushOutboxRelayOutcomeKind::from(RadrootsRelayOutcomeKind::Restricted), + PushOutboxRelayOutcomeKind::Restricted + ); + assert_eq!( + PushOutboxRelayOutcomeKind::from(RadrootsRelayOutcomeKind::AuthRequired), + PushOutboxRelayOutcomeKind::AuthRequired + ); + assert_eq!( + PushOutboxRelayOutcomeKind::from(RadrootsRelayOutcomeKind::Error), + PushOutboxRelayOutcomeKind::Error + ); + assert_eq!( + PushOutboxRelayOutcomeKind::from(RadrootsRelayOutcomeKind::Timeout), + PushOutboxRelayOutcomeKind::Timeout + ); + assert_eq!( + PushOutboxRelayOutcomeKind::from(RadrootsRelayOutcomeKind::ConnectionFailed), + PushOutboxRelayOutcomeKind::ConnectionFailed + ); + assert_eq!( + PushOutboxRelayOutcomeKind::from(RadrootsRelayOutcomeKind::Unknown), + PushOutboxRelayOutcomeKind::Unknown + ); +} + +#[tokio::test] +async fn sync_status_maps_closed_store_errors() { + let event_store_closed = crate::RadrootsSdk::builder().build().await.expect("sdk"); + event_store_closed._event_store.pool().close().await; + assert!(matches!( + event_store_closed + .sync() + .status(super::SyncStatusRequest::new()) + .await, + Err(RadrootsSdkError::EventStore { .. }) + )); + + let outbox_closed = crate::RadrootsSdk::builder().build().await.expect("sdk"); + outbox_closed._outbox.pool().close().await; + assert!(matches!( + outbox_closed + .sync() + .status(super::SyncStatusRequest::new()) + .await, + Err(RadrootsSdkError::Outbox { .. }) + )); +} + +#[tokio::test] +async fn sync_runtime_reports_clock_errors_before_store_or_relay_work() { + let sdk = crate::RadrootsSdk::builder() + .clock(crate::RadrootsSdkClock::BeforeUnixEpoch) + .build() + .await + .expect("sdk"); + assert!(matches!( + sdk.sync().status(super::SyncStatusRequest::new()).await, + Err(RadrootsSdkError::ClockBeforeUnixEpoch) + )); + assert!(matches!( + sdk.sync() + .push_outbox_with_adapter(&UnusedPublishAdapter, super::PushOutboxRequest::new()) + .await, + Err(RadrootsSdkError::ClockBeforeUnixEpoch) + )); +} + +fn relay_publish_receipt(event_id: &str) -> RadrootsRelayPublishReceipt { + RadrootsRelayPublishReceipt { + event_id: event_id.to_owned(), + attempted_count: 0, + accepted_count: 0, + retryable_count: 0, + terminal_count: 0, + quorum: 0, + quorum_met: false, + relays: Vec::new(), + } +} + +trait RelayReceiptFixture { + fn with_relay(self) -> Self; + fn with_quorum_met(self, quorum_met: bool) -> Self; + fn with_retryable_count(self, retryable_count: usize) -> Self; +} + +impl RelayReceiptFixture for RadrootsRelayPublishReceipt { + fn with_relay(mut self) -> Self { + self.relays.push( + radroots_relay_transport::RadrootsRelayPublishRelayReceipt::attempted( + "wss://relay.example.com", + radroots_relay_transport::RadrootsRelayOutcome::accepted(), + ), + ); + self + } + + fn with_quorum_met(mut self, quorum_met: bool) -> Self { + self.quorum_met = quorum_met; + self + } + + fn with_retryable_count(mut self, retryable_count: usize) -> Self { + self.retryable_count = retryable_count; + self + } +} + +fn push_receipt(final_state: PushOutboxEventState) -> PushOutboxEventReceipt { + PushOutboxEventReceipt { + event_id: RadrootsEventId::parse("a".repeat(64)).expect("event id"), + outbox_event_id: 1, + final_state, + attempted_count: 0, + accepted_count: 0, + retryable_count: 0, + terminal_count: 0, + quorum: 0, + quorum_met: false, + relays: Vec::new(), + } +} diff --git a/crates/sdk/tests/unit/workflow_runtime_tests.rs b/crates/sdk/tests/unit/workflow_runtime_tests.rs @@ -0,0 +1,214 @@ +use super::*; +use radroots_authority::{RadrootsSignerError, RadrootsSignerIdentity}; +use radroots_events::contract::RadrootsActorRole; +use radroots_events::draft::RadrootsSignedNostrEvent; +use radroots_events::kinds::KIND_FARM; +use radroots_events_codec::wire::{WireEventParts, to_frozen_draft}; +use radroots_nostr::prelude::{ + RadrootsNostrKeys, RadrootsNostrSecretKey, radroots_nostr_sign_frozen_draft, +}; + +const FARMER_SECRET_KEY_HEX: &str = + "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; +const FARMER_PUBLIC_KEY_HEX: &str = + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; + +struct WorkflowSigner { + identity: RadrootsSignerIdentity, + keys: RadrootsNostrKeys, +} + +impl WorkflowSigner { + fn new() -> Self { + let secret_key = + RadrootsNostrSecretKey::from_hex(FARMER_SECRET_KEY_HEX).expect("secret key"); + let keys = RadrootsNostrKeys::new(secret_key); + Self { + identity: RadrootsSignerIdentity::new(FARMER_PUBLIC_KEY_HEX).expect("identity"), + keys, + } + } +} + +impl RadrootsEventSigner for WorkflowSigner { + fn pubkey(&self) -> &radroots_events::ids::RadrootsPublicKey { + self.identity.pubkey() + } + + fn sign_frozen_draft( + &self, + draft: &RadrootsFrozenEventDraft, + ) -> Result<RadrootsSignedNostrEvent, RadrootsSignerError> { + radroots_nostr_sign_frozen_draft(&self.keys, draft).map_err(|error| { + RadrootsSignerError::SigningFailed { + message: error.to_string(), + } + }) + } +} + +fn frozen_draft_for(pubkey: &str) -> RadrootsFrozenEventDraft { + to_frozen_draft( + WireEventParts { + kind: KIND_FARM, + content: "{}".to_owned(), + tags: vec![vec!["d".to_owned(), "test".to_owned()]], + }, + "radroots.farm.profile.v1", + pubkey, + 1_700_000_000, + ) + .expect("frozen draft") +} + +fn frozen_draft() -> RadrootsFrozenEventDraft { + frozen_draft_for("a".repeat(64).as_str()) +} + +fn signed_event() -> RadrootsSignedNostrEvent { + RadrootsSignedNostrEvent { + id: "b".repeat(64), + pubkey: "a".repeat(64), + created_at: 1_700_000_000, + kind: 1, + tags: vec![vec!["d".to_owned(), "test".to_owned()]], + content: "{}".to_owned(), + sig: "c".repeat(128), + raw_json: "{}".to_owned(), + } +} + +#[test] +fn workflow_digest_and_event_helpers_cover_error_and_input_paths() { + assert_eq!(digest_prefix("abcdef1234567890"), "abcdef123456"); + assert_eq!( + parse_event_id("b".repeat(64).as_str(), "event id").expect("event id"), + RadrootsEventId::parse("b".repeat(64)).expect("event id") + ); + assert!(matches!( + parse_event_id("not-an-event-id", "signed event id"), + Err(RadrootsSdkError::InvalidRequest { message }) + if message.contains("signed event id is invalid") + )); + + let draft = frozen_draft(); + let digest = outbox_idempotency_digest_prefix( + "workflow.test.v1", + &draft, + &["wss://relay.example.com".to_owned()], + ); + assert_eq!(digest.len(), 12); + + let signed = signed_event(); + let event = event_from_signed(&signed); + assert_eq!(event.id, signed.id); + assert_eq!(event.author, signed.pubkey); + + let idempotency_key = SdkIdempotencyKey::new("workflow-idempotency").expect("idempotency"); + let input = signed_outbox_input( + "workflow.test.v1", + &draft, + signed_event(), + vec!["wss://relay.example.com".to_owned()], + idempotency_key, + true, + 1_700_000_000_000, + ); + assert_eq!(input.operation_kind, "workflow.test.v1"); + assert_eq!( + input.target_relays, + vec!["wss://relay.example.com".to_owned()] + ); + assert!(input.event_store_inserted); +} + +#[tokio::test] +async fn enqueue_signed_workflow_reports_partial_mutation_when_outbox_fails() { + let sdk = crate::RadrootsSdk::builder() + .relay_url("wss://relay.example.com") + .build() + .await + .expect("sdk"); + sdk._outbox.pool().close().await; + let actor = RadrootsActorContext::test(FARMER_PUBLIC_KEY_HEX, [RadrootsActorRole::Farmer]) + .expect("actor"); + let draft = frozen_draft_for(FARMER_PUBLIC_KEY_HEX); + let request = SdkWorkflowEnqueueRequest { + operation_kind: "workflow.test.v1", + actor: &actor, + frozen_draft: &draft, + target_relays: SdkRelayTargetPolicy::UseConfiguredRelays, + idempotency_key: None, + }; + + let error = match enqueue_signed_workflow(&sdk, request, &WorkflowSigner::new()).await { + Err(error) => error, + Ok(_) => panic!("expected closed outbox error"), + }; + + match error { + RadrootsSdkError::PartialLocalMutation(partial) => { + assert!(partial.stored); + assert!(!partial.queued); + assert_eq!(partial.operation_kind, "workflow.test.v1"); + assert_eq!( + partial.failure, + crate::RadrootsSdkPartialLocalMutationFailure::OutboxEnqueue + ); + } + other => panic!("unexpected workflow error: {other:?}"), + } +} + +#[tokio::test] +async fn enqueue_signed_workflow_reports_store_failures() { + let actor = RadrootsActorContext::test(FARMER_PUBLIC_KEY_HEX, [RadrootsActorRole::Farmer]) + .expect("actor"); + let draft = frozen_draft_for(FARMER_PUBLIC_KEY_HEX); + let closed_store_sdk = crate::RadrootsSdk::builder() + .relay_url("wss://relay.example.com") + .build() + .await + .expect("sdk"); + closed_store_sdk._event_store.pool().close().await; + let store_failure_request = SdkWorkflowEnqueueRequest { + operation_kind: "workflow.test.v1", + actor: &actor, + frozen_draft: &draft, + target_relays: SdkRelayTargetPolicy::UseConfiguredRelays, + idempotency_key: None, + }; + assert!(matches!( + enqueue_signed_workflow( + &closed_store_sdk, + store_failure_request, + &WorkflowSigner::new() + ) + .await, + Err(RadrootsSdkError::EventStore { .. }) + )); +} + +#[tokio::test] +async fn enqueue_signed_workflow_reports_clock_failures() { + let sdk = crate::RadrootsSdk::builder() + .clock(crate::RadrootsSdkClock::BeforeUnixEpoch) + .relay_url("wss://relay.example.com") + .build() + .await + .expect("sdk"); + let actor = RadrootsActorContext::test(FARMER_PUBLIC_KEY_HEX, [RadrootsActorRole::Farmer]) + .expect("actor"); + let draft = frozen_draft_for(FARMER_PUBLIC_KEY_HEX); + let request = SdkWorkflowEnqueueRequest { + operation_kind: "workflow.test.v1", + actor: &actor, + frozen_draft: &draft, + target_relays: SdkRelayTargetPolicy::UseConfiguredRelays, + idempotency_key: None, + }; + assert!(matches!( + enqueue_signed_workflow(&sdk, request, &WorkflowSigner::new()).await, + Err(RadrootsSdkError::ClockBeforeUnixEpoch) + )); +}