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:
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)
+ ));
+}