sdk

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

commit 010b90e6a19b30b257ffe7ebb91f284f387ec185
parent aac11ecc400624d8789c1dc99cc9fd5681f8a7e7
Author: triesap <tyson@radroots.org>
Date:   Sat, 27 Jun 2026 01:19:04 +0000

sdk: harden farm location coverage

- cover private farm location DTOs, store transitions, and GeoNames error mapping

- construct NIP-46 signer requests through typed nostr events

- exercise workflow, radrootsd, idempotency, backup, and market receipt edges

Diffstat:
Mcrates/sdk/Cargo.toml | 3++-
Mcrates/sdk/src/adapters/radrootsd.rs | 9+++++----
Mcrates/sdk/src/signer_provider.rs | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mcrates/sdk/src/sync_runtime.rs | 7++++++-
Mcrates/sdk/tests/farms_runtime.rs | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/sdk/tests/unit/adapters_radrootsd_tests.rs | 34++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/unit/error_tests.rs | 17++++++++++++++---
Mcrates/sdk/tests/unit/farms_runtime_tests.rs | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/unit/idempotency_tests.rs | 15+++++++++++++++
Mcrates/sdk/tests/unit/market_runtime_tests.rs | 21+++++++++++++++++++++
Mcrates/sdk/tests/unit/private_store_tests.rs | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/sdk/tests/unit/runtime_tests.rs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/unit/signer_provider_tests.rs | 63++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/tests/unit/sync_runtime_tests.rs | 16++++++++--------
Mcrates/sdk/tests/unit/workflow_runtime_tests.rs | 118++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
15 files changed, 892 insertions(+), 57 deletions(-)

diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml @@ -29,7 +29,7 @@ identity-models = [ "radroots_identity/std", ] identity-storage = ["identity-models", "std", "radroots_identity/std"] -signing = ["dep:radroots_nostr", "nostr"] +signing = ["dep:nostr", "dep:radroots_nostr", "nostr"] relay-client = ["signing", "std", "serde_json", "radroots_nostr/client"] radrootsd-proxy = [ "std", @@ -127,6 +127,7 @@ reqwest = { workspace = true, optional = true, default-features = false, feature "rustls-tls", ] } hex = { workspace = true, optional = true } +nostr = { workspace = true, optional = true } serde = { workspace = true, optional = true, default-features = false, features = [ "derive", "alloc", diff --git a/crates/sdk/src/adapters/radrootsd.rs b/crates/sdk/src/adapters/radrootsd.rs @@ -182,9 +182,9 @@ impl fmt::Display for RadrootsdError { Self::InvalidAuthHeader(value) => { write!(f, "invalid radrootsd bearer token header: {value}") } - Self::InvalidRequest(value) | Self::Http(value) | Self::MalformedResponse(value) => { - f.write_str(value) - } + Self::InvalidRequest(value) => f.write_str(value), + Self::Http(value) => f.write_str(value), + Self::MalformedResponse(value) => f.write_str(value), Self::JsonRpc { code, message } => { write!(f, "radrootsd jsonrpc failed {code}: {message}") } @@ -230,7 +230,8 @@ fn auth_headers(auth: &RadrootsdAuth) -> Result<HeaderMap, RadrootsdError> { match auth { RadrootsdAuth::None => Ok(headers), RadrootsdAuth::BearerToken(token) => { - let value = HeaderValue::from_str(format!("Bearer {token}").as_str()) + let header = format!("Bearer {token}"); + let value = HeaderValue::from_str(header.as_str()) .map_err(|err| RadrootsdError::InvalidAuthHeader(err.to_string()))?; headers.insert(AUTHORIZATION, value); Ok(headers) diff --git a/crates/sdk/src/signer_provider.rs b/crates/sdk/src/signer_provider.rs @@ -1,4 +1,5 @@ use crate::RadrootsSdkError; +use nostr::{JsonUtil, Kind, PublicKey, Tag, Tags, Timestamp, UnsignedEvent}; #[cfg(feature = "local-signer")] use radroots_authority::RadrootsLocalEventSigner; use radroots_authority::{ @@ -19,7 +20,6 @@ use radroots_nostr_connect::prelude::{ RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest, RadrootsNostrConnectResponse, execute_request_with_transport, }; -use serde_json::json; use std::sync::Arc; use std::time::Duration; use tokio::time::timeout; @@ -546,32 +546,68 @@ impl RadrootsNostrConnectClientTransport for RadrootsSdkNip46TransportAdapter<'_ fn sign_event_request_from_frozen_draft( draft: &RadrootsFrozenEventDraft, ) -> Result<RadrootsNostrConnectRequest, RadrootsSdkError> { - let unsigned_event = serde_json::from_value(json!({ - "pubkey": draft.expected_pubkey, - "created_at": draft.created_at, - "kind": draft.kind, - "tags": draft.tags, - "content": draft.content, - })) - .map_err(|error| RadrootsSdkError::SignerProtocol { - mode: RadrootsSdkSignerMode::MycNip46.as_str().to_owned(), - reason: format!("failed to convert frozen draft to NIP-46 unsigned event: {error}"), - })?; + let public_key = nip46_unsigned_event_pubkey(draft)?; + let kind = nip46_unsigned_event_kind(draft)?; + let tags = nip46_unsigned_event_tags(draft)?; + let unsigned_event = UnsignedEvent { + id: None, + pubkey: public_key, + created_at: Timestamp::from_secs(u64::from(draft.created_at)), + kind, + tags: Tags::from_list(tags), + content: draft.content.clone(), + }; Ok(RadrootsNostrConnectRequest::SignEvent(unsigned_event)) } +fn nip46_unsigned_event_pubkey( + draft: &RadrootsFrozenEventDraft, +) -> Result<PublicKey, RadrootsSdkError> { + PublicKey::parse(draft.expected_pubkey.as_str()).map_err(|error| { + nip46_sign_event_protocol_error(format!( + "failed to parse frozen draft pubkey for NIP-46 unsigned event: {error}" + )) + }) +} + +fn nip46_unsigned_event_kind(draft: &RadrootsFrozenEventDraft) -> Result<Kind, RadrootsSdkError> { + let kind = u16::try_from(draft.kind).map_err(|error| { + nip46_sign_event_protocol_error(format!( + "failed to convert frozen draft kind to NIP-46 unsigned event: {error}" + )) + })?; + Ok(Kind::from_u16(kind)) +} + +fn nip46_unsigned_event_tags( + draft: &RadrootsFrozenEventDraft, +) -> Result<Vec<Tag>, RadrootsSdkError> { + let mut tags = Vec::with_capacity(draft.tags.len()); + for raw_tag in &draft.tags { + let tag = Tag::parse(raw_tag.clone()).map_err(|error| { + nip46_sign_event_protocol_error(format!( + "failed to convert frozen draft tags to NIP-46 unsigned event: {error}" + )) + })?; + tags.push(tag); + } + Ok(tags) +} + +fn nip46_sign_event_protocol_error(reason: String) -> RadrootsSdkError { + RadrootsSdkError::SignerProtocol { + mode: RadrootsSdkSignerMode::MycNip46.as_str().to_owned(), + reason, + } +} + fn signed_event_from_nip46_response( operation_kind: &str, response: RadrootsNostrConnectResponse, ) -> Result<RadrootsSignedNostrEvent, RadrootsSdkError> { match response { RadrootsNostrConnectResponse::SignedEvent(event) => { - let raw_json = serde_json::to_string(&event).map_err(|error| { - RadrootsSdkError::SignerProtocol { - mode: RadrootsSdkSignerMode::MycNip46.as_str().to_owned(), - reason: format!("failed to serialize remote signed event: {error}"), - } - })?; + let raw_json = event.as_json(); RadrootsSignedNostrEvent::from_event(radroots_event_from_nostr(&event), raw_json) .map_err(|error| RadrootsSdkError::SignerProtocol { mode: RadrootsSdkSignerMode::MycNip46.as_str().to_owned(), diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs @@ -277,7 +277,12 @@ impl PushOutboxRequest { } fn validate(&self) -> Result<(), RadrootsSdkError> { - if self.limit == 0 || self.limit > PUSH_OUTBOX_MAX_LIMIT { + if self.limit == 0 { + return Err(RadrootsSdkError::InvalidRequest { + message: format!("push_outbox limit must be between 1 and {PUSH_OUTBOX_MAX_LIMIT}"), + }); + } + if self.limit > PUSH_OUTBOX_MAX_LIMIT { return Err(RadrootsSdkError::InvalidRequest { message: format!("push_outbox limit must be between 1 and {PUSH_OUTBOX_MAX_LIMIT}"), }); diff --git a/crates/sdk/tests/farms_runtime.rs b/crates/sdk/tests/farms_runtime.rs @@ -15,13 +15,14 @@ use radroots_outbox::{RadrootsOutbox, RadrootsOutboxEventState}; use radroots_relay_transport::RadrootsMockRelayPublishAdapter; use radroots_sdk::{ FARM_PUBLISH_OPERATION_KIND, FarmEnqueuePublishRequest, FarmPreparePublishRequest, - FarmPrivateLocationInput, FarmPrivateLocationReceipt, FarmPrivateLocationSetRequest, + FarmPrivateLocationClearRequest, FarmPrivateLocationInput, FarmPrivateLocationLookupCandidate, + FarmPrivateLocationLookupReceipt, FarmPrivateLocationReceipt, FarmPrivateLocationSetRequest, FarmPrivateLocationSetResult, FarmPrivateLocationUpsertRequest, Geocoder, GeocoderLocalityQuery, PushOutboxEventState, PushOutboxRelayOutcomeKind, PushOutboxRequest, RadrootsClient, RadrootsSdkError, RadrootsSdkErrorClass, RadrootsSdkGeoNamesErrorKind, RadrootsSdkPartialLocalMutationFailure, RadrootsSdkRecoveryAction, RadrootsSdkTimestamp, - SdkExactLocation, SdkIdempotencyKey, SdkMutationState, SdkRelayTargetPolicy, SdkRelayTargetSet, - SdkRelayUrlPolicy, StorageStatusRequest, + SdkExactLocation, SdkIdempotencyKey, SdkMutationState, SdkPublicLocality, SdkRelayTargetPolicy, + SdkRelayTargetSet, SdkRelayUrlPolicy, StorageStatusRequest, }; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; @@ -957,6 +958,199 @@ async fn farm_runtime_dtos_serialize_deterministically() { serde_json::json!({ "value": "<redacted>", "len": 23 }) ); + let private_upsert = FarmPrivateLocationUpsertRequest::new( + farmer_actor(), + FARM_C_D_TAG, + SdkExactLocation::new(48.4359, -123.35155), + ) + .with_label("north gate") + .with_updated_at(created_at); + assert_eq!( + serde_json::to_value(&private_upsert).expect("private upsert json"), + serde_json::json!({ + "actor": { + "pubkey": FARMER, + "roles": ["farmer"], + "account_id": null, + "source": "test" + }, + "farm_d_tag": FARM_C_D_TAG, + "exact_location": { + "latitude": 48.4359, + "longitude": -123.35155 + }, + "label": "north gate", + "updated_at": 1_700_000_123 + }) + ); + assert_struct_serialize_error_paths(&private_upsert, 5); + + let private_set = FarmPrivateLocationSetRequest::exact( + farmer_actor(), + FARM_D_D_TAG, + SdkExactLocation::new(48.9, -123.4), + ) + .with_label("identifier gate") + .with_updated_at(created_at); + assert_eq!( + serde_json::to_value(&private_set).expect("private set json"), + serde_json::json!({ + "actor": { + "pubkey": FARMER, + "roles": ["farmer"], + "account_id": null, + "source": "test" + }, + "farm_d_tag": FARM_D_D_TAG, + "input": { + "kind": "exact", + "value": { + "latitude": 48.9, + "longitude": -123.4 + } + }, + "label": "identifier gate", + "updated_at": 1_700_000_123 + }) + ); + assert_struct_serialize_error_paths(&private_set, 5); + + assert_eq!( + serde_json::to_value(FarmPrivateLocationSetRequest::query( + farmer_actor(), + FARM_D_D_TAG, + "Shared Market, BC, CA" + )) + .expect("private query set json")["input"], + serde_json::json!({ + "kind": "locality", + "value": { + "input": { + "Query": "Shared Market, BC, CA" + }, + "limit": 10 + } + }) + ); + assert_eq!( + serde_json::to_value(FarmPrivateLocationSetRequest::geonames_id( + farmer_actor(), + FARM_D_D_TAG, + 3004 + )) + .expect("private geonames id set json")["input"], + serde_json::json!({ + "kind": "locality", + "value": { + "input": { + "FeatureId": 3004 + }, + "limit": 10 + } + }) + ); + + let private_clear = FarmPrivateLocationClearRequest::new(farmer_actor(), FARM_E_D_TAG); + assert_eq!( + serde_json::to_value(&private_clear).expect("private clear json"), + serde_json::json!({ + "actor": { + "pubkey": FARMER, + "roles": ["farmer"], + "account_id": null, + "source": "test" + }, + "farm_d_tag": FARM_E_D_TAG + }) + ); + assert_struct_serialize_error_paths(&private_clear, 2); + + let private_receipt = FarmPrivateLocationReceipt { + farm_addr: farm_addr(&farmer_actor(), FARM_D_D_TAG), + farm_pubkey: FARMER.to_owned(), + farm_d_tag: FARM_D_D_TAG.to_owned(), + label: Some("identifier gate".to_owned()), + exact_location: SdkExactLocation::new(48.9, -123.4), + public_locality: SdkPublicLocality { + primary: "Identifier Grove, British Columbia, Canada".to_owned(), + city: Some("Identifier Grove".to_owned()), + region: Some("British Columbia".to_owned()), + country: Some("Canada".to_owned()), + geohash5: "c28rn".to_owned(), + }, + geonames_feature_id: Some(3004), + geonames_country_id: Some("CA".to_owned()), + updated_at_ms: 1_700_000_123_000, + }; + let private_receipt_json = + serde_json::to_value(&private_receipt).expect("private receipt json"); + assert_struct_serialize_error_paths(&private_receipt, 9); + assert_struct_serialize_error_paths(&private_receipt.exact_location, 2); + assert_struct_serialize_error_paths(&private_receipt.public_locality, 5); + assert_eq!(private_receipt_json["updated_at_ms"], 1_700_000_123_000_i64); + let listing_location = private_receipt.public_locality.to_listing_public_location(); + assert_eq!( + listing_location.primary, + "Identifier Grove, British Columbia, Canada" + ); + assert_eq!(listing_location.city.as_deref(), Some("Identifier Grove")); + assert_eq!(listing_location.region.as_deref(), Some("British Columbia")); + assert_eq!(listing_location.country.as_deref(), Some("Canada")); + assert_eq!(listing_location.geohash, "c28rn"); + let farm_location = private_receipt.public_locality.to_farm_public_location(); + assert_eq!( + farm_location.primary, + "Identifier Grove, British Columbia, Canada" + ); + assert_eq!(farm_location.city.as_deref(), Some("Identifier Grove")); + assert_eq!(farm_location.region.as_deref(), Some("British Columbia")); + assert_eq!(farm_location.country.as_deref(), Some("Canada")); + assert_eq!(farm_location.geohash, "c28rn"); + assert_eq!( + serde_json::from_value::<FarmPrivateLocationReceipt>(private_receipt_json) + .expect("private receipt round trip"), + private_receipt + ); + + let candidate = FarmPrivateLocationLookupCandidate { + geonames_feature_id: 3002, + geonames_country_id: "CA".to_owned(), + name: "Shared Market".to_owned(), + display_name: "Shared Market, British Columbia, Canada".to_owned(), + exact_location: SdkExactLocation::new(48.7, -123.2), + region: Some("British Columbia".to_owned()), + country: Some("Canada".to_owned()), + }; + assert_struct_serialize_error_paths(&candidate, 7); + let lookup = FarmPrivateLocationLookupReceipt { + farm_addr: farm_addr(&farmer_actor(), FARM_F_D_TAG), + farm_pubkey: FARMER.to_owned(), + farm_d_tag: FARM_F_D_TAG.to_owned(), + input: FarmPrivateLocationInput::query("Shared Market"), + candidates: vec![candidate], + }; + let lookup_json = serde_json::to_value(&lookup).expect("lookup receipt json"); + assert_struct_serialize_error_paths(&lookup, 5); + assert_eq!(lookup_json["candidates"][0]["geonames_feature_id"], 3002); + assert_eq!( + serde_json::from_value::<FarmPrivateLocationLookupReceipt>(lookup_json) + .expect("lookup receipt round trip"), + lookup + ); + for result in [ + FarmPrivateLocationSetResult::Stored(private_receipt), + FarmPrivateLocationSetResult::Ambiguous(lookup.clone()), + FarmPrivateLocationSetResult::NoMatch(FarmPrivateLocationLookupReceipt { + candidates: Vec::new(), + ..lookup + }), + ] { + let value = serde_json::to_value(&result).expect("location set result json"); + let round_trip = serde_json::from_value::<FarmPrivateLocationSetResult>(value) + .expect("location set result round trip"); + assert_eq!(round_trip, result); + } + let receipt = sdk .farms() .enqueue_publish_with_explicit_signer(enqueue_request, &FixtureSigner::new(FARMER)) diff --git a/crates/sdk/tests/unit/adapters_radrootsd_tests.rs b/crates/sdk/tests/unit/adapters_radrootsd_tests.rs @@ -436,6 +436,40 @@ async fn publish_event_posts_publish_proxy_jsonrpc() { } #[tokio::test] +async fn publish_signed_event_posts_typed_proxy_request() { + let (endpoint, handle) = spawn_http_server("200 OK", publish_response_json().as_str()); + let adapter = RadrootsdProxyPublishAdapter::new( + RadrootsdProxyConfig::new(endpoint) + .with_auth(RadrootsdAuth::BearerToken("sdk-token".into())) + .with_request_timeout_ms(7_000), + ); + + let receipt = adapter + .publish_signed_event(RadrootsdProxyPublishRequest { + signed_event: signed_event(), + relays: vec!["wss://relay.example.com".to_owned()], + delivery_policy: PublishDeliveryPolicy::All, + idempotency_key: Some("idem-typed".to_owned()), + timeout_ms: adapter.config().request_timeout_ms, + }) + .await + .expect("typed publish"); + + assert!(receipt.quorum_met); + let recorded = handle.join().expect("server thread"); + assert!( + recorded + .headers + .iter() + .any(|(name, value)| name == "authorization" && value == "Bearer sdk-token") + ); + let body: serde_json::Value = serde_json::from_str(recorded.body.as_str()).expect("body"); + assert_eq!(body["params"]["delivery_policy"]["mode"], "all"); + assert_eq!(body["params"]["idempotency_key"], "idem-typed"); + assert_eq!(body["params"]["timeout_ms"], 7_000); +} + +#[tokio::test] async fn publish_event_http_errors_omit_body_and_token_material() { let body = "{\"error\":\"token-secret content carrots\"}"; let (endpoint, _handle) = spawn_http_server("503 Service Unavailable", body); diff --git a/crates/sdk/tests/unit/error_tests.rs b/crates/sdk/tests/unit/error_tests.rs @@ -4,7 +4,7 @@ use super::{ }; use radroots_authority::RadrootsAuthorityError; use radroots_events::contract::RadrootsActorRole; -use radroots_geocoder::GeocoderError; +use radroots_geocoder::{GeoNamesAssetFetcher, GeoNamesBlockingHttpFetcher, GeocoderError}; #[test] fn partial_local_mutation_constructor_preserves_supplied_error() { @@ -440,16 +440,27 @@ fn sdk_error_contract_methods_cover_representative_classes_and_details() { #[test] fn geonames_error_conversion_maps_source_errors_to_sdk_kinds() { let path = std::path::PathBuf::from("geonames-test.db"); + let download_error = GeoNamesBlockingHttpFetcher + .fetch("not-a-url") + .expect_err("invalid URL download error"); + assert!(matches!( + RadrootsSdkError::from(download_error), + RadrootsSdkError::GeoNames { + kind: RadrootsSdkGeoNamesErrorKind::Download, + .. + } + )); + let cases = vec![ ( GeocoderError::InvalidAssetUrl { - url: "http://assets.radroots.io/geonames.db".to_owned(), + url: "http://assets.radroots.io/geonames-1.0.db".to_owned(), }, RadrootsSdkGeoNamesErrorKind::Configuration, ), ( GeocoderError::InvalidAssetHost { - url: "https://example.com/geonames.db".to_owned(), + url: "https://example.com/geonames-1.0.db".to_owned(), expected_host: "assets.radroots.io".to_owned(), actual_host: "example.com".to_owned(), }, diff --git a/crates/sdk/tests/unit/farms_runtime_tests.rs b/crates/sdk/tests/unit/farms_runtime_tests.rs @@ -190,6 +190,166 @@ fn farm_runtime_request_builders_and_serializers_cover_success_paths() { serde_json::to_value(&try_key).expect("try key json")["idempotency_key"]["len"], "farm-unit-try-key".len() ); + + let private_upsert = FarmPrivateLocationUpsertRequest::new( + farmer_actor(), + FARM_A_D_TAG, + SdkExactLocation::new(12.25, -34.5), + ) + .with_label("unit gate") + .with_updated_at(created_at); + assert_struct_serialize_error_paths(&private_upsert, 5); + let private_upsert_json = serde_json::to_value(&private_upsert).expect("private upsert json"); + assert_eq!(private_upsert_json["label"], "unit gate"); + assert_eq!(private_upsert_json["updated_at"], 1_700_000_321); + + for input in [ + FarmPrivateLocationInput::exact(SdkExactLocation::new(12.25, -34.5)), + FarmPrivateLocationInput::city("Fixture City"), + FarmPrivateLocationInput::query("Fixture City, FX"), + FarmPrivateLocationInput::geonames_id(42), + ] { + let input_json = serde_json::to_value(&input).expect("private input json"); + assert_eq!( + serde_json::from_value::<FarmPrivateLocationInput>(input_json) + .expect("private input round trip"), + input + ); + } + + let private_set = + FarmPrivateLocationSetRequest::query(farmer_actor(), FARM_B_D_TAG, "Fixture City, FX") + .with_label("query gate") + .with_updated_at(created_at); + assert_struct_serialize_error_paths(&private_set, 5); + let private_set_json = serde_json::to_value(&private_set).expect("private set json"); + assert_eq!(private_set_json["label"], "query gate"); + assert_eq!(private_set_json["input"]["kind"], "locality"); + + let private_exact = FarmPrivateLocationSetRequest::exact( + farmer_actor(), + FARM_B_D_TAG, + SdkExactLocation::new(12.25, -34.5), + ); + assert_eq!( + serde_json::to_value(&private_exact).expect("private exact set json")["input"]["kind"], + "exact" + ); + + let private_city = + FarmPrivateLocationSetRequest::city(farmer_actor(), FARM_B_D_TAG, "Fixture City"); + let private_city_json = serde_json::to_value(&private_city).expect("private city set json"); + assert_eq!(private_city_json["input"]["kind"], "locality"); + assert_eq!( + private_city_json["input"]["value"]["input"]["Structured"]["locality"], + "Fixture City" + ); + + let private_geonames = + FarmPrivateLocationSetRequest::geonames_id(farmer_actor(), FARM_B_D_TAG, 42); + assert_eq!( + serde_json::to_value(&private_geonames).expect("private geonames set json")["input"]["value"] + ["input"]["FeatureId"], + 42 + ); + + let private_clear = FarmPrivateLocationClearRequest::new(farmer_actor(), FARM_C_D_TAG); + assert_struct_serialize_error_paths(&private_clear, 2); + assert_eq!( + serde_json::to_value(&private_clear).expect("private clear json")["farm_d_tag"], + FARM_C_D_TAG + ); + + let public_locality = SdkPublicLocality { + primary: "Fixture City, Fixture Region, Fixture Country".to_owned(), + city: Some("Fixture City".to_owned()), + region: Some("Fixture Region".to_owned()), + country: Some("Fixture Country".to_owned()), + geohash5: "e4pmw".to_owned(), + }; + assert_struct_serialize_error_paths(&public_locality, 5); + assert_eq!( + public_locality.to_listing_public_location().geohash, + "e4pmw" + ); + assert_eq!(public_locality.to_farm_public_location().geohash, "e4pmw"); + + let private_receipt = FarmPrivateLocationReceipt { + farm_addr: farm_addr(&farmer_actor(), FARM_B_D_TAG).expect("farm addr"), + farm_pubkey: FARMER.to_owned(), + farm_d_tag: FARM_B_D_TAG.to_owned(), + label: Some("query gate".to_owned()), + exact_location: SdkExactLocation::new(12.25, -34.5), + public_locality, + geonames_feature_id: Some(42), + geonames_country_id: Some("FX".to_owned()), + updated_at_ms: 1_700_000_321_000, + }; + assert_struct_serialize_error_paths(&private_receipt, 9); + assert_struct_serialize_error_paths(&private_receipt.exact_location, 2); + let private_receipt_json = + serde_json::to_value(&private_receipt).expect("private receipt json"); + assert_eq!(private_receipt_json["updated_at_ms"], 1_700_000_321_000_i64); + assert_eq!( + serde_json::from_value::<FarmPrivateLocationReceipt>(private_receipt_json) + .expect("private receipt round trip"), + private_receipt + ); + + let clear_receipt = FarmPrivateLocationClearReceipt { + farm_addr: farm_addr(&farmer_actor(), FARM_C_D_TAG).expect("farm addr"), + cleared: true, + }; + assert_struct_serialize_error_paths(&clear_receipt, 2); + assert_eq!( + serde_json::from_value::<FarmPrivateLocationClearReceipt>( + serde_json::to_value(&clear_receipt).expect("clear receipt json") + ) + .expect("clear receipt round trip"), + clear_receipt + ); + + let candidate = FarmPrivateLocationLookupCandidate { + geonames_feature_id: 42, + geonames_country_id: "FX".to_owned(), + name: "Fixture City".to_owned(), + display_name: "Fixture City, Fixture Region, Fixture Country".to_owned(), + exact_location: SdkExactLocation::new(12.25, -34.5), + region: Some("Fixture Region".to_owned()), + country: Some("Fixture Country".to_owned()), + }; + assert_struct_serialize_error_paths(&candidate, 7); + let lookup = FarmPrivateLocationLookupReceipt { + farm_addr: farm_addr(&farmer_actor(), FARM_B_D_TAG).expect("farm addr"), + farm_pubkey: FARMER.to_owned(), + farm_d_tag: FARM_B_D_TAG.to_owned(), + input: FarmPrivateLocationInput::query("Fixture City, FX"), + candidates: vec![candidate], + }; + assert_struct_serialize_error_paths(&lookup, 5); + assert_eq!( + serde_json::from_value::<FarmPrivateLocationLookupReceipt>( + serde_json::to_value(&lookup).expect("lookup json") + ) + .expect("lookup round trip"), + lookup + ); + + for result in [ + FarmPrivateLocationSetResult::Stored(private_receipt), + FarmPrivateLocationSetResult::Ambiguous(lookup.clone()), + FarmPrivateLocationSetResult::NoMatch(FarmPrivateLocationLookupReceipt { + candidates: Vec::new(), + ..lookup + }), + ] { + let result_json = serde_json::to_value(&result).expect("location result json"); + assert_eq!( + serde_json::from_value::<FarmPrivateLocationSetResult>(result_json) + .expect("location result round trip"), + result + ); + } } #[test] @@ -545,6 +705,19 @@ async fn farm_private_location_default_client_and_lookup_report_store_edges() { .. }) )); + assert!(matches!( + sdk.farms() + .set_private_location(FarmPrivateLocationSetRequest::city( + actor.clone(), + FARM_A_D_TAG, + "Fixture Town", + )) + .await, + Err(RadrootsSdkError::GeoNames { + kind: crate::RadrootsSdkGeoNamesErrorKind::Configuration, + .. + }) + )); let farm_a_addr = farm_addr(&actor, FARM_A_D_TAG).expect("farm addr"); assert_eq!( diff --git a/crates/sdk/tests/unit/idempotency_tests.rs b/crates/sdk/tests/unit/idempotency_tests.rs @@ -13,6 +13,21 @@ fn empty_key_is_rejected_before_redacted_storage() { Err(RadrootsSdkError::InvalidRequest { ref message }) if message == "idempotency key must not be empty" )); + assert!(matches!( + SdkIdempotencyKey::new(" key"), + Err(RadrootsSdkError::InvalidRequest { ref message }) + if message == "idempotency key must not include boundary whitespace" + )); + assert!(matches!( + SdkIdempotencyKey::new("key\nvalue"), + Err(RadrootsSdkError::InvalidRequest { ref message }) + if message == "idempotency key must not contain control characters" + )); + assert!(matches!( + SdkIdempotencyKey::new("k".repeat(super::SDK_IDEMPOTENCY_KEY_MAX_LEN + 1)), + Err(RadrootsSdkError::InvalidRequest { ref message }) + if message.contains("idempotency key must be at most") + )); } #[test] diff --git a/crates/sdk/tests/unit/market_runtime_tests.rs b/crates/sdk/tests/unit/market_runtime_tests.rs @@ -74,6 +74,27 @@ fn listing_projection_row_conversion_validates_stored_identity_columns() { ); } +#[test] +fn market_search_receipt_serializes_absent_optional_locality_fields() { + let mut row = projection_row(); + row.locality_city = None; + row.locality_region = None; + row.locality_country = None; + let search_row = MarketListingSearchRow::try_from_projection_row(row).expect("search row"); + let receipt = MarketSearchReceipt { + source: MarketSearchSource::LocalProjectionFts, + refresh: SyncProjectionRefreshReceipt::default(), + listings: vec![search_row], + }; + + let value = serde_json::to_value(receipt).expect("receipt json"); + + assert_eq!(value["source"], "local_projection_fts"); + assert!(value["listings"][0]["locality_city"].is_null()); + assert!(value["listings"][0]["locality_region"].is_null()); + assert!(value["listings"][0]["locality_country"].is_null()); +} + #[tokio::test] async fn market_search_reports_projection_refresh_errors_before_querying_rows() { let sdk = crate::RadrootsClient::builder() diff --git a/crates/sdk/tests/unit/private_store_tests.rs b/crates/sdk/tests/unit/private_store_tests.rs @@ -3,11 +3,15 @@ use radroots_events::ids::RadrootsAddressableCoordinate; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; fn farm_addr() -> RadrootsAddressableCoordinate { + farm_addr_for("AAAAAAAAAAAAAAAAAAAAAA") +} + +fn farm_addr_for(d_tag: &str) -> RadrootsAddressableCoordinate { RadrootsAddressableCoordinate::parse(format!( "{}:{}:{}", radroots_events::kinds::KIND_FARM, "a".repeat(64), - "AAAAAAAAAAAAAAAAAAAAAA" + d_tag )) .expect("farm addr") } @@ -19,6 +23,25 @@ fn private_store_error_message<T>(result: Result<T, RadrootsSdkError>) -> String } } +fn private_location_record() -> SdkPrivateFarmLocationRecord { + SdkPrivateFarmLocationRecord { + farm_addr: farm_addr(), + farm_pubkey: "a".repeat(64), + farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(), + label: Some("Main pickup point".to_owned()), + latitude: 12.26, + longitude: -34.51, + locality_primary: "Fixture Town".to_owned(), + locality_city: Some("Fixture Town".to_owned()), + locality_region: Some("Fixture Region".to_owned()), + locality_country: Some("Fixture Country".to_owned()), + geohash5: "e4pmw".to_owned(), + geonames_feature_id: Some(1), + geonames_country_id: Some("FX".to_owned()), + updated_at_ms: 1_700_000_123_000, + } +} + #[tokio::test] async fn private_farm_location_row_decode_reports_each_missing_column() { let store = SdkPrivateStore::open_memory().await.expect("private store"); @@ -71,6 +94,106 @@ async fn private_store_file_open_rejects_directory_paths() { } #[tokio::test] +async fn private_store_status_update_delete_and_pragmas_round_trip() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let path = tempdir.path().join("private.sqlite"); + let store = SdkPrivateStore::open_file(&path).await.expect("open store"); + assert_eq!(store.pragma_foreign_keys().await.expect("foreign keys"), 1); + assert_eq!( + store.pragma_busy_timeout().await.expect("busy timeout"), + 5_000 + ); + assert_eq!( + store.pragma_journal_mode().await.expect("journal mode"), + "wal" + ); + assert_eq!( + store + .status_summary() + .await + .expect("empty status") + .farm_private_locations, + 0 + ); + + let record = private_location_record(); + assert_eq!( + store + .farm_location(&record.farm_addr) + .await + .expect("missing lookup"), + None + ); + store + .upsert_farm_location(&record) + .await + .expect("insert private location"); + assert_eq!( + store + .status_summary() + .await + .expect("inserted status") + .farm_private_locations, + 1 + ); + assert_eq!( + store + .farm_location(&record.farm_addr) + .await + .expect("stored lookup"), + Some(record.clone()) + ); + + let mut updated = record.clone(); + updated.label = None; + updated.latitude = 12.5; + updated.longitude = -34.75; + updated.locality_primary = "Updated Town".to_owned(); + updated.locality_city = Some("Updated Town".to_owned()); + updated.geonames_feature_id = Some(2); + updated.updated_at_ms = 1_700_000_124_000; + store + .upsert_farm_location(&updated) + .await + .expect("update private location"); + assert_eq!( + store + .farm_location(&record.farm_addr) + .await + .expect("updated lookup"), + Some(updated.clone()) + ); + + let missing_addr = farm_addr_for("AAAAAAAAAAAAAAAAAAAAAQ"); + assert!( + !store + .delete_farm_location(&missing_addr) + .await + .expect("delete missing") + ); + assert!( + store + .delete_farm_location(&updated.farm_addr) + .await + .expect("delete stored") + ); + assert!( + !store + .delete_farm_location(&updated.farm_addr) + .await + .expect("delete already cleared") + ); + assert_eq!( + store + .status_summary() + .await + .expect("cleared status") + .farm_private_locations, + 0 + ); +} + +#[tokio::test] async fn private_store_file_open_materializes_label_column_for_existing_stores() { let tempdir = tempfile::tempdir().expect("tempdir"); let path = tempdir.path().join("private.sqlite"); @@ -107,22 +230,7 @@ async fn private_store_file_open_materializes_label_column_for_existing_stores() pool.close().await; let store = SdkPrivateStore::open_file(&path).await.expect("open store"); - let record = SdkPrivateFarmLocationRecord { - farm_addr: farm_addr(), - farm_pubkey: "a".repeat(64), - farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(), - label: Some("Main pickup point".to_owned()), - latitude: 12.26, - longitude: -34.51, - locality_primary: "Fixture Town".to_owned(), - locality_city: Some("Fixture Town".to_owned()), - locality_region: Some("Fixture Region".to_owned()), - locality_country: Some("Fixture Country".to_owned()), - geohash5: "e4pmw".to_owned(), - geonames_feature_id: Some(1), - geonames_country_id: Some("FX".to_owned()), - updated_at_ms: 1_700_000_123_000, - }; + let record = private_location_record(); store .upsert_farm_location(&record) .await diff --git a/crates/sdk/tests/unit/runtime_tests.rs b/crates/sdk/tests/unit/runtime_tests.rs @@ -796,6 +796,14 @@ fn restore_destination_preflight_covers_empty_existing_new_and_overlap_paths() { paths.event_store_path, new_destination.join(EVENT_STORE_BACKUP_FILE) ); + let nested_new_destination = parent.join("nested").join("new-destination"); + fs::create_dir(nested_new_destination.parent().expect("nested parent")).expect("nested parent"); + let nested_paths = preflight_restore_destination(&source, &nested_new_destination, false) + .expect("nested new preflight"); + assert_eq!( + nested_paths.private_store_path, + nested_new_destination.join(PRIVATE_STORE_BACKUP_FILE) + ); let relative_destination = PathBuf::from(format!( "relative-restore-{}", system_time_nanos_since_unix_epoch(SystemTime::now()).expect("time") @@ -860,6 +868,17 @@ fn restore_destination_preflight_covers_empty_existing_new_and_overlap_paths() { #[cfg(unix)] { + let symlink_destination = parent.join("symlink-destination"); + std::os::unix::fs::symlink(&empty_directory, &symlink_destination).expect("symlink"); + assert!( + invalid_request_message(preflight_restore_destination( + &source, + &symlink_destination, + true, + )) + .contains("symbolic link") + ); + let socket_parent = tempfile::Builder::new() .prefix("rrsdk") .tempdir_in("/tmp") @@ -1130,6 +1149,46 @@ fn permission_denied_paths_cover_backup_restore_io_edges() { assert!(!io_message(metadata_result).is_empty()); } +#[test] +fn restore_staging_helpers_cover_new_and_existing_destination_installs() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let new_staging = tempdir.path().join("new-staging"); + let new_destination = tempdir.path().join("new-destination"); + let new_previous = tempdir.path().join("new-previous"); + fs::create_dir(&new_staging).expect("new staging"); + + let sidecar = + unique_restore_sidecar_path(tempdir.path(), &new_destination, "staging").expect("sidecar"); + assert_eq!(sidecar.parent(), Some(tempdir.path())); + assert!( + sidecar + .file_name() + .expect("sidecar name") + .to_string_lossy() + .contains("new-destination") + ); + assert!( + !install_restore_staging(&new_staging, &new_destination, &new_previous) + .expect("install new staging") + ); + assert!(new_destination.is_dir()); + assert!(!new_previous.exists()); + + let existing_staging = tempdir.path().join("existing-staging"); + let existing_destination = tempdir.path().join("existing-destination"); + let existing_previous = tempdir.path().join("existing-previous"); + fs::create_dir(&existing_staging).expect("existing staging"); + fs::create_dir(&existing_destination).expect("existing destination"); + fs::write(existing_destination.join("old"), b"old").expect("old destination member"); + + assert!( + install_restore_staging(&existing_staging, &existing_destination, &existing_previous,) + .expect("replace existing staging") + ); + assert!(existing_destination.is_dir()); + assert!(existing_previous.join("old").exists()); +} + #[cfg(unix)] #[tokio::test] async fn sqlite_backup_errors_cover_invalid_paths_and_execute_failures() { diff --git a/crates/sdk/tests/unit/signer_provider_tests.rs b/crates/sdk/tests/unit/signer_provider_tests.rs @@ -7,7 +7,7 @@ use radroots_events_codec::wire::{WireEventParts, to_frozen_draft}; use radroots_nostr::prelude::{RadrootsNostrEvent, RadrootsNostrSecretKey}; use radroots_nostr_connect::prelude::{ RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectClientTarget, RadrootsNostrConnectError, - RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, + RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, }; use std::collections::VecDeque; use std::future; @@ -573,6 +573,25 @@ async fn myc_nip46_provider_signs_and_validates_remote_event() { assert_eq!(receipt.signed_event, signed); assert_eq!(transport.published().len(), 1); let request_messages = transport.published_request_messages(); + let sign_event_request = match &request_messages[0].request { + RadrootsNostrConnectRequest::SignEvent(unsigned_event) => unsigned_event, + other => panic!("unexpected NIP-46 request: {other:?}"), + }; + let request_tags = sign_event_request + .tags + .clone() + .to_vec() + .into_iter() + .map(Tag::to_vec) + .collect::<Vec<_>>(); + assert_eq!(sign_event_request.pubkey.to_hex(), draft.expected_pubkey); + assert_eq!( + sign_event_request.created_at.as_secs(), + u64::from(draft.created_at) + ); + assert_eq!(sign_event_request.kind.as_u16(), draft.kind as u16); + assert_eq!(request_tags, draft.tags); + assert_eq!(sign_event_request.content, draft.content); let request_id = request_messages[0] .id .strip_prefix("radroots-sdk-myc-nip46-sign-") @@ -591,6 +610,48 @@ async fn myc_nip46_provider_signs_and_validates_remote_event() { ); } +#[test] +fn myc_nip46_sign_event_request_reports_invalid_frozen_drafts() { + let mut invalid_pubkey = frozen_draft(); + invalid_pubkey.expected_pubkey = "not-a-public-key".to_owned(); + let error = sign_event_request_from_frozen_draft(&invalid_pubkey) + .expect_err("invalid NIP-46 pubkey error"); + assert!(matches!( + error, + RadrootsSdkError::SignerProtocol { + mode, + ref reason + } if mode == RadrootsSdkSignerMode::MycNip46.as_str() + && reason.contains("failed to parse frozen draft pubkey") + )); + + let mut oversized_kind = frozen_draft(); + oversized_kind.kind = u32::from(u16::MAX) + 1; + let error = sign_event_request_from_frozen_draft(&oversized_kind) + .expect_err("oversized NIP-46 event kind error"); + assert!(matches!( + error, + RadrootsSdkError::SignerProtocol { + mode, + ref reason + } if mode == RadrootsSdkSignerMode::MycNip46.as_str() + && reason.contains("failed to convert frozen draft kind") + )); + + let mut empty_tag = frozen_draft(); + empty_tag.tags = vec![Vec::new()]; + let error = + sign_event_request_from_frozen_draft(&empty_tag).expect_err("invalid NIP-46 tag error"); + assert!(matches!( + error, + RadrootsSdkError::SignerProtocol { + mode, + ref reason + } if mode == RadrootsSdkSignerMode::MycNip46.as_str() + && reason.contains("failed to convert frozen draft tags") + )); +} + #[tokio::test] async fn myc_nip46_provider_reports_preflight_and_progress_sink_edges() { let draft = frozen_draft(); diff --git a/crates/sdk/tests/unit/sync_runtime_tests.rs b/crates/sdk/tests/unit/sync_runtime_tests.rs @@ -1,13 +1,13 @@ -use super::{ - CLAIM_OWNER, PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, - PushOutboxRelayOutcomeKind, SdkRelayAuthPolicy, SyncEventStoreStatus, SyncOutboxStatus, - push_event_final_state, push_event_receipt, push_outbox_claim_token, -}; #[cfg(feature = "radrootsd-proxy")] use super::{ - complete_proxy_publish_attempt, proxy_delivery_policy, proxy_error_message, + CLAIM_OWNER, complete_proxy_publish_attempt, proxy_delivery_policy, proxy_error_message, proxy_outbox_idempotency_key, proxy_transport_error_receipt, push_proxy_claimed_outbox_event, }; +use super::{ + PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, PushOutboxRelayOutcomeKind, + SdkRelayAuthPolicy, SyncEventStoreStatus, SyncOutboxStatus, push_event_final_state, + push_event_receipt, push_outbox_claim_token, +}; use crate::RadrootsSdkError; #[cfg(feature = "radrootsd-proxy")] use crate::adapters::radrootsd::{ @@ -24,10 +24,10 @@ use radroots_event_store::RadrootsEventStoreStatusSummary; #[cfg(feature = "radrootsd-proxy")] use radroots_events::contract::RadrootsActorRole; #[cfg(feature = "radrootsd-proxy")] -use radroots_events::draft::RadrootsSignedNostrEvent; +use radroots_events::draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent}; +use radroots_events::ids::RadrootsEventId; #[cfg(feature = "radrootsd-proxy")] use radroots_events::kinds::KIND_FARM; -use radroots_events::{draft::RadrootsFrozenEventDraft, ids::RadrootsEventId}; #[cfg(feature = "radrootsd-proxy")] use radroots_events_codec::wire::{WireEventParts, to_frozen_draft}; #[cfg(feature = "radrootsd-proxy")] diff --git a/crates/sdk/tests/unit/workflow_runtime_tests.rs b/crates/sdk/tests/unit/workflow_runtime_tests.rs @@ -1,4 +1,6 @@ use super::*; +#[cfg(feature = "signer-adapters")] +use crate::{RadrootsSdkLocalKeySigner, RadrootsSdkSignerProvider}; use radroots_authority::{RadrootsSignerError, RadrootsSignerIdentity}; use radroots_events::contract::RadrootsActorRole; use radroots_events::draft::RadrootsSignedNostrEvent; @@ -48,11 +50,15 @@ impl RadrootsEventSigner for WorkflowSigner { } fn frozen_draft_for(pubkey: &str) -> RadrootsFrozenEventDraft { + frozen_draft_for_d_tag(pubkey, "test") +} + +fn frozen_draft_for_d_tag(pubkey: &str, d_tag: &str) -> RadrootsFrozenEventDraft { to_frozen_draft( WireEventParts { kind: KIND_FARM, content: "{}".to_owned(), - tags: vec![vec!["d".to_owned(), "test".to_owned()]], + tags: vec![vec!["d".to_owned(), d_tag.to_owned()]], }, "radroots.farm.profile.v1", pubkey, @@ -124,6 +130,116 @@ fn workflow_digest_and_event_helpers_cover_error_and_input_paths() { } #[tokio::test] +async fn enqueue_signed_workflow_stores_signed_event_and_reports_idempotency_conflicts() { + let sdk = crate::RadrootsClient::builder() + .relay_url("wss://relay.example.com") + .fixed_clock(crate::RadrootsSdkTimestamp::from_unix_seconds( + 1_700_000_010, + )) + .build() + .await + .expect("sdk"); + let actor = RadrootsActorContext::test(FARMER_PUBLIC_KEY_HEX, [RadrootsActorRole::Farmer]) + .expect("actor"); + let signer = WorkflowSigner::new(); + let first_draft = frozen_draft_for_d_tag(FARMER_PUBLIC_KEY_HEX, "workflow-success"); + let idempotency_key = SdkIdempotencyKey::new("workflow-idempotency").expect("idempotency"); + let receipt = enqueue_signed_workflow( + &sdk, + SdkWorkflowEnqueueRequest { + operation_kind: "workflow.test.v1", + actor: &actor, + frozen_draft: &first_draft, + target_relays: SdkRelayTargetPolicy::UseConfiguredRelays, + idempotency_key: Some(idempotency_key.clone()), + }, + &signer, + ) + .await + .expect("enqueue signed workflow"); + + assert_eq!( + receipt.signed_event_id.as_str(), + first_draft.expected_event_id + ); + assert!(receipt.local_event_seq > 0); + assert!(receipt.outbox_operation_id > 0); + assert!(receipt.outbox_event_id > 0); + assert_eq!(receipt.idempotency_digest_prefix.len(), 12); + + let second_draft = frozen_draft_for_d_tag(FARMER_PUBLIC_KEY_HEX, "workflow-conflict"); + let error = match enqueue_signed_workflow( + &sdk, + SdkWorkflowEnqueueRequest { + operation_kind: "workflow.test.v1", + actor: &actor, + frozen_draft: &second_draft, + target_relays: SdkRelayTargetPolicy::UseConfiguredRelays, + idempotency_key: Some(idempotency_key), + }, + &signer, + ) + .await + { + Err(error) => error, + Ok(_) => panic!("expected idempotency conflict"), + }; + + match error { + RadrootsSdkError::PartialLocalMutation(partial) => { + assert!(partial.stored); + assert!(!partial.queued); + assert_eq!( + partial.failure, + crate::RadrootsSdkPartialLocalMutationFailure::OutboxIdempotencyConflict + ); + assert_eq!( + partial.idempotency_digest_prefix.as_deref().map(str::len), + Some(12) + ); + } + other => panic!("unexpected workflow error: {other:?}"), + } +} + +#[cfg(feature = "signer-adapters")] +#[tokio::test] +async fn enqueue_configured_signed_workflow_uses_sdk_signer_provider() { + let secret_key = RadrootsNostrSecretKey::from_hex(FARMER_SECRET_KEY_HEX).expect("secret key"); + let keys = RadrootsNostrKeys::new(secret_key); + let sdk = crate::RadrootsClient::builder() + .relay_url("wss://relay.example.com") + .fixed_clock(crate::RadrootsSdkTimestamp::from_unix_seconds( + 1_700_000_011, + )) + .signer_provider(RadrootsSdkSignerProvider::LocalKey( + RadrootsSdkLocalKeySigner::new(keys).expect("local signer"), + )) + .build() + .await + .expect("sdk"); + let actor = RadrootsActorContext::test(FARMER_PUBLIC_KEY_HEX, [RadrootsActorRole::Farmer]) + .expect("actor"); + let draft = frozen_draft_for_d_tag(FARMER_PUBLIC_KEY_HEX, "workflow-configured"); + + let receipt = enqueue_configured_signed_workflow( + &sdk, + SdkWorkflowEnqueueRequest { + operation_kind: "workflow.test.v1", + actor: &actor, + frozen_draft: &draft, + target_relays: SdkRelayTargetPolicy::UseConfiguredRelays, + idempotency_key: None, + }, + ) + .await + .expect("configured enqueue"); + + assert_eq!(receipt.signed_event_id.as_str(), draft.expected_event_id); + assert_eq!(receipt.idempotency_digest_prefix.len(), 12); +} + +#[tokio::test] async fn enqueue_signed_workflow_reports_partial_mutation_when_outbox_fails() { let sdk = crate::RadrootsClient::builder() .relay_url("wss://relay.example.com")