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:
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")