sdk

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

commit 862c78d5d63b2282dac0e8eb75b8971e5b11d585
parent 27c621961ecfd7fcd0c1aa3513d285596378ccd6
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 11:23:41 +0000

sdk: refactor farm prepare and enqueue operations

- freeze pure farm plans from canonical event types
- retain private location authority in private storage
- enqueue through native sync commit and idempotency law
- verify purity cancellation replay and delivery policy

Diffstat:
Mcrates/sdk/Cargo.toml | 5++++-
Mcrates/sdk/src/client.rs | 6++++++
Mcrates/sdk/src/farm.rs | 503++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Dcrates/sdk/src/farms_runtime.rs | 1076-------------------------------------------------------------------------------
Dcrates/sdk/tests/farms_runtime.rs | 1303-------------------------------------------------------------------------------
Mcrates/sdk/tests/package_boundary.rs | 35+++++++++++++++++++++++++++++++++++
Dcrates/sdk/tests/unit/farms_runtime_tests.rs | 1090-------------------------------------------------------------------------------
7 files changed, 547 insertions(+), 3471 deletions(-)

diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml @@ -56,7 +56,10 @@ full = [ [dependencies] radroots_core = { workspace = true, default-features = false } radroots_event = { workspace = true, default-features = false } -radroots_event_codec = { workspace = true, default-features = false } +radroots_event_codec = { workspace = true, default-features = false, features = [ + "json", + "std", +] } radroots_identity = { workspace = true, default-features = false } radroots_protocol = { workspace = true, default-features = false } radroots_signing = { workspace = true, default-features = false } diff --git a/crates/sdk/src/client.rs b/crates/sdk/src/client.rs @@ -239,6 +239,12 @@ impl Client { Ok(self.inner.sync.as_ref().map(crate::sync::Operations::new)) } + /// Returns farm commit operations when canonical synchronization is configured. + #[cfg(feature = "sync")] + pub fn farm(&self) -> Result<Option<crate::farm::Operations<'_>>> { + Ok(self.sync()?.map(crate::farm::Operations::new)) + } + /// Returns whether explicit close completed successfully or reached the /// lower storage commit point. #[must_use] diff --git a/crates/sdk/src/farm.rs b/crates/sdk/src/farm.rs @@ -1 +1,502 @@ -//! Farm product operations. +//! Side-effect-free farm planning and canonical durable enqueue operations. + +use std::{error, fmt}; + +use radroots_event::{ + EventDraft, + contract::AuthorRole, + envelope::kind::KIND_FARM, + farm::Farm, + id::{AddressableCoordinate, ParseError}, +}; +use radroots_event_codec::{encode::EventEncodeError, encode::farm::to_wire_parts}; +use radroots_signing::Actor; + +const FARM_PROFILE_CONTRACT_ID: &str = "radroots.farm.profile.v1"; + +/// Pure inputs for one frozen farm profile plan. +#[derive(Clone, Debug)] +pub struct PrepareRequest { + actor: Actor, + farm: Farm, + created_at_unix: u64, +} + +impl PrepareRequest { + /// Creates explicit canonical planning inputs. + #[must_use] + pub const fn new(actor: Actor, farm: Farm, created_at_unix: u64) -> Self { + Self { + actor, + farm, + created_at_unix, + } + } +} + +/// Frozen, replay-stable farm publication plan. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Plan { + actor: Actor, + coordinate: AddressableCoordinate, + draft: EventDraft, +} + +impl Plan { + /// Returns the exact authorized actor carried into signing. + #[must_use] + pub const fn actor(&self) -> &Actor { + &self.actor + } + + /// Returns the canonical addressable farm coordinate. + #[must_use] + pub const fn coordinate(&self) -> &AddressableCoordinate { + &self.coordinate + } + + /// Returns the frozen canonical event draft. + #[must_use] + pub const fn draft(&self) -> &EventDraft { + &self.draft + } +} + +/// Farm plan validation stage. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum PrepareErrorKind { + /// The actor does not claim the required farm author role. + UnauthorizedActor, + /// The canonical farm codec rejected the native farm model. + Encode, + /// The canonical addressable coordinate rejected the farm identity. + Coordinate, + /// The canonical event draft rejected the encoded parts. + Draft, +} + +/// One secret-safe farm planning failure retaining its lower source. +pub struct PrepareError { + kind: PrepareErrorKind, + source: Option<Box<dyn error::Error + Send + Sync>>, +} + +impl PrepareError { + /// Returns the stable client-level planning stage. + #[must_use] + pub const fn kind(&self) -> PrepareErrorKind { + self.kind + } + + fn unauthorized_actor() -> Self { + Self { + kind: PrepareErrorKind::UnauthorizedActor, + source: None, + } + } + + fn encode(source: EventEncodeError) -> Self { + Self::with_source(PrepareErrorKind::Encode, source) + } + + fn coordinate(source: ParseError) -> Self { + Self::with_source(PrepareErrorKind::Coordinate, source) + } + + fn draft(source: radroots_event::draft::DraftError) -> Self { + Self::with_source(PrepareErrorKind::Draft, source) + } + + fn with_source( + kind: PrepareErrorKind, + source: impl error::Error + Send + Sync + 'static, + ) -> Self { + Self { + kind, + source: Some(Box::new(source)), + } + } +} + +impl fmt::Display for PrepareError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + PrepareErrorKind::UnauthorizedActor => "farm actor is not authorized", + PrepareErrorKind::Encode => "farm model is invalid", + PrepareErrorKind::Coordinate => "farm coordinate is invalid", + PrepareErrorKind::Draft => "farm event draft is invalid", + }) + } +} + +impl fmt::Debug for PrepareError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PrepareError") + .field("kind", &self.kind) + .finish_non_exhaustive() + } +} + +impl error::Error for PrepareError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + self.source + .as_deref() + .map(|source| source as &(dyn error::Error + 'static)) + } +} + +/// Validates and freezes one farm profile without storage, signing, or network work. +/// +/// `Farm` contains only public profile locality. Exact coordinates and other +/// private farm artifacts are deliberately not accepted here; hosts persist +/// their typed references and metadata through +/// `radroots_storage::private_artifact::PrivateArtifactStore`. +pub fn prepare(request: PrepareRequest) -> Result<Plan, PrepareError> { + if !request.actor.satisfies(AuthorRole::Farmer) { + return Err(PrepareError::unauthorized_actor()); + } + let parts = to_wire_parts(&request.farm).map_err(PrepareError::encode)?; + let coordinate = AddressableCoordinate::parse(format!( + "{KIND_FARM}:{}:{}", + request.actor.public_key(), + request.farm.d_tag + )) + .map_err(PrepareError::coordinate)?; + let draft = EventDraft::new( + FARM_PROFILE_CONTRACT_ID, + parts.kind, + request.created_at_unix, + parts.tags, + parts.content, + request.actor.public_key().to_hex(), + ) + .map_err(PrepareError::draft)?; + Ok(Plan { + actor: request.actor, + coordinate, + draft, + }) +} + +#[cfg(feature = "sync")] +use radroots_signing::request::CancellationPolicy; +#[cfg(feature = "sync")] +use radroots_storage::journal::IdempotencyKey; +#[cfg(feature = "sync")] +use radroots_sync::{ + PushReceipt, + policy::{Error as SyncError, SyncId}, +}; + +/// Explicit commit inputs for one prepared farm publication. +#[cfg(feature = "sync")] +#[derive(Clone, Debug)] +pub struct EnqueueRequest { + operation_id: SyncId, + idempotency_key: IdempotencyKey, + plan: Plan, + profile: crate::transport::Profile, + cancellation: CancellationPolicy, +} + +#[cfg(feature = "sync")] +impl EnqueueRequest { + /// Creates an enqueue request whose transport selection has no fallback. + #[must_use] + pub const fn new( + operation_id: SyncId, + idempotency_key: IdempotencyKey, + plan: Plan, + profile: crate::transport::Profile, + cancellation: CancellationPolicy, + ) -> Self { + Self { + operation_id, + idempotency_key, + plan, + profile, + cancellation, + } + } +} + +/// Borrowed farm commit operations over the canonical sync engine. +#[cfg(feature = "sync")] +#[derive(Clone, Copy, Debug)] +pub struct Operations<'a> { + sync: crate::sync::Operations<'a>, +} + +#[cfg(feature = "sync")] +impl<'a> Operations<'a> { + pub(crate) const fn new(sync: crate::sync::Operations<'a>) -> Self { + Self { sync } + } + + /// Signs and atomically enqueues a prepared farm publication. + /// + /// Before the lower atomic enqueue commit, cancellation may leave only + /// recoverable prepared/signed journal state. After commit, cancellation + /// cannot claim rollback; replay with the same idempotency input returns + /// the durable outbox record. + pub async fn enqueue(&self, request: EnqueueRequest) -> Result<PushReceipt, SyncError> { + let targets = request + .profile + .targets() + .cloned() + .ok_or(SyncError::InvalidPushRequest)?; + let satisfaction = request + .profile + .satisfaction() + .cloned() + .ok_or(SyncError::InvalidPushRequest)?; + self.sync + .sign_and_enqueue(radroots_sync::PushRequest::new( + request.operation_id, + request.idempotency_key, + request.plan.actor, + request.plan.draft, + targets, + satisfaction, + request.cancellation, + )?) + .await + } +} + +#[cfg(test)] +mod tests { + use radroots_event::farm::FarmPublicLocation; + use radroots_signing::actor::ActorSource; + + use super::*; + + const PUBLIC_KEY: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; + + fn actor(role: AuthorRole) -> Actor { + Actor::from_public_key_hex(PUBLIC_KEY, ActorSource::ExplicitPublicKey, [role]) + .expect("actor") + } + + fn farm() -> Farm { + Farm { + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(), + name: "Moss Street Farm".to_owned(), + about: Some("seasonal vegetables".to_owned()), + website: None, + picture: None, + banner: None, + location: Some(FarmPublicLocation { + primary: "Santa Cruz, California".to_owned(), + city: Some("Santa Cruz".to_owned()), + region: Some("California".to_owned()), + country: Some("US".to_owned()), + geohash: "9q8yy".to_owned(), + }), + tags: Some(vec!["vegetables".to_owned()]), + } + } + + #[test] + fn prepare_is_pure_deterministic_and_uses_canonical_public_types() { + let request = PrepareRequest::new(actor(AuthorRole::Farmer), farm(), 1_800_000_000); + let first = prepare(request.clone()).expect("first plan"); + let second = prepare(request).expect("second plan"); + + assert_eq!(first, second); + assert_eq!(first.draft().contract_id(), FARM_PROFILE_CONTRACT_ID); + assert_eq!(first.draft().kind_u32(), KIND_FARM); + assert_eq!(first.draft().created_at_u64(), 1_800_000_000); + assert_eq!( + first.draft().expected_pubkey(), + &actor(AuthorRole::Farmer).public_key() + ); + assert_eq!( + first.coordinate().as_str(), + format!("{KIND_FARM}:{PUBLIC_KEY}:AAAAAAAAAAAAAAAAAAAAAA") + ); + assert!(first.draft().content().contains("Moss Street Farm")); + assert!(!first.draft().content().contains("latitude")); + assert!(!first.draft().content().contains("longitude")); + } + + #[test] + fn prepare_maps_authorization_and_lower_validation_once() { + let unauthorized = prepare(PrepareRequest::new( + actor(AuthorRole::Buyer), + farm(), + 1_800_000_000, + )) + .expect_err("unauthorized"); + assert_eq!(unauthorized.kind(), PrepareErrorKind::UnauthorizedActor); + assert!(std::error::Error::source(&unauthorized).is_none()); + + let mut invalid = farm(); + invalid.name.clear(); + let encoded = prepare(PrepareRequest::new( + actor(AuthorRole::Farmer), + invalid, + 1_800_000_000, + )) + .expect_err("invalid model"); + assert_eq!(encoded.kind(), PrepareErrorKind::Encode); + assert!(std::error::Error::source(&encoded).is_some()); + assert_eq!(encoded.to_string(), "farm model is invalid"); + assert!(!format!("{encoded:?}").contains("name")); + } + + #[cfg(all(feature = "sync", feature = "memory", feature = "local-signing"))] + mod enqueue { + use std::sync::{ + Arc, + atomic::{AtomicU8, Ordering}, + }; + + use radroots_storage::{ + Outbox, event::SourceGeneration, journal::IdempotencyKey, memory::MemoryStorage, + }; + use radroots_sync::{ + Engine, + policy::{Clock, DeadlinePolicy, Error, IdSource, OperationKind, SyncId, SyncStorage}, + }; + use radroots_transport::{ + DeliveryReceipt, DeliveryRequest, Error as TransportError, EventSink, SinkStatus, + Target, TargetSet, TransportId, + capability::{Availability, Maturity, SinkCapabilities}, + policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy}, + }; + + use crate::{ClientBuilder, transport::Profile}; + + use super::*; + + struct FixedClock; + struct SequenceIds(AtomicU8); + struct NoopSink; + + impl Clock for FixedClock { + fn now_unix_ms(&self) -> Result<u64, Error> { + Ok(2_000_000_000_000) + } + } + + impl IdSource for SequenceIds { + fn next_id(&self, _operation: OperationKind) -> Result<SyncId, Error> { + SyncId::new([self.0.fetch_add(1, Ordering::Relaxed); 16]) + } + } + + impl EventSink for NoopSink { + fn status( + &self, + ) -> radroots_transport::BoxFuture<'_, Result<SinkStatus, TransportError>> { + Box::pin(async { + Ok(SinkStatus::new( + TransportId::NOSTR, + true, + Maturity::Stable, + Availability::Available, + SinkCapabilities::DELIVER, + "ready", + )) + }) + } + + fn deliver( + &self, + _request: DeliveryRequest, + ) -> radroots_transport::BoxFuture<'_, Result<DeliveryReceipt, TransportError>> + { + Box::pin(async { Err(TransportError::UnsupportedOperation) }) + } + } + + #[tokio::test] + async fn enqueue_preserves_commit_cancellation_idempotency_and_delivery_policy() { + let storage = Arc::new(MemoryStorage::new( + SourceGeneration::new([4; 32]).expect("generation"), + )); + let signer = + Arc::new(radroots_nostr::signing::LocalSigner::generate().expect("local signer")); + let farm_actor = Actor::new( + signer.public_key(), + ActorSource::ExplicitPublicKey, + [AuthorRole::Farmer], + ) + .expect("actor"); + let plan = + prepare(PrepareRequest::new(farm_actor, farm(), 1_800_000_000)).expect("plan"); + let targets = TargetSet::new(vec![ + Target::nostr_relay("wss://farm.example").expect("target"), + ]) + .expect("targets"); + let satisfaction = + SatisfactionPolicy::new(SatisfactionClass::Delivered, TargetPolicy::all()); + let profile = Profile::delivery(targets.clone(), satisfaction.clone()) + .expect("transport profile"); + let capability: Arc<dyn SyncStorage> = storage.clone(); + let engine = Engine::builder( + capability, + Arc::new(FixedClock), + Arc::new(SequenceIds(AtomicU8::new(1))), + DeadlinePolicy::new(1_000, 1_000, 1_000).expect("deadlines"), + ) + .sink(Arc::new(NoopSink)) + .signer(signer) + .build() + .expect("engine"); + let client = ClientBuilder::new() + .storage(storage.clone()) + .sync_engine(engine) + .build() + .expect("client"); + let operations = client.farm().expect("open").expect("farm operations"); + let request = EnqueueRequest::new( + SyncId::new([7; 16]).expect("operation id"), + IdempotencyKey::parse("farm-publish-a").expect("idempotency key"), + plan, + profile, + CancellationPolicy::PreservePublishedRequest, + ); + + drop(operations.enqueue(request.clone())); + assert_eq!( + Outbox::status(storage.as_ref()) + .await + .expect("outbox status") + .pending, + 0 + ); + + let committed = operations + .enqueue(request.clone()) + .await + .expect("committed enqueue"); + assert!(!committed.is_replay()); + assert_eq!(committed.outbox().request().target_set(), &targets); + assert_eq!(committed.outbox().request().satisfaction(), &satisfaction); + let replay = operations.enqueue(request).await.expect("replay"); + assert!(replay.is_replay()); + assert_eq!(replay.outbox().item_id(), committed.outbox().item_id()); + + let unavailable = EnqueueRequest::new( + SyncId::new([8; 16]).expect("operation id"), + IdempotencyKey::parse("farm-publish-preview").expect("idempotency key"), + prepare(PrepareRequest::new( + actor(AuthorRole::Farmer), + farm(), + 1_800_000_000, + )) + .expect("plan"), + Profile::unavailable_preview(TransportId::RETICULUM), + CancellationPolicy::PreservePublishedRequest, + ); + assert_eq!( + operations.enqueue(unavailable).await, + Err(Error::InvalidPushRequest) + ); + } + } +} diff --git a/crates/sdk/src/farms_runtime.rs b/crates/sdk/src/farms_runtime.rs @@ -1,1076 +0,0 @@ -#[cfg(feature = "signer-adapters")] -use crate::workflow_runtime::enqueue_configured_signed_workflow; -#[cfg(feature = "runtime")] -use crate::{ - FarmsClient, NostrRelayUrlPolicy, RadrootsSdkError, RadrootsSdkTimestamp, SatisfactionPolicy, - SdkIdempotencyKey, SdkMutationState, TargetPolicy, farm, - geonames::{ - Geocoder, GeocoderLocalityCandidate, GeocoderLocalityLookup, GeocoderLocalityQuery, - GeocoderPoint, GeocoderReverseOptions, GeocoderReverseResult, - }, - private_store::SdkPrivateFarmLocationRecord, - workflow_runtime::{SdkWorkflowEnqueueRequest, enqueue_signed_workflow}, -}; -#[cfg(feature = "runtime")] -use radroots_event::{ - contract::AuthorRole, - draft::EventDraft, - envelope::kind::KIND_FARM, - farm::{Farm, FarmPublicLocation}, - id::{AddressableCoordinate, EventId}, - listing::operational::OperationalListingPublicLocation, -}; -#[cfg(feature = "runtime")] -use radroots_signing::{Actor, Signer}; -#[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")] -const FARM_PRIVATE_LOCATION_OPERATION: &str = "farm.private_location.upsert"; -#[cfg(feature = "runtime")] -const GEOHASH5_LEN: usize = 5; -#[cfg(feature = "runtime")] -const GEOHASH_BASE32: &[u8; 32] = b"0123456789bcdefghjkmnpqrstuvwxyz"; - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, serde::Serialize)] -#[non_exhaustive] -pub struct FarmPreparePublishRequest { - #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] - pub actor: Actor, - pub farm: Farm, - pub created_at: Option<RadrootsSdkTimestamp>, -} - -#[cfg(feature = "runtime")] -impl FarmPreparePublishRequest { - pub fn new(actor: Actor, farm: Farm) -> Self { - Self { - actor, - farm, - created_at: None, - } - } - - pub fn with_created_at(mut self, created_at: RadrootsSdkTimestamp) -> Self { - self.created_at = Some(created_at); - self - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, serde::Serialize)] -#[non_exhaustive] -pub struct FarmEnqueuePublishRequest { - #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] - pub actor: Actor, - pub farm: Farm, - pub target_policy: TargetPolicy, - pub idempotency_key: Option<SdkIdempotencyKey>, - pub created_at: Option<RadrootsSdkTimestamp>, -} - -#[cfg(feature = "runtime")] -impl FarmEnqueuePublishRequest { - pub fn new(actor: Actor, farm: Farm, target_policy: TargetPolicy) -> Self { - Self { - actor, - farm, - target_policy, - idempotency_key: None, - created_at: None, - } - } - - pub fn try_with_nostr_targets<I, S>( - mut self, - target_policy: I, - policy: NostrRelayUrlPolicy, - ) -> Result<Self, RadrootsSdkError> - where - I: IntoIterator<Item = S>, - S: AsRef<str>, - { - self.target_policy = TargetPolicy::try_nostr_relays(target_policy, policy)?; - Ok(self) - } - - pub fn with_idempotency_key(mut self, idempotency_key: SdkIdempotencyKey) -> Self { - self.idempotency_key = Some(idempotency_key); - self - } - - pub fn try_with_idempotency_key( - mut self, - idempotency_key: impl AsRef<str>, - ) -> Result<Self, RadrootsSdkError> { - self.idempotency_key = Some(SdkIdempotencyKey::new(idempotency_key)?); - Ok(self) - } - - pub fn with_created_at(mut self, created_at: RadrootsSdkTimestamp) -> Self { - self.created_at = Some(created_at); - self - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct FarmPublishPlan { - farm_addr: AddressableCoordinate, - expected_event_id: EventId, - frozen_draft: EventDraft, - created_at: RadrootsSdkTimestamp, -} - -#[cfg(feature = "runtime")] -impl FarmPublishPlan { - pub fn farm_addr(&self) -> &AddressableCoordinate { - &self.farm_addr - } - - pub fn expected_event_id(&self) -> &EventId { - &self.expected_event_id - } - - pub fn frozen_draft(&self) -> &EventDraft { - &self.frozen_draft - } - - pub fn created_at(&self) -> RadrootsSdkTimestamp { - self.created_at - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct FarmEnqueueReceipt { - pub farm_addr: AddressableCoordinate, - pub expected_event_id: EventId, - pub signed_event_id: EventId, - pub local_event_seq: i64, - pub outbox_operation_id: i64, - pub outbox_event_id: i64, - pub state: SdkMutationState, - pub idempotency_digest_prefix: Option<String>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct SdkExactLocation { - pub latitude: f64, - pub longitude: f64, -} - -#[cfg(feature = "runtime")] -impl SdkExactLocation { - pub fn new(latitude: f64, longitude: f64) -> Self { - Self { - latitude, - longitude, - } - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct SdkPublicLocality { - pub primary: String, - pub city: Option<String>, - pub region: Option<String>, - pub country: Option<String>, - pub geohash5: String, -} - -#[cfg(feature = "runtime")] -impl SdkPublicLocality { - pub fn to_farm_public_location(&self) -> FarmPublicLocation { - FarmPublicLocation { - primary: self.primary.clone(), - city: self.city.clone(), - region: self.region.clone(), - country: self.country.clone(), - geohash: self.geohash5.clone(), - } - } - - pub fn to_listing_public_location(&self) -> OperationalListingPublicLocation { - OperationalListingPublicLocation { - primary: self.primary.clone(), - city: self.city.clone(), - region: self.region.clone(), - country: self.country.clone(), - geohash: self.geohash5.clone(), - } - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, serde::Serialize)] -#[non_exhaustive] -pub struct FarmPrivateLocationUpsertRequest { - #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] - pub actor: Actor, - pub farm_d_tag: String, - pub exact_location: SdkExactLocation, - pub label: Option<String>, - pub updated_at: Option<RadrootsSdkTimestamp>, -} - -#[cfg(feature = "runtime")] -impl FarmPrivateLocationUpsertRequest { - pub fn new( - actor: Actor, - farm_d_tag: impl Into<String>, - exact_location: SdkExactLocation, - ) -> Self { - Self { - actor, - farm_d_tag: farm_d_tag.into(), - exact_location, - label: None, - updated_at: None, - } - } - - pub fn with_label(mut self, label: impl Into<String>) -> Self { - self.label = Some(label.into()); - self - } - - pub fn with_updated_at(mut self, updated_at: RadrootsSdkTimestamp) -> Self { - self.updated_at = Some(updated_at); - self - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "snake_case", tag = "kind", content = "value")] -pub enum FarmPrivateLocationInput { - Exact(SdkExactLocation), - Locality(GeocoderLocalityQuery), -} - -#[cfg(feature = "runtime")] -impl FarmPrivateLocationInput { - pub fn exact(location: SdkExactLocation) -> Self { - Self::Exact(location) - } - - pub fn city(city: impl Into<String>) -> Self { - Self::Locality(GeocoderLocalityQuery::structured(city)) - } - - pub fn query(query: impl Into<String>) -> Self { - Self::Locality(GeocoderLocalityQuery::query(query)) - } - - pub fn geonames_id(id: i64) -> Self { - Self::Locality(GeocoderLocalityQuery::feature_id(id)) - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, serde::Serialize)] -#[non_exhaustive] -pub struct FarmPrivateLocationSetRequest { - #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] - pub actor: Actor, - pub farm_d_tag: String, - pub input: FarmPrivateLocationInput, - pub label: Option<String>, - pub updated_at: Option<RadrootsSdkTimestamp>, -} - -#[cfg(feature = "runtime")] -impl FarmPrivateLocationSetRequest { - pub fn new( - actor: Actor, - farm_d_tag: impl Into<String>, - input: FarmPrivateLocationInput, - ) -> Self { - Self { - actor, - farm_d_tag: farm_d_tag.into(), - input, - label: None, - updated_at: None, - } - } - - pub fn exact( - actor: Actor, - farm_d_tag: impl Into<String>, - exact_location: SdkExactLocation, - ) -> Self { - Self::new( - actor, - farm_d_tag, - FarmPrivateLocationInput::exact(exact_location), - ) - } - - pub fn city(actor: Actor, farm_d_tag: impl Into<String>, city: impl Into<String>) -> Self { - Self::new(actor, farm_d_tag, FarmPrivateLocationInput::city(city)) - } - - pub fn query(actor: Actor, farm_d_tag: impl Into<String>, query: impl Into<String>) -> Self { - Self::new(actor, farm_d_tag, FarmPrivateLocationInput::query(query)) - } - - pub fn geonames_id(actor: Actor, farm_d_tag: impl Into<String>, id: i64) -> Self { - Self::new(actor, farm_d_tag, FarmPrivateLocationInput::geonames_id(id)) - } - - pub fn with_label(mut self, label: impl Into<String>) -> Self { - self.label = Some(label.into()); - self - } - - pub fn with_updated_at(mut self, updated_at: RadrootsSdkTimestamp) -> Self { - self.updated_at = Some(updated_at); - self - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, serde::Serialize)] -#[non_exhaustive] -pub struct FarmPrivateLocationClearRequest { - #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] - pub actor: Actor, - pub farm_d_tag: String, -} - -#[cfg(feature = "runtime")] -impl FarmPrivateLocationClearRequest { - pub fn new(actor: Actor, farm_d_tag: impl Into<String>) -> Self { - Self { - actor, - farm_d_tag: farm_d_tag.into(), - } - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct FarmPrivateLocationClearReceipt { - pub farm_addr: AddressableCoordinate, - pub cleared: bool, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct FarmPrivateLocationReceipt { - pub farm_addr: AddressableCoordinate, - pub farm_pubkey: String, - pub farm_d_tag: String, - pub label: Option<String>, - pub exact_location: SdkExactLocation, - pub public_locality: SdkPublicLocality, - pub geonames_feature_id: Option<i64>, - pub geonames_country_id: Option<String>, - pub updated_at_ms: i64, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct FarmPrivateLocationLookupCandidate { - pub geonames_feature_id: i64, - pub geonames_country_id: String, - pub name: String, - pub display_name: String, - pub exact_location: SdkExactLocation, - pub region: Option<String>, - pub country: Option<String>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct FarmPrivateLocationLookupReceipt { - pub farm_addr: AddressableCoordinate, - pub farm_pubkey: String, - pub farm_d_tag: String, - pub input: FarmPrivateLocationInput, - pub candidates: Vec<FarmPrivateLocationLookupCandidate>, -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "snake_case", tag = "status", content = "receipt")] -pub enum FarmPrivateLocationSetResult { - Stored(FarmPrivateLocationReceipt), - NoMatch(FarmPrivateLocationLookupReceipt), - Ambiguous(FarmPrivateLocationLookupReceipt), -} - -#[cfg(feature = "runtime")] -impl<'sdk> FarmsClient<'sdk> { - pub fn prepare_publish( - &self, - request: FarmPreparePublishRequest, - ) -> Result<FarmPublishPlan, RadrootsSdkError> { - let created_at = self.resolved_created_at(request.created_at)?; - farm_publish_plan(&request.actor, request.farm, created_at) - } - - #[cfg(feature = "signer-adapters")] - pub async fn enqueue_publish( - &self, - request: FarmEnqueuePublishRequest, - ) -> Result<FarmEnqueueReceipt, RadrootsSdkError> { - let FarmEnqueuePublishRequest { - actor, - farm, - target_policy, - idempotency_key, - created_at, - } = request; - let prepare_request = FarmPreparePublishRequest { - actor: actor.clone(), - farm, - created_at, - }; - let plan = self.prepare_publish(prepare_request)?; - self.enqueue_prepared_publish(&actor, plan, target_policy, idempotency_key) - .await - } - - pub async fn enqueue_publish_with_explicit_signer( - &self, - request: FarmEnqueuePublishRequest, - signer: &dyn Signer, - ) -> Result<FarmEnqueueReceipt, RadrootsSdkError> { - let FarmEnqueuePublishRequest { - actor, - farm, - target_policy, - idempotency_key, - created_at, - } = request; - let prepare_request = FarmPreparePublishRequest { - actor: actor.clone(), - farm, - created_at, - }; - let plan = self.prepare_publish(prepare_request)?; - self.enqueue_prepared_publish_with_explicit_signer( - &actor, - plan, - target_policy, - idempotency_key, - signer, - ) - .await - } - - #[cfg(feature = "signer-adapters")] - pub async fn enqueue_prepared_publish( - &self, - actor: &Actor, - plan: FarmPublishPlan, - target_policy: TargetPolicy, - idempotency_key: Option<SdkIdempotencyKey>, - ) -> Result<FarmEnqueueReceipt, RadrootsSdkError> { - let metadata = validate_farm_publish_plan(&plan)?; - let enqueue = enqueue_configured_signed_workflow( - self.sdk, - SdkWorkflowEnqueueRequest { - operation_kind: FARM_PUBLISH_OPERATION_KIND, - actor, - frozen_draft: &plan.frozen_draft, - target_policy, - satisfaction_policy: SatisfactionPolicy::AllAccepted, - idempotency_key, - }, - ) - .await?; - Ok(farm_enqueue_receipt(metadata, enqueue)) - } - - pub async fn enqueue_prepared_publish_with_explicit_signer( - &self, - actor: &Actor, - plan: FarmPublishPlan, - target_policy: TargetPolicy, - idempotency_key: Option<SdkIdempotencyKey>, - signer: &dyn Signer, - ) -> Result<FarmEnqueueReceipt, RadrootsSdkError> { - let metadata = validate_farm_publish_plan(&plan)?; - let enqueue = enqueue_signed_workflow( - self.sdk, - SdkWorkflowEnqueueRequest { - operation_kind: FARM_PUBLISH_OPERATION_KIND, - actor, - frozen_draft: &plan.frozen_draft, - target_policy, - satisfaction_policy: SatisfactionPolicy::AllAccepted, - idempotency_key, - }, - signer, - ) - .await?; - Ok(farm_enqueue_receipt(metadata, enqueue)) - } - - pub async fn upsert_private_location( - &self, - request: FarmPrivateLocationUpsertRequest, - ) -> Result<FarmPrivateLocationReceipt, RadrootsSdkError> { - let geocoder = self.sdk.geonames().open_verified()?; - self.upsert_private_location_with_geocoder(request, &geocoder) - .await - } - - pub async fn set_private_location( - &self, - request: FarmPrivateLocationSetRequest, - ) -> Result<FarmPrivateLocationSetResult, RadrootsSdkError> { - let geocoder = self.sdk.geonames().open_verified()?; - self.set_private_location_with_geocoder(request, &geocoder) - .await - } - - pub async fn set_private_location_with_geocoder( - &self, - request: FarmPrivateLocationSetRequest, - geocoder: &Geocoder, - ) -> Result<FarmPrivateLocationSetResult, RadrootsSdkError> { - match request.input { - FarmPrivateLocationInput::Exact(exact_location) => { - let upsert = FarmPrivateLocationUpsertRequest { - actor: request.actor, - farm_d_tag: request.farm_d_tag, - exact_location, - label: request.label, - updated_at: request.updated_at, - }; - self.upsert_private_location_with_geocoder(upsert, geocoder) - .await - .map(FarmPrivateLocationSetResult::Stored) - } - FarmPrivateLocationInput::Locality(locality_query) => { - self.set_private_location_from_locality( - request.actor, - request.farm_d_tag, - locality_query, - request.label, - request.updated_at, - geocoder, - ) - .await - } - } - } - - pub async fn upsert_private_location_with_geocoder( - &self, - request: FarmPrivateLocationUpsertRequest, - geocoder: &Geocoder, - ) -> Result<FarmPrivateLocationReceipt, RadrootsSdkError> { - require_farmer_actor(&request.actor, FARM_PRIVATE_LOCATION_OPERATION)?; - validate_exact_location(request.exact_location)?; - let label = normalize_private_location_label(request.label)?; - let updated_at_ms = match request.updated_at { - Some(updated_at) => sdk_timestamp_ms(updated_at)?, - None => crate::runtime::sdk_now_ms(self.sdk)?, - }; - let farm_addr = farm_addr(&request.actor, request.farm_d_tag.as_str())?; - let reverse = nearest_geonames_locality(geocoder, request.exact_location)?; - let public_locality = public_locality_from_reverse(request.exact_location, &reverse)?; - let record = SdkPrivateFarmLocationRecord { - farm_addr: farm_addr.clone(), - farm_pubkey: request.actor.public_key().to_hex(), - farm_d_tag: request.farm_d_tag, - label, - latitude: request.exact_location.latitude, - longitude: request.exact_location.longitude, - locality_primary: public_locality.primary.clone(), - locality_city: public_locality.city.clone(), - locality_region: public_locality.region.clone(), - locality_country: public_locality.country.clone(), - geohash5: public_locality.geohash5.clone(), - geonames_feature_id: Some(reverse.id), - geonames_country_id: Some(reverse.country_id.clone()), - updated_at_ms, - }; - self.sdk - ._private_store - .upsert_farm_location(&record) - .await?; - Ok(private_location_receipt_from_record(record)) - } - - async fn set_private_location_from_locality( - &self, - actor: Actor, - farm_d_tag: String, - locality_query: GeocoderLocalityQuery, - label: Option<String>, - updated_at: Option<RadrootsSdkTimestamp>, - geocoder: &Geocoder, - ) -> Result<FarmPrivateLocationSetResult, RadrootsSdkError> { - require_farmer_actor(&actor, FARM_PRIVATE_LOCATION_OPERATION)?; - let label = normalize_private_location_label(label)?; - let farm_addr = farm_addr(&actor, farm_d_tag.as_str())?; - let lookup = geocoder.locality(&locality_query)?; - match lookup { - GeocoderLocalityLookup::Unique { candidate } => { - let exact_location = exact_location_from_locality_candidate(&candidate); - validate_exact_location(exact_location)?; - let public_locality = public_locality_from_candidate(exact_location, &candidate)?; - let updated_at_ms = match updated_at { - Some(updated_at) => sdk_timestamp_ms(updated_at)?, - None => crate::runtime::sdk_now_ms(self.sdk)?, - }; - let record = SdkPrivateFarmLocationRecord { - farm_addr: farm_addr.clone(), - farm_pubkey: actor.public_key().to_hex(), - farm_d_tag, - label, - latitude: exact_location.latitude, - longitude: exact_location.longitude, - locality_primary: public_locality.primary.clone(), - locality_city: public_locality.city.clone(), - locality_region: public_locality.region.clone(), - locality_country: public_locality.country.clone(), - geohash5: public_locality.geohash5.clone(), - geonames_feature_id: Some(candidate.id), - geonames_country_id: Some(candidate.country_id.clone()), - updated_at_ms, - }; - self.sdk - ._private_store - .upsert_farm_location(&record) - .await?; - Ok(FarmPrivateLocationSetResult::Stored( - private_location_receipt_from_record(record), - )) - } - GeocoderLocalityLookup::NoMatch => Ok(FarmPrivateLocationSetResult::NoMatch( - farm_private_location_lookup_receipt( - farm_addr, - actor.public_key().to_hex().as_str(), - farm_d_tag, - FarmPrivateLocationInput::Locality(locality_query), - Vec::new(), - ), - )), - GeocoderLocalityLookup::Ambiguous { candidates } => Ok( - FarmPrivateLocationSetResult::Ambiguous(farm_private_location_lookup_receipt( - farm_addr, - actor.public_key().to_hex().as_str(), - farm_d_tag, - FarmPrivateLocationInput::Locality(locality_query), - candidates - .into_iter() - .map(farm_private_location_candidate) - .collect(), - )), - ), - } - } - - pub async fn private_location( - &self, - farm_addr: &AddressableCoordinate, - ) -> Result<Option<FarmPrivateLocationReceipt>, RadrootsSdkError> { - self.sdk - ._private_store - .farm_location(farm_addr) - .await? - .map(private_location_receipt_from_record) - .map(Ok) - .transpose() - } - - pub async fn clear_private_location( - &self, - request: FarmPrivateLocationClearRequest, - ) -> Result<FarmPrivateLocationClearReceipt, RadrootsSdkError> { - require_farmer_actor(&request.actor, FARM_PRIVATE_LOCATION_OPERATION)?; - let farm_addr = farm_addr(&request.actor, request.farm_d_tag.as_str())?; - let cleared = self - .sdk - ._private_store - .delete_farm_location(&farm_addr) - .await?; - Ok(FarmPrivateLocationClearReceipt { farm_addr, cleared }) - } - - fn resolved_created_at( - &self, - created_at: Option<RadrootsSdkTimestamp>, - ) -> Result<RadrootsSdkTimestamp, RadrootsSdkError> { - match created_at { - Some(created_at) => Ok(created_at), - None => self.sdk.now(), - } - } -} - -#[cfg(feature = "runtime")] -fn farm_enqueue_receipt( - metadata: ValidatedFarmPublishPlanMetadata, - enqueue: crate::workflow_runtime::SdkWorkflowEnqueueReceipt, -) -> FarmEnqueueReceipt { - FarmEnqueueReceipt { - farm_addr: metadata.farm_addr, - expected_event_id: metadata.expected_event_id, - signed_event_id: enqueue.signed_event_id, - local_event_seq: enqueue.local_event_seq, - outbox_operation_id: enqueue.outbox_operation_id, - outbox_event_id: enqueue.outbox_event_id, - state: enqueue.state.into(), - idempotency_digest_prefix: Some(enqueue.idempotency_digest_prefix), - } -} - -#[cfg(feature = "runtime")] -struct ValidatedFarmPublishPlanMetadata { - farm_addr: AddressableCoordinate, - expected_event_id: EventId, -} - -#[cfg(feature = "runtime")] -fn validate_farm_publish_plan( - plan: &FarmPublishPlan, -) -> Result<ValidatedFarmPublishPlanMetadata, RadrootsSdkError> { - let invalid = |reason: &str| RadrootsSdkError::InvalidRequest { - message: format!("invalid prepared farm publish plan: {reason}"), - }; - plan.frozen_draft - .validate_for_signing() - .map_err(|_| invalid("frozen draft is invalid"))?; - if plan.frozen_draft.contract_id() != FARM_PROFILE_CONTRACT_ID - || plan.frozen_draft.kind_u32() != KIND_FARM - { - return Err(invalid("contract or kind does not match Farm publish")); - } - - let expected_event_id = EventId::parse(plan.frozen_draft.expected_event_id_hex()) - .expect("validated frozen draft has a typed event ID"); - if plan.expected_event_id != expected_event_id { - return Err(invalid("expected event ID does not match frozen draft")); - } - if plan.created_at.unix_seconds() != plan.frozen_draft.created_at_u64() { - return Err(invalid("created-at timestamp does not match frozen draft")); - } - - let tags = plan.frozen_draft.tags_as_vec(); - let mut d_tags = tags - .iter() - .filter(|tag| tag.first().is_some_and(|value| value == "d")); - let d_tag = d_tags - .next() - .and_then(|tag| tag.get(1)) - .ok_or_else(|| invalid("frozen draft is missing its farm identifier"))?; - if d_tags.next().is_some() { - return Err(invalid("frozen draft contains duplicate farm identifiers")); - } - let farm_addr = AddressableCoordinate::parse(format!( - "{KIND_FARM}:{}:{d_tag}", - plan.frozen_draft.expected_pubkey().to_hex() - )) - .map_err(|_| invalid("frozen draft farm address is invalid"))?; - if plan.farm_addr != farm_addr { - return Err(invalid("farm address does not match frozen draft")); - } - Ok(ValidatedFarmPublishPlanMetadata { - farm_addr, - expected_event_id, - }) -} - -#[cfg(feature = "runtime")] -fn farm_publish_plan( - actor: &Actor, - farm_value: Farm, - created_at: RadrootsSdkTimestamp, -) -> Result<FarmPublishPlan, RadrootsSdkError> { - require_farmer_actor(actor, "farm.prepare_publish")?; - let created_at_nostr = created_at.try_into_nostr_created_at()?; - 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 = EventDraft::new( - FARM_PROFILE_CONTRACT_ID, - parts.kind, - created_at_nostr.into(), - parts.tags, - parts.content, - actor.public_key().to_hex(), - ) - .expect("validated farm publish draft freezes"); - let expected_event_id = EventId::parse(frozen_draft.expected_event_id_hex()) - .expect("frozen farm draft produces a valid event id"); - Ok(FarmPublishPlan { - farm_addr, - expected_event_id, - frozen_draft, - created_at, - }) -} - -#[cfg(feature = "runtime")] -fn require_farmer_actor(actor: &Actor, operation: &'static str) -> Result<(), RadrootsSdkError> { - if actor.satisfies(AuthorRole::Farmer) { - Ok(()) - } else { - Err(RadrootsSdkError::UnauthorizedActor { - operation: operation.to_owned(), - reason: "missing role Farmer".to_owned(), - }) - } -} - -#[cfg(feature = "runtime")] -fn farm_addr(actor: &Actor, d_tag: &str) -> Result<AddressableCoordinate, RadrootsSdkError> { - AddressableCoordinate::parse(format!("{KIND_FARM}:{}:{d_tag}", actor.public_key())).map_err( - |error| RadrootsSdkError::InvalidRequest { - message: format!("farm address is invalid: {error}"), - }, - ) -} - -#[cfg(feature = "runtime")] -fn validate_exact_location(location: SdkExactLocation) -> Result<(), RadrootsSdkError> { - if !location.latitude.is_finite() - || !location.longitude.is_finite() - || location.latitude < -90.0 - || location.latitude > 90.0 - || location.longitude < -180.0 - || location.longitude > 180.0 - { - return Err(RadrootsSdkError::InvalidRequest { - message: "farm exact location coordinates are outside valid latitude/longitude bounds" - .to_owned(), - }); - } - Ok(()) -} - -#[cfg(feature = "runtime")] -fn sdk_timestamp_ms(timestamp: RadrootsSdkTimestamp) -> Result<i64, RadrootsSdkError> { - let seconds = timestamp.unix_seconds(); - let millis = seconds - .checked_mul(1_000) - .ok_or(RadrootsSdkError::TimestampOutOfRange { value: seconds })?; - i64::try_from(millis).map_err(|_| RadrootsSdkError::TimestampOutOfRange { value: seconds }) -} - -#[cfg(feature = "runtime")] -fn nearest_geonames_locality( - geocoder: &Geocoder, - exact_location: SdkExactLocation, -) -> Result<GeocoderReverseResult, RadrootsSdkError> { - let mut results = geocoder.reverse( - GeocoderPoint { - lat: exact_location.latitude, - lng: exact_location.longitude, - }, - Some(GeocoderReverseOptions { - limit: 1, - degree_offset: 0.5, - }), - )?; - results.pop().ok_or_else(|| RadrootsSdkError::GeoNames { - kind: crate::RadrootsSdkGeoNamesErrorKind::Lookup, - message: "GeoNames reverse lookup returned no public locality".to_owned(), - }) -} - -#[cfg(feature = "runtime")] -fn public_locality_from_reverse( - exact_location: SdkExactLocation, - reverse: &GeocoderReverseResult, -) -> Result<SdkPublicLocality, RadrootsSdkError> { - let primary = required_public_string(reverse.name.as_str(), "GeoNames locality name")?; - let country = optional_public_string(reverse.country_name.as_deref()) - .or_else(|| Some(reverse.country_id.clone())); - Ok(SdkPublicLocality { - primary: primary.clone(), - city: Some(primary), - region: optional_public_string(reverse.admin1_name.as_deref()), - country, - geohash5: geohash5(exact_location)?, - }) -} - -#[cfg(feature = "runtime")] -fn public_locality_from_candidate( - exact_location: SdkExactLocation, - candidate: &GeocoderLocalityCandidate, -) -> Result<SdkPublicLocality, RadrootsSdkError> { - let primary = required_public_string(candidate.name.as_str(), "GeoNames locality name")?; - let country = optional_public_string(candidate.country_name.as_deref()) - .or_else(|| Some(candidate.country_id.clone())); - Ok(SdkPublicLocality { - primary: primary.clone(), - city: Some(primary), - region: optional_public_string(candidate.admin1_name.as_deref()), - country, - geohash5: geohash5(exact_location)?, - }) -} - -#[cfg(feature = "runtime")] -fn exact_location_from_locality_candidate( - candidate: &GeocoderLocalityCandidate, -) -> SdkExactLocation { - SdkExactLocation { - latitude: candidate.point.lat, - longitude: candidate.point.lng, - } -} - -#[cfg(feature = "runtime")] -fn normalize_private_location_label( - label: Option<String>, -) -> Result<Option<String>, RadrootsSdkError> { - match label { - Some(label) => { - let label = label.trim(); - if label.is_empty() { - return Err(RadrootsSdkError::InvalidRequest { - message: "farm private location label must not be empty".to_owned(), - }); - } - Ok(Some(label.to_owned())) - } - None => Ok(None), - } -} - -#[cfg(feature = "runtime")] -fn required_public_string(value: &str, label: &str) -> Result<String, RadrootsSdkError> { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(RadrootsSdkError::GeoNames { - kind: crate::RadrootsSdkGeoNamesErrorKind::Lookup, - message: format!("{label} must not be empty"), - }); - } - Ok(trimmed.to_owned()) -} - -#[cfg(feature = "runtime")] -fn optional_public_string(value: Option<&str>) -> Option<String> { - value - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned) -} - -#[cfg(feature = "runtime")] -fn geohash5(location: SdkExactLocation) -> Result<String, RadrootsSdkError> { - validate_exact_location(location)?; - let mut latitude_min = -90.0; - let mut latitude_max = 90.0; - let mut longitude_min = -180.0; - let mut longitude_max = 180.0; - let mut even = true; - let mut bit_count = 0; - let mut value = 0usize; - let mut geohash = String::with_capacity(GEOHASH5_LEN); - while geohash.len() < GEOHASH5_LEN { - value <<= 1; - if even { - let middle = (longitude_min + longitude_max) / 2.0; - if location.longitude >= middle { - value |= 1; - longitude_min = middle; - } else { - longitude_max = middle; - } - } else { - let middle = (latitude_min + latitude_max) / 2.0; - if location.latitude >= middle { - value |= 1; - latitude_min = middle; - } else { - latitude_max = middle; - } - } - even = !even; - bit_count += 1; - if bit_count == 5 { - geohash.push(GEOHASH_BASE32[value] as char); - bit_count = 0; - value = 0; - } - } - Ok(geohash) -} - -#[cfg(feature = "runtime")] -fn private_location_receipt_from_record( - record: SdkPrivateFarmLocationRecord, -) -> FarmPrivateLocationReceipt { - FarmPrivateLocationReceipt { - farm_addr: record.farm_addr, - farm_pubkey: record.farm_pubkey, - farm_d_tag: record.farm_d_tag, - label: record.label, - exact_location: SdkExactLocation { - latitude: record.latitude, - longitude: record.longitude, - }, - public_locality: SdkPublicLocality { - primary: record.locality_primary, - city: record.locality_city, - region: record.locality_region, - country: record.locality_country, - geohash5: record.geohash5, - }, - geonames_feature_id: record.geonames_feature_id, - geonames_country_id: record.geonames_country_id, - updated_at_ms: record.updated_at_ms, - } -} - -#[cfg(feature = "runtime")] -fn farm_private_location_lookup_receipt( - farm_addr: AddressableCoordinate, - farm_pubkey: &str, - farm_d_tag: String, - input: FarmPrivateLocationInput, - candidates: Vec<FarmPrivateLocationLookupCandidate>, -) -> FarmPrivateLocationLookupReceipt { - FarmPrivateLocationLookupReceipt { - farm_addr, - farm_pubkey: farm_pubkey.to_owned(), - farm_d_tag, - input, - candidates, - } -} - -#[cfg(feature = "runtime")] -fn farm_private_location_candidate( - candidate: GeocoderLocalityCandidate, -) -> FarmPrivateLocationLookupCandidate { - let exact_location = exact_location_from_locality_candidate(&candidate); - let region = optional_public_string(candidate.admin1_name.as_deref()); - let country = optional_public_string(candidate.country_name.as_deref()) - .or_else(|| Some(candidate.country_id.clone())); - FarmPrivateLocationLookupCandidate { - geonames_feature_id: candidate.id, - geonames_country_id: candidate.country_id, - name: candidate.name, - display_name: candidate.display_name, - exact_location, - region, - country, - } -} - -#[cfg(all(test, feature = "runtime", feature = "signer-adapters"))] -#[path = "../tests/unit/farms_runtime_tests.rs"] -mod tests; diff --git a/crates/sdk/tests/farms_runtime.rs b/crates/sdk/tests/farms_runtime.rs @@ -1,1303 +0,0 @@ -#![cfg(feature = "runtime")] - -use radroots_event::{ - contract::AuthorRole, - envelope::kind::{KIND_FARM, KIND_PROFILE}, - farm::Farm, - id::AddressableCoordinate, -}; -use radroots_event_store::RadrootsEventStore; -use radroots_outbox::{ - RadrootsOutbox, RadrootsOutboxDeliveryPlanStatus, RadrootsOutboxDeliveryTargetStatus, - RadrootsOutboxEventState, -}; -use radroots_sdk::{ - FARM_PUBLISH_OPERATION_KIND, FarmEnqueuePublishRequest, FarmPreparePublishRequest, - FarmPrivateLocationClearRequest, FarmPrivateLocationInput, FarmPrivateLocationLookupCandidate, - FarmPrivateLocationLookupReceipt, FarmPrivateLocationReceipt, FarmPrivateLocationSetRequest, - FarmPrivateLocationSetResult, FarmPrivateLocationUpsertRequest, Geocoder, - GeocoderLocalityQuery, MultiTargetProfile, NostrProfile, NostrRelayUrlPolicy, - PushOutboxEventState, PushOutboxRequest, PushOutboxTargetOutcomeKind, RadrootsClient, - RadrootsSdkError, RadrootsSdkErrorClass, RadrootsSdkGeoNamesErrorKind, - RadrootsSdkRecoveryAction, RadrootsSdkTimestamp, ReticulumProfile, SdkExactLocation, - SdkIdempotencyKey, SdkMutationState, SdkPublicLocality, StorageStatusRequest, TargetPolicy, - TargetSet, TransportProfile, -}; -use radroots_signing::{Actor, actor::ActorSource}; -use radroots_transport_nostr::{RadrootsMockRelayPublishAdapter, RadrootsNostrTransport}; -use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; - -#[path = "support/fixture_signer.rs"] -mod fixture_signer; -#[path = "support/serializer_failure.rs"] -mod serializer_failure; - -use fixture_signer::{FixtureSigner, fixture_alice_pubkey, fixture_bob_pubkey}; -use serializer_failure::assert_struct_serialize_error_paths; - -const FARM_A_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; -const FARM_B_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAQ"; -const FARM_C_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAg"; -const FARM_D_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAw"; -const FARM_E_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAABA"; -const FARM_F_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAABQ"; -const RELAY: &str = "wss://relay.example.com"; -const RELAY_B: &str = "wss://relay-b.example.com"; - -fn farmer_pubkey() -> &'static str { - fixture_alice_pubkey() -} - -fn other_pubkey() -> &'static str { - fixture_bob_pubkey() -} - -fn farmer_actor() -> Actor { - Actor::from_public_key_hex( - farmer_pubkey(), - ActorSource::ExplicitPublicKey, - [AuthorRole::Farmer], - ) - .expect("actor") -} - -fn non_farmer_actor() -> Actor { - Actor::from_public_key_hex( - farmer_pubkey(), - ActorSource::ExplicitPublicKey, - [AuthorRole::Buyer], - ) - .expect("actor") -} - -fn farm(d_tag: &str, name: &str) -> Farm { - Farm { - d_tag: d_tag.to_owned(), - name: name.to_owned(), - about: Some("Vegetable farm".to_owned()), - website: Some("https://example.invalid/north-farm".to_owned()), - picture: None, - banner: None, - location: None, - tags: Some(vec!["vegetables".to_owned(), "local".to_owned()]), - } -} - -fn farm_addr(actor: &Actor, d_tag: &str) -> AddressableCoordinate { - AddressableCoordinate::parse(format!("{KIND_FARM}:{}:{d_tag}", actor.public_key())) - .expect("farm addr") -} - -fn stored_location(result: FarmPrivateLocationSetResult) -> FarmPrivateLocationReceipt { - let FarmPrivateLocationSetResult::Stored(receipt) = result else { - panic!("expected stored location"); - }; - receipt -} - -async fn directory_sdk() -> (tempfile::TempDir, RadrootsClient) { - directory_sdk_with_relays(&[RELAY]).await -} - -async fn directory_sdk_with_relays(relays: &[&str]) -> (tempfile::TempDir, RadrootsClient) { - let tempdir = tempfile::tempdir().expect("tempdir"); - let mut builder = RadrootsClient::builder() - .directory_storage(tempdir.path().join("sdk")) - .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)); - if !relays.is_empty() { - builder = builder.transport_profile(TransportProfile::nostr( - NostrProfile::new(relays.iter().copied(), NostrRelayUrlPolicy::Public) - .expect("Nostr profile"), - )); - } - let sdk = builder.build().await.expect("sdk"); - (tempdir, sdk) -} - -async fn multi_target_directory_sdk() -> (tempfile::TempDir, RadrootsClient) { - let tempdir = tempfile::tempdir().expect("tempdir"); - let sdk = RadrootsClient::builder() - .directory_storage(tempdir.path().join("sdk")) - .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)) - .transport_profile(TransportProfile::multi_target(MultiTargetProfile::new( - NostrProfile::new([RELAY], NostrRelayUrlPolicy::Public).expect("Nostr profile"), - ReticulumProfile::deferred_until_implemented(), - ))) - .build() - .await - .expect("sdk"); - (tempdir, sdk) -} - -async fn fixture_geocoder(tempdir: &tempfile::TempDir) -> Geocoder { - let path = tempdir.path().join("geonames-fixture.db"); - let options = SqliteConnectOptions::new() - .filename(&path) - .create_if_missing(true); - let pool = SqlitePoolOptions::new() - .max_connections(1) - .connect_with(options) - .await - .expect("geonames fixture pool"); - sqlx::raw_sql( - r#" - CREATE TABLE countries( - id TEXT, - name TEXT, - PRIMARY KEY (id) - ); - CREATE TABLE admin1( - country_id TEXT, - id INTEGER, - name TEXT, - PRIMARY KEY (country_id, id) - ); - CREATE TABLE features( - id INTEGER, - name TEXT, - country_id TEXT, - admin1_id INTEGER, - PRIMARY KEY (id) - ); - CREATE TABLE coordinates( - feature_id INTEGER, - latitude REAL, - longitude REAL, - PRIMARY KEY (feature_id) - ); - CREATE INDEX coordinates_lat_lng ON coordinates (latitude, longitude); - CREATE VIEW geonames AS - SELECT - features.id, - features.name, - admin1.id AS admin1_id, - admin1.name AS admin1_name, - countries.id AS country_id, - countries.name AS country_name, - coordinates.latitude AS latitude, - coordinates.longitude AS longitude - FROM features - LEFT JOIN countries ON features.country_id = countries.id - LEFT JOIN admin1 ON features.country_id = admin1.country_id AND features.admin1_id = admin1.id - JOIN coordinates ON features.id = coordinates.feature_id; - INSERT INTO countries (id, name) VALUES ('FX', 'Fixture Country'); - INSERT INTO countries (id, name) VALUES ('CA', 'Canada'); - INSERT INTO countries (id, name) VALUES ('US', 'United States'); - INSERT INTO admin1 (country_id, id, name) VALUES ('FX', 1, 'Fixture Region'); - INSERT INTO admin1 (country_id, id, name) VALUES ('CA', 2, 'British Columbia'); - INSERT INTO admin1 (country_id, id, name) VALUES ('CA', 3, 'Prairie Region'); - INSERT INTO admin1 (country_id, id, name) VALUES ('US', 4, 'River Region'); - INSERT INTO features (id, name, country_id, admin1_id) VALUES (1, 'Fixture Town', 'FX', 1); - INSERT INTO features (id, name, country_id, admin1_id) VALUES (3001, 'Fixture Victoria', 'CA', 2); - INSERT INTO features (id, name, country_id, admin1_id) VALUES (3002, 'Shared Market', 'CA', 2); - INSERT INTO features (id, name, country_id, admin1_id) VALUES (3003, 'Shared Market', 'CA', 3); - INSERT INTO features (id, name, country_id, admin1_id) VALUES (3004, 'Identifier Grove', 'CA', 2); - INSERT INTO features (id, name, country_id, admin1_id) VALUES (3005, 'Query Hamlet', 'US', 4); - INSERT INTO coordinates (feature_id, latitude, longitude) VALUES (1, 12.25, -34.50); - INSERT INTO coordinates (feature_id, latitude, longitude) VALUES (3001, 48.4359, -123.35155); - INSERT INTO coordinates (feature_id, latitude, longitude) VALUES (3002, 48.7, -123.2); - INSERT INTO coordinates (feature_id, latitude, longitude) VALUES (3003, 50.2, -110.4); - INSERT INTO coordinates (feature_id, latitude, longitude) VALUES (3004, 48.9, -123.4); - INSERT INTO coordinates (feature_id, latitude, longitude) VALUES (3005, 39.25, -77.5); - "#, - ) - .execute(&pool) - .await - .expect("seed geonames fixture"); - pool.close().await; - Geocoder::open_path(path).expect("open geonames fixture") -} - -#[tokio::test] -async fn farm_prepare_publish_is_side_effect_free() { - let (_tempdir, sdk) = directory_sdk().await; - let request = FarmPreparePublishRequest::new(farmer_actor(), farm(FARM_A_D_TAG, "North Farm")); - let prepared = sdk.farms().prepare_publish(request).expect("prepared"); - - assert_eq!(prepared.frozen_draft().kind_u32(), KIND_FARM); - assert_eq!(prepared.created_at().unix_seconds(), 1_700_000_000); - assert_eq!( - prepared.expected_event_id().to_hex(), - prepared.frozen_draft().expected_event_id_hex() - ); - assert_eq!( - prepared.farm_addr().as_str(), - format!("{KIND_FARM}:{}:{FARM_A_D_TAG}", farmer_pubkey()) - ); - - let paths = sdk.storage_paths().expect("paths"); - let event_store = RadrootsEventStore::open_file(&paths.runtime_path) - .await - .expect("event store"); - assert_eq!( - event_store - .status_summary() - .await - .expect("event store status") - .total_events, - 0 - ); - assert!( - event_store - .raw_event(&prepared.expected_event_id().to_hex()) - .await - .expect("event lookup") - .is_none() - ); - let outbox = RadrootsOutbox::open_file(&paths.runtime_path) - .await - .expect("outbox"); - assert!( - outbox - .claim_next_ready_event("worker", "claim", 2_000, 1_700_000_000_000) - .await - .expect("claim") - .is_none() - ); -} - -#[tokio::test] -async fn farm_prepare_publish_rejects_non_farmer_actor() { - let (_tempdir, sdk) = directory_sdk().await; - let request = - FarmPreparePublishRequest::new(non_farmer_actor(), farm(FARM_B_D_TAG, "North Farm")); - - let error = sdk - .farms() - .prepare_publish(request) - .expect_err("non farmer"); - - assert!(matches!(error, RadrootsSdkError::UnauthorizedActor { .. })); -} - -#[tokio::test] -async fn farm_private_location_upsert_stores_exact_location_and_public_locality_without_events() { - let (tempdir, sdk) = directory_sdk().await; - let geocoder = fixture_geocoder(&tempdir).await; - let request = FarmPrivateLocationUpsertRequest::new( - farmer_actor(), - FARM_A_D_TAG, - SdkExactLocation::new(12.26, -34.51), - ) - .with_updated_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_123)); - - let receipt = sdk - .farms() - .upsert_private_location_with_geocoder(request, &geocoder) - .await - .expect("upsert private location"); - - assert_eq!( - receipt.farm_addr.as_str(), - format!("{KIND_FARM}:{}:{FARM_A_D_TAG}", farmer_pubkey()) - ); - assert_eq!(receipt.farm_pubkey, farmer_pubkey()); - assert_eq!(receipt.farm_d_tag, FARM_A_D_TAG); - assert_eq!(receipt.label, None); - assert_eq!(receipt.exact_location, SdkExactLocation::new(12.26, -34.51)); - assert_eq!(receipt.public_locality.primary, "Fixture Town"); - assert_eq!( - receipt.public_locality.city.as_deref(), - Some("Fixture Town") - ); - assert_eq!( - receipt.public_locality.region.as_deref(), - Some("Fixture Region") - ); - assert_eq!( - receipt.public_locality.country.as_deref(), - Some("Fixture Country") - ); - assert_eq!(receipt.public_locality.geohash5, "e4pmw"); - assert_eq!(receipt.geonames_feature_id, Some(1)); - assert_eq!(receipt.geonames_country_id.as_deref(), Some("FX")); - assert_eq!(receipt.updated_at_ms, 1_700_000_123_000); - let farm_public = receipt.public_locality.to_farm_public_location(); - assert_eq!(farm_public.primary, "Fixture Town"); - assert_eq!(farm_public.geohash, "e4pmw"); - let listing_public = receipt.public_locality.to_listing_public_location(); - assert_eq!(listing_public.primary, "Fixture Town"); - assert_eq!(listing_public.geohash, "e4pmw"); - - let stored = sdk - .farms() - .private_location(&receipt.farm_addr) - .await - .expect("private location read") - .expect("stored private location"); - assert_eq!(stored, receipt); - let status = sdk - .storage_status(StorageStatusRequest::new()) - .await - .expect("status"); - assert_eq!(status.private_store.farm_private_locations, 1); - assert_eq!(status.event_store.total_events, 0); - assert_eq!(status.outbox.total_events, 0); - - let clock_receipt = sdk - .farms() - .upsert_private_location_with_geocoder( - FarmPrivateLocationUpsertRequest::new( - farmer_actor(), - FARM_B_D_TAG, - SdkExactLocation::new(12.26, -34.51), - ), - &geocoder, - ) - .await - .expect("upsert with sdk clock"); - assert_eq!(clock_receipt.updated_at_ms, 1_700_000_000_000); -} - -#[tokio::test] -async fn farm_private_location_set_resolves_forward_inputs_and_preserves_no_mutation_failures() { - let (tempdir, sdk) = directory_sdk().await; - let geocoder = fixture_geocoder(&tempdir).await; - let actor = farmer_actor(); - - let exact = stored_location( - sdk.farms() - .set_private_location_with_geocoder( - FarmPrivateLocationSetRequest::exact( - actor.clone(), - FARM_A_D_TAG, - SdkExactLocation::new(12.26, -34.51), - ) - .with_label(" main pickup point ") - .with_updated_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_200)), - &geocoder, - ) - .await - .expect("exact set"), - ); - assert_eq!(exact.label.as_deref(), Some("main pickup point")); - assert_eq!(exact.geonames_feature_id, Some(1)); - assert_eq!(exact.updated_at_ms, 1_700_000_200_000); - - let city = stored_location( - sdk.farms() - .set_private_location_with_geocoder( - FarmPrivateLocationSetRequest::city( - actor.clone(), - FARM_B_D_TAG, - "Fixture Victoria", - ) - .with_updated_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_201)), - &geocoder, - ) - .await - .expect("city set"), - ); - assert_eq!(city.geonames_feature_id, Some(3001)); - assert_eq!(city.public_locality.primary, "Fixture Victoria"); - assert_eq!( - city.public_locality.region.as_deref(), - Some("British Columbia") - ); - assert_eq!(city.public_locality.country.as_deref(), Some("Canada")); - assert_eq!( - city.exact_location, - SdkExactLocation::new(48.4359, -123.35155) - ); - - let query = stored_location( - sdk.farms() - .set_private_location_with_geocoder( - FarmPrivateLocationSetRequest::query( - actor.clone(), - FARM_C_D_TAG, - "Fixture Victoria, BC, CA", - ), - &geocoder, - ) - .await - .expect("query set"), - ); - assert_eq!(query.geonames_feature_id, Some(3001)); - - let selected = stored_location( - sdk.farms() - .set_private_location_with_geocoder( - FarmPrivateLocationSetRequest::geonames_id(actor.clone(), FARM_D_D_TAG, 3004), - &geocoder, - ) - .await - .expect("id set"), - ); - assert_eq!(selected.geonames_feature_id, Some(3004)); - assert_eq!(selected.public_locality.primary, "Identifier Grove"); - - let narrowed = stored_location( - sdk.farms() - .set_private_location_with_geocoder( - FarmPrivateLocationSetRequest::new( - actor.clone(), - FARM_E_D_TAG, - FarmPrivateLocationInput::Locality( - GeocoderLocalityQuery::structured("Shared Market") - .with_region("Prairie Region") - .with_country("CA"), - ), - ), - &geocoder, - ) - .await - .expect("structured narrowed set"), - ); - assert_eq!(narrowed.geonames_feature_id, Some(3003)); - assert_eq!( - narrowed.public_locality.region.as_deref(), - Some("Prairie Region") - ); - - let before_failure_status = sdk - .storage_status(StorageStatusRequest::new()) - .await - .expect("pre-failure status") - .private_store - .farm_private_locations; - - let ambiguous = sdk - .farms() - .set_private_location_with_geocoder( - FarmPrivateLocationSetRequest::new( - actor.clone(), - FARM_F_D_TAG, - FarmPrivateLocationInput::Locality( - GeocoderLocalityQuery::structured("Shared Market").with_country("CA"), - ), - ), - &geocoder, - ) - .await - .expect("ambiguous set"); - let FarmPrivateLocationSetResult::Ambiguous(ambiguous) = ambiguous else { - panic!("expected ambiguous result"); - }; - assert_eq!( - ambiguous - .candidates - .iter() - .map(|candidate| candidate.geonames_feature_id) - .collect::<Vec<_>>(), - vec![3002, 3003] - ); - - let missing = sdk - .farms() - .set_private_location_with_geocoder( - FarmPrivateLocationSetRequest::query(actor.clone(), FARM_F_D_TAG, "Missing Market, CA"), - &geocoder, - ) - .await - .expect("missing set"); - assert!(matches!(missing, FarmPrivateLocationSetResult::NoMatch(_))); - - assert_eq!( - sdk.storage_status(StorageStatusRequest::new()) - .await - .expect("post-failure status") - .private_store - .farm_private_locations, - before_failure_status - ); - assert_eq!( - sdk.farms() - .private_location(&farm_addr(&actor, FARM_F_D_TAG)) - .await - .expect("failure location lookup"), - None - ); - - assert!(matches!( - sdk.farms() - .set_private_location_with_geocoder( - FarmPrivateLocationSetRequest::city(actor, FARM_F_D_TAG, "Fixture Victoria") - .with_label(" "), - &geocoder, - ) - .await, - Err(RadrootsSdkError::InvalidRequest { .. }) - )); -} - -#[tokio::test] -async fn farm_private_location_requires_farmer_role_and_valid_coordinates() { - let (tempdir, sdk) = directory_sdk().await; - let geocoder = fixture_geocoder(&tempdir).await; - let non_farmer = sdk - .farms() - .upsert_private_location_with_geocoder( - FarmPrivateLocationUpsertRequest::new( - non_farmer_actor(), - FARM_B_D_TAG, - SdkExactLocation::new(12.26, -34.51), - ), - &geocoder, - ) - .await - .expect_err("non farmer"); - assert!(matches!( - non_farmer, - RadrootsSdkError::UnauthorizedActor { .. } - )); - - let invalid = sdk - .farms() - .upsert_private_location_with_geocoder( - FarmPrivateLocationUpsertRequest::new( - farmer_actor(), - FARM_B_D_TAG, - SdkExactLocation::new(91.0, -34.51), - ), - &geocoder, - ) - .await - .expect_err("invalid coordinates"); - assert!(matches!(invalid, RadrootsSdkError::InvalidRequest { .. })); -} - -#[tokio::test] -async fn farm_private_location_requires_configured_geonames_for_default_upsert() { - let (_tempdir, sdk) = directory_sdk().await; - let error = sdk - .farms() - .upsert_private_location(FarmPrivateLocationUpsertRequest::new( - farmer_actor(), - FARM_C_D_TAG, - SdkExactLocation::new(12.26, -34.51), - )) - .await - .expect_err("missing geonames config"); - - assert!(matches!( - error, - RadrootsSdkError::GeoNames { - kind: RadrootsSdkGeoNamesErrorKind::Configuration, - .. - } - )); - assert_eq!(error.class(), RadrootsSdkErrorClass::Configuration); - assert_eq!( - error.recovery_actions(), - vec![RadrootsSdkRecoveryAction::ConfigureGeoNamesCache] - ); -} - -#[tokio::test] -async fn farm_enqueue_publish_stores_event_and_queues_signed_outbox_without_profile_event() { - let (_tempdir, sdk) = directory_sdk().await; - let request = FarmEnqueuePublishRequest::new( - farmer_actor(), - farm(FARM_B_D_TAG, "North Farm"), - TargetPolicy::default_profile(), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000229") - .expect("idempotency key"); - let prepared = sdk - .farms() - .prepare_publish(FarmPreparePublishRequest::new( - farmer_actor(), - farm(FARM_B_D_TAG, "North Farm"), - )) - .expect("prepared"); - let receipt = sdk - .farms() - .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(farmer_pubkey())) - .await - .expect("enqueue"); - - assert_eq!(&receipt.expected_event_id, prepared.expected_event_id()); - assert_eq!(receipt.signed_event_id, receipt.expected_event_id); - assert_eq!(&receipt.farm_addr, prepared.farm_addr()); - assert_eq!(receipt.local_event_seq, 1); - assert_eq!(receipt.outbox_operation_id, 1); - assert_eq!(receipt.outbox_event_id, 1); - assert_eq!(receipt.state, SdkMutationState::StoredAndQueued); - assert!(receipt.idempotency_digest_prefix.is_some()); - - let paths = sdk.storage_paths().expect("paths"); - let event_store = RadrootsEventStore::open_file(&paths.runtime_path) - .await - .expect("event store"); - let status = event_store - .status_summary() - .await - .expect("event store status"); - assert_eq!(status.total_events, 1); - let stored_event = event_store - .valid_event(&receipt.signed_event_id.to_hex()) - .await - .expect("event lookup") - .expect("stored event"); - let stored_event = stored_event.raw_event(); - assert_eq!(stored_event.kind, KIND_FARM); - assert_ne!(stored_event.kind, KIND_PROFILE); - assert_eq!( - stored_event.contract_id.as_deref(), - Some("radroots.farm.profile.v1") - ); - - let outbox = RadrootsOutbox::open_file(&paths.runtime_path) - .await - .expect("outbox"); - let outbox_event = outbox - .get_event(receipt.outbox_event_id) - .await - .expect("outbox event") - .expect("outbox event"); - assert_eq!(outbox_event.state, RadrootsOutboxEventState::Signed); - assert_eq!(outbox_event.draft.kind_u32(), KIND_FARM); - assert!(outbox_event.signed_event.is_some()); -} - -#[tokio::test] -async fn farm_enqueue_publish_returns_sanitized_signer_errors_before_mutation() { - let (_tempdir, sdk) = directory_sdk().await; - let request = FarmEnqueuePublishRequest::new( - farmer_actor(), - farm(FARM_C_D_TAG, "North Farm"), - TargetPolicy::default_profile(), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000238") - .expect("idempotency key"); - let error = sdk - .farms() - .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(other_pubkey())) - .await - .expect_err("signer error"); - let message = error.to_string(); - - assert!(matches!(error, RadrootsSdkError::UnauthorizedActor { .. })); - assert!(!message.contains("raw")); - assert!(!message.contains("ffff")); - - let paths = sdk.storage_paths().expect("paths"); - let event_store = RadrootsEventStore::open_file(&paths.runtime_path) - .await - .expect("event store"); - assert_eq!( - event_store - .status_summary() - .await - .expect("event store status") - .total_events, - 0 - ); - let outbox = RadrootsOutbox::open_file(&paths.runtime_path) - .await - .expect("outbox"); - assert!( - outbox - .claim_next_ready_event("worker", "claim", 2_000, 1_700_000_000_000) - .await - .expect("claim") - .is_none() - ); -} - -#[tokio::test] -async fn farm_enqueue_publish_uses_explicit_idempotency_key_across_equivalent_target_order() { - let (_tempdir, sdk) = directory_sdk().await; - let first = FarmEnqueuePublishRequest::new( - farmer_actor(), - farm(FARM_D_D_TAG, "North Farm"), - TargetPolicy::default_profile(), - ) - .try_with_nostr_targets([RELAY_B, RELAY], NostrRelayUrlPolicy::Public) - .expect("first transport targets") - .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000022d") - .expect("first idempotency key"); - let second = FarmEnqueuePublishRequest::new( - farmer_actor(), - farm(FARM_D_D_TAG, "North Farm"), - TargetPolicy::explicit( - TargetSet::nostr_relays([RELAY, RELAY_B], NostrRelayUrlPolicy::Public) - .expect("second transport targets"), - ), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000022d") - .expect("second idempotency key"); - - let first_receipt = sdk - .farms() - .enqueue_publish_with_explicit_signer(first, &FixtureSigner::new(farmer_pubkey())) - .await - .expect("first enqueue"); - let second_receipt = sdk - .farms() - .enqueue_publish_with_explicit_signer(second, &FixtureSigner::new(farmer_pubkey())) - .await - .expect("second enqueue"); - - assert_eq!( - first_receipt.outbox_event_id, - second_receipt.outbox_event_id - ); - assert_eq!( - first_receipt.idempotency_digest_prefix, - second_receipt.idempotency_digest_prefix - ); - assert_eq!(second_receipt.state, SdkMutationState::StoredAndQueued); - - let paths = sdk.storage_paths().expect("paths"); - let outbox = RadrootsOutbox::open_file(&paths.runtime_path) - .await - .expect("outbox"); - let relay_urls = outbox - .delivery_targets(first_receipt.outbox_event_id) - .await - .expect("delivery targets") - .into_iter() - .map(|target| target.endpoint_uri.to_string()) - .collect::<Vec<_>>(); - assert_eq!(relay_urls, vec![RELAY_B.to_owned(), RELAY.to_owned()]); -} - -#[tokio::test] -async fn farm_enqueue_publish_pushes_queued_event_with_mock_relay_sync() { - let (_tempdir, sdk) = directory_sdk().await; - let enqueue_request = FarmEnqueuePublishRequest::new( - farmer_actor(), - farm(FARM_D_D_TAG, "Sync Farm"), - TargetPolicy::default_profile(), - ) - .try_with_nostr_targets([RELAY], NostrRelayUrlPolicy::Public) - .expect("transport targets") - .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000022e") - .expect("idempotency key"); - let enqueue_receipt = sdk - .farms() - .enqueue_publish_with_explicit_signer(enqueue_request, &FixtureSigner::new(farmer_pubkey())) - .await - .expect("enqueue"); - let adapter = RadrootsMockRelayPublishAdapter::new(); - - let push_receipt = sdk - .sync() - .push_outbox_with_transport( - &RadrootsNostrTransport::new(&adapter), - PushOutboxRequest::new().with_limit(1), - ) - .await - .expect("push"); - - assert_eq!(push_receipt.attempted_events, 1); - assert_eq!(push_receipt.published_events, 1); - assert_eq!(push_receipt.retryable_events, 0); - assert_eq!(push_receipt.terminal_events, 0); - assert_eq!(push_receipt.events.len(), 1); - let event = &push_receipt.events[0]; - assert_eq!(event.event_id, enqueue_receipt.signed_event_id); - assert_eq!(event.outbox_event_id, enqueue_receipt.outbox_event_id); - assert_eq!(event.final_state, PushOutboxEventState::Published); - assert_eq!(event.attempted_count, 1); - assert_eq!(event.accepted_count, 1); - assert_eq!(event.retryable_count, 0); - assert_eq!(event.terminal_count, 0); - assert_eq!(event.quorum, 1); - assert!(event.quorum_met); - assert_eq!(event.targets.len(), 1); - assert_eq!(event.targets[0].endpoint_uri, RELAY); - assert_eq!( - event.targets[0].outcome_kind, - PushOutboxTargetOutcomeKind::Accepted - ); - assert_eq!(adapter.captured_raw_events().len(), 1); - - let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path) - .await - .expect("outbox"); - let stored = outbox - .get_event(enqueue_receipt.outbox_event_id) - .await - .expect("stored") - .expect("stored"); - assert_eq!(stored.state, RadrootsOutboxEventState::Published); -} - -#[tokio::test] -async fn farm_multi_target_profile_publishes_after_nostr_success_and_retains_reticulum() { - let (_tempdir, sdk) = multi_target_directory_sdk().await; - let enqueue_receipt = sdk - .farms() - .enqueue_publish_with_explicit_signer( - FarmEnqueuePublishRequest::new( - farmer_actor(), - farm(FARM_E_D_TAG, "MultiTarget Farm"), - TargetPolicy::default_profile(), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000022f") - .expect("idempotency key"), - &FixtureSigner::new(farmer_pubkey()), - ) - .await - .expect("enqueue"); - let adapter = RadrootsMockRelayPublishAdapter::new(); - - let push_receipt = sdk - .sync() - .push_outbox_with_transport( - &RadrootsNostrTransport::new(&adapter), - PushOutboxRequest::new().with_limit(1), - ) - .await - .expect("push"); - - assert_eq!(push_receipt.attempted_events, 1); - assert_eq!(push_receipt.published_events, 1); - assert_eq!(push_receipt.retryable_events, 0); - assert_eq!(push_receipt.terminal_events, 0); - let event = &push_receipt.events[0]; - assert_eq!(event.outbox_event_id, enqueue_receipt.outbox_event_id); - assert_eq!(event.final_state, PushOutboxEventState::Published); - assert_eq!(event.quorum, 1); - assert!(event.quorum_met); - assert_eq!(event.targets.len(), 1); - assert_eq!(event.targets[0].endpoint_uri, RELAY); - assert_eq!( - event.targets[0].outcome_kind, - PushOutboxTargetOutcomeKind::Accepted - ); - assert_eq!(adapter.captured_raw_events().len(), 1); - - let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").runtime_path) - .await - .expect("outbox"); - let stored = outbox - .get_event(enqueue_receipt.outbox_event_id) - .await - .expect("stored") - .expect("stored"); - assert_eq!(stored.state, RadrootsOutboxEventState::Published); - let plans = outbox - .delivery_plans(enqueue_receipt.outbox_event_id) - .await - .expect("delivery plans"); - assert_eq!(plans.len(), 1); - assert_eq!(plans[0].required_success_count, 1); - assert_eq!(plans[0].status, RadrootsOutboxDeliveryPlanStatus::Complete); - let targets = outbox - .delivery_targets(enqueue_receipt.outbox_event_id) - .await - .expect("delivery targets"); - assert_eq!(targets.len(), 2); - assert!(targets.iter().any(|target| { - target.endpoint_uri.to_string() == RELAY - && target.status == RadrootsOutboxDeliveryTargetStatus::Accepted - })); - assert!(targets.iter().any(|target| { - target.endpoint_uri.to_string() == "reticulum:local" - && target.status == RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented - && target.attempt_count == 0 - })); -} - -#[tokio::test] -async fn farm_enqueue_publish_reports_preflight_idempotency_conflict_without_mutation() { - let (_tempdir, sdk) = directory_sdk().await; - let first = FarmEnqueuePublishRequest::new( - farmer_actor(), - farm(FARM_E_D_TAG, "North Farm"), - TargetPolicy::default_profile(), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000022a") - .expect("idempotency key"); - sdk.farms() - .enqueue_publish_with_explicit_signer(first, &FixtureSigner::new(farmer_pubkey())) - .await - .expect("first enqueue"); - let paths = sdk.storage_paths().expect("paths"); - let event_store = RadrootsEventStore::open_file(&paths.runtime_path) - .await - .expect("event store"); - let outbox = RadrootsOutbox::open_file(&paths.runtime_path) - .await - .expect("outbox"); - assert_eq!( - event_store - .status_summary() - .await - .expect("event store status") - .total_events, - 1 - ); - assert_eq!( - outbox - .status_summary(0) - .await - .expect("outbox status") - .total_events, - 1 - ); - - let second = FarmEnqueuePublishRequest::new( - farmer_actor(), - farm(FARM_F_D_TAG, "Changed Farm"), - TargetPolicy::default_profile(), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000022a") - .expect("idempotency key"); - let error = sdk - .farms() - .enqueue_publish_with_explicit_signer(second, &FixtureSigner::new(farmer_pubkey())) - .await - .expect_err("conflict"); - - assert!(matches!( - error, - RadrootsSdkError::IdempotencyConflict { ref operation_kind, .. } - if operation_kind == FARM_PUBLISH_OPERATION_KIND - )); - assert_eq!( - error.recovery_actions(), - vec![RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey] - ); - assert!( - !error - .to_string() - .contains("01890f0e-6c00-7000-8000-00000000022a") - ); - assert_eq!( - event_store - .status_summary() - .await - .expect("event store status after conflict") - .total_events, - 1 - ); - assert_eq!( - outbox - .status_summary(0) - .await - .expect("outbox status after conflict") - .total_events, - 1 - ); -} - -#[tokio::test] -async fn farm_runtime_dtos_serialize_deterministically() { - let (_tempdir, sdk) = directory_sdk().await; - let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_123); - let prepare_request = - 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, - serde_json::json!({ - "actor": { - "pubkey": farmer_pubkey(), - "roles": ["farmer"], - "account_id": null, - "source": "explicit_public_key" - }, - "farm": { - "d_tag": FARM_A_D_TAG, - "name": "Serialized Farm", - "about": "Vegetable farm", - "website": "https://example.invalid/north-farm", - "picture": null, - "banner": null, - "location": null, - "tags": ["vegetables", "local"] - }, - "created_at": 1_700_000_123 - }) - ); - - let enqueue_request = FarmEnqueuePublishRequest::new( - farmer_actor(), - farm(FARM_B_D_TAG, "Queued Farm"), - TargetPolicy::default_profile(), - ) - .try_with_nostr_targets([RELAY, RELAY_B], NostrRelayUrlPolicy::Public) - .expect("relay targets") - .with_idempotency_key( - SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-00000000022b").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, - serde_json::json!({ - "actor": { - "pubkey": farmer_pubkey(), - "roles": ["farmer"], - "account_id": null, - "source": "explicit_public_key" - }, - "farm": { - "d_tag": FARM_B_D_TAG, - "name": "Queued Farm", - "about": "Vegetable farm", - "website": "https://example.invalid/north-farm", - "picture": null, - "banner": null, - "location": null, - "tags": ["vegetables", "local"] - }, - "target_policy": { - "kind": "explicit", - "targets": [ - { - "kind": "nostr", - "uri": RELAY, - "scope": null, - "label": null, - "fingerprint": "a1997ec4596596af6ffc65e6a30ab7cffa53ea71f524c1c86d64018b96d130af" - }, - { - "kind": "nostr", - "uri": RELAY_B, - "scope": null, - "label": null, - "fingerprint": "5136077cfe7eddcbfaddc5d7bf1f42cdbb8191f3691b86ccc3a81047851cef05" - } - ], - "canonical_targets": [ - "5136077cfe7eddcbfaddc5d7bf1f42cdbb8191f3691b86ccc3a81047851cef05", - "a1997ec4596596af6ffc65e6a30ab7cffa53ea71f524c1c86d64018b96d130af" - ] - }, - "idempotency_key": { "value": "<redacted>", "len": 36 }, - "created_at": 1_700_000_123 - }) - ); - assert!( - !enqueue_json - .to_string() - .contains("01890f0e-6c00-7000-8000-00000000022b") - ); - - let try_key_request = FarmEnqueuePublishRequest::new( - farmer_actor(), - farm(FARM_C_D_TAG, "Queued Farm"), - TargetPolicy::default_profile(), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000022c") - .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": 36 }) - ); - - 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_pubkey(), - "roles": ["farmer"], - "account_id": null, - "source": "explicit_public_key" - }, - "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_pubkey(), - "roles": ["farmer"], - "account_id": null, - "source": "explicit_public_key" - }, - "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_pubkey(), - "roles": ["farmer"], - "account_id": null, - "source": "explicit_public_key" - }, - "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_pubkey().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_pubkey().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_pubkey())) - .await - .expect("enqueue"); - let receipt_json = serde_json::to_value(&receipt).expect("receipt json"); - - assert_eq!( - receipt_json, - serde_json::json!({ - "farm_addr": receipt.farm_addr.as_str(), - "expected_event_id": receipt.expected_event_id.to_hex(), - "signed_event_id": receipt.signed_event_id.to_hex(), - "local_event_seq": 1, - "outbox_operation_id": 1, - "outbox_event_id": 1, - "state": "stored_and_queued", - "idempotency_digest_prefix": receipt.idempotency_digest_prefix.as_deref() - }) - ); -} diff --git a/crates/sdk/tests/package_boundary.rs b/crates/sdk/tests/package_boundary.rs @@ -3,6 +3,7 @@ use std::collections::BTreeSet; const MANIFEST: &str = include_str!("../Cargo.toml"); const ROOT: &str = include_str!("../src/lib.rs"); const CLIENT: &str = include_str!("../src/client.rs"); +const FARM: &str = include_str!("../src/farm.rs"); const SYNC: &str = include_str!("../src/sync.rs"); const TRANSPORT: &str = include_str!("../src/transport.rs"); @@ -195,6 +196,40 @@ fn sync_operations_only_delegate_to_the_canonical_engine() { ); } +#[test] +fn farm_operations_preserve_pure_planning_commit_and_privacy_boundaries() { + for required in [ + "encode::farm::to_wire_parts", + "AddressableCoordinate", + "EventDraft", + "radroots_sync::PushRequest::new", + "self.sync", + ".sign_and_enqueue(", + "PrivateArtifactStore", + ] { + assert!( + FARM.contains(required), + "missing farm boundary `{required}`" + ); + } + for forbidden in [ + "SdkExactLocation", + "SdkPublicLocality", + "latitude:", + "longitude:", + "enqueue_signed_workflow", + "local_event_seq", + "outbox_event_id", + ] { + assert!( + !FARM.contains(forbidden), + "farm source contains retired SDK or private representation `{forbidden}`" + ); + } + let source_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + assert!(!source_root.join("farms_runtime.rs").exists()); +} + fn dependency_names(manifest: &str) -> BTreeSet<&str> { let dependencies = manifest .split_once("[dependencies]") diff --git a/crates/sdk/tests/unit/farms_runtime_tests.rs b/crates/sdk/tests/unit/farms_runtime_tests.rs @@ -1,1090 +0,0 @@ -use super::*; -use crate::{RadrootsSdkLocalKeySigner, RadrootsSdkSignerProvider}; -use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; - -use crate::fixture_signer::{FixtureSigner, fixture_alice_pubkey, fixture_bob_pubkey}; -use crate::serializer_failure::assert_struct_serialize_error_paths; -use radroots_signing::actor::ActorSource; - -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_pubkey() -> &'static str { - fixture_alice_pubkey() -} - -fn farmer_actor() -> Actor { - Actor::from_public_key_hex( - farmer_pubkey(), - ActorSource::ExplicitPublicKey, - [AuthorRole::Farmer], - ) - .expect("actor") -} - -fn farm(d_tag: &str, name: &str) -> Farm { - Farm { - 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()]), - } -} - -async fn fixture_geocoder(tempdir: &tempfile::TempDir, feature_name: Option<&str>) -> Geocoder { - let path = tempdir.path().join(match feature_name { - Some(name) if name.trim().is_empty() => "geonames-blank.db", - Some(_) => "geonames-fixture.db", - None => "geonames-empty.db", - }); - let options = SqliteConnectOptions::new() - .filename(&path) - .create_if_missing(true); - let pool = SqlitePoolOptions::new() - .max_connections(1) - .connect_with(options) - .await - .expect("geonames fixture pool"); - sqlx::raw_sql( - r#" - CREATE TABLE countries( - id TEXT, - name TEXT, - PRIMARY KEY (id) - ); - CREATE TABLE admin1( - country_id TEXT, - id INTEGER, - name TEXT, - PRIMARY KEY (country_id, id) - ); - CREATE TABLE features( - id INTEGER, - name TEXT, - country_id TEXT, - admin1_id INTEGER, - PRIMARY KEY (id) - ); - CREATE TABLE coordinates( - feature_id INTEGER, - latitude REAL, - longitude REAL, - PRIMARY KEY (feature_id) - ); - CREATE INDEX coordinates_lat_lng ON coordinates (latitude, longitude); - CREATE VIEW geonames AS - SELECT - features.id, - features.name, - admin1.id AS admin1_id, - admin1.name AS admin1_name, - countries.id AS country_id, - countries.name AS country_name, - coordinates.latitude AS latitude, - coordinates.longitude AS longitude - FROM features - LEFT JOIN countries ON features.country_id = countries.id - LEFT JOIN admin1 ON features.country_id = admin1.country_id AND features.admin1_id = admin1.id - JOIN coordinates ON features.id = coordinates.feature_id; - "#, - ) - .execute(&pool) - .await - .expect("schema geonames fixture"); - if let Some(name) = feature_name { - sqlx::raw_sql( - r#" - INSERT INTO countries (id, name) VALUES ('FX', 'Fixture Country'); - INSERT INTO admin1 (country_id, id, name) VALUES ('FX', 1, 'Fixture Region'); - INSERT INTO coordinates (feature_id, latitude, longitude) VALUES (1, 12.25, -34.50); - "#, - ) - .execute(&pool) - .await - .expect("seed geonames fixture"); - sqlx::query( - "INSERT INTO features (id, name, country_id, admin1_id) VALUES (1, ?1, 'FX', 1)", - ) - .bind(name) - .execute(&pool) - .await - .expect("seed geonames feature"); - } - pool.close().await; - Geocoder::open_path(path).expect("open geonames fixture") -} - -#[test] -fn farm_publish_plan_rejects_invalid_draft_tags() { - let actor = Actor::from_public_key_hex( - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ActorSource::ExplicitPublicKey, - [AuthorRole::Farmer], - ) - .expect("actor"); - let farm = Farm { - 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), - ) - .expect_err("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_pubkey()); - assert_eq!(prepare_json["created_at"], 1_700_000_321); - - let enqueue = FarmEnqueuePublishRequest::new( - farmer_actor(), - farm(FARM_B_D_TAG, "Queued Farm"), - TargetPolicy::default_profile(), - ) - .try_with_nostr_targets([RELAY_A, RELAY_B], NostrRelayUrlPolicy::Public) - .expect("relay targets") - .with_idempotency_key( - SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000233").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_policy"]["kind"], "explicit"); - assert_eq!(enqueue_json["created_at"], 1_700_000_321); - assert!( - !enqueue_json - .to_string() - .contains("01890f0e-6c00-7000-8000-000000000233") - ); - - let try_key = FarmEnqueuePublishRequest::new( - farmer_actor(), - farm(FARM_C_D_TAG, "Try Key Farm"), - TargetPolicy::default_profile(), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000234") - .expect("try key"); - assert_eq!( - serde_json::to_value(&try_key).expect("try key json")["idempotency_key"]["len"], - "01890f0e-6c00-7000-8000-000000000234".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_pubkey().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_pubkey().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] -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"), - TargetPolicy::default_profile(), - ) - .try_with_nostr_targets(["http://relay.radroots.test"], NostrRelayUrlPolicy::Public); - assert!(invalid_relays.is_err()); - - let invalid_key = FarmEnqueuePublishRequest::new( - farmer_actor(), - farm(FARM_B_D_TAG, "Invalid Key Farm"), - TargetPolicy::default_profile(), - ) - .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), - ) - .expect_err("timestamp error"); - assert!(matches!( - timestamp_error, - RadrootsSdkError::TimestampOutOfRange { .. } - )); -} - -#[test] -fn farm_public_locality_derivation_covers_country_fallback_and_empty_names() { - let reverse = GeocoderReverseResult { - id: 1, - name: " Fixture Town ".to_owned(), - admin1_id: None, - admin1_name: None, - country_id: "FX".to_owned(), - country_name: None, - latitude: 12.25, - longitude: -34.50, - }; - let locality = public_locality_from_reverse(SdkExactLocation::new(12.26, -34.51), &reverse) - .expect("locality"); - assert_eq!(locality.primary, "Fixture Town"); - assert_eq!(locality.city.as_deref(), Some("Fixture Town")); - assert_eq!(locality.region, None); - assert_eq!(locality.country.as_deref(), Some("FX")); - assert_eq!(locality.geohash5, "e4pmw"); - - let named_region = GeocoderReverseResult { - admin1_name: Some(" Fixture Region ".to_owned()), - country_name: Some(" Fixture Country ".to_owned()), - ..reverse.clone() - }; - let named_locality = - public_locality_from_reverse(SdkExactLocation::new(12.26, -34.51), &named_region) - .expect("named locality"); - assert_eq!(named_locality.region.as_deref(), Some("Fixture Region")); - assert_eq!(named_locality.country.as_deref(), Some("Fixture Country")); - - let blank_optional_names = GeocoderReverseResult { - admin1_name: Some(" ".to_owned()), - country_name: Some(" ".to_owned()), - ..reverse.clone() - }; - let fallback_locality = - public_locality_from_reverse(SdkExactLocation::new(12.26, -34.51), &blank_optional_names) - .expect("fallback locality"); - assert_eq!(fallback_locality.region, None); - assert_eq!(fallback_locality.country.as_deref(), Some("FX")); - - let blank_name = GeocoderReverseResult { - name: " ".to_owned(), - ..reverse - }; - assert!(matches!( - public_locality_from_reverse(SdkExactLocation::new(12.26, -34.51), &blank_name), - Err(RadrootsSdkError::GeoNames { - kind: crate::RadrootsSdkGeoNamesErrorKind::Lookup, - .. - }) - )); - - for location in [ - SdkExactLocation::new(f64::NAN, -34.51), - SdkExactLocation::new(12.26, f64::INFINITY), - SdkExactLocation::new(-90.1, -34.51), - SdkExactLocation::new(90.1, -34.51), - SdkExactLocation::new(12.26, -180.1), - SdkExactLocation::new(12.26, 180.1), - ] { - assert!(matches!( - validate_exact_location(location), - Err(RadrootsSdkError::InvalidRequest { .. }) - )); - assert!(matches!( - geohash5(location), - 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 { .. }) - )); -} - -#[tokio::test] -async fn farm_client_prepare_resolves_default_and_explicit_created_at() { - let sdk = crate::RadrootsClient::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_prepared_plan_rejects_forged_state_before_signing_or_mutation() { - let sdk = crate::RadrootsClient::builder() - .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_450)) - .build() - .await - .expect("sdk"); - let actor = farmer_actor(); - let plan = sdk - .farms() - .prepare_publish(FarmPreparePublishRequest::new( - actor.clone(), - farm(FARM_A_D_TAG, "Sealed Plan Farm"), - )) - .expect("plan"); - assert_eq!( - serde_json::to_value(&plan).expect("plan json"), - serde_json::json!({ - "farm_addr": plan.farm_addr(), - "expected_event_id": plan.expected_event_id(), - "frozen_draft": plan.frozen_draft(), - "created_at": plan.created_at(), - }) - ); - // Frozen drafts enforce registry contract-kind consistency, so a valid foreign pair is the - // safe representable substitution for both identity fields. - let foreign_draft = EventDraft::new( - "radroots.social.geochat.v1", - radroots_event::envelope::kind::KIND_GEOCHAT, - plan.created_at().unix_seconds(), - Vec::new(), - "Foreign draft", - farmer_pubkey(), - ) - .expect("valid foreign draft"); - let mut forged_contract_kind = plan.clone(); - forged_contract_kind.frozen_draft = foreign_draft; - assert!(matches!( - validate_farm_publish_plan(&forged_contract_kind), - Err(RadrootsSdkError::InvalidRequest { ref message }) - if message.contains("contract or kind") - )); - - let mut forged_event_id = plan.clone(); - forged_event_id.expected_event_id = - EventId::parse(fixture_bob_pubkey()).expect("alternate event ID"); - assert!(matches!( - validate_farm_publish_plan(&forged_event_id), - Err(RadrootsSdkError::InvalidRequest { ref message }) - if message.contains("expected event ID") - )); - - let mut forged_address = plan.clone(); - forged_address.farm_addr = - AddressableCoordinate::parse(format!("{KIND_FARM}:{}:{FARM_B_D_TAG}", farmer_pubkey())) - .expect("alternate farm address"); - assert!(matches!( - validate_farm_publish_plan(&forged_address), - Err(RadrootsSdkError::InvalidRequest { ref message }) - if message.contains("farm address") - )); - - let mut forged_created_at = plan.clone(); - forged_created_at.created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_451); - assert!(matches!( - validate_farm_publish_plan(&forged_created_at), - Err(RadrootsSdkError::InvalidRequest { ref message }) - if message.contains("created-at timestamp") - )); - - let alternate_plan = sdk - .farms() - .prepare_publish(FarmPreparePublishRequest::new( - actor.clone(), - farm(FARM_B_D_TAG, "Alternate Plan Farm"), - )) - .expect("alternate plan"); - let mut forged_draft = plan; - forged_draft.frozen_draft = alternate_plan.frozen_draft; - assert!(matches!( - validate_farm_publish_plan(&forged_draft), - Err(RadrootsSdkError::InvalidRequest { ref message }) - if message.contains("expected event ID") - )); - - let error = sdk - .farms() - .enqueue_prepared_publish_with_explicit_signer( - &actor, - forged_contract_kind, - TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) - .expect("transport targets"), - Some( - SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000445") - .expect("idempotency"), - ), - &FixtureSigner::new(fixture_bob_pubkey()), - ) - .await - .expect_err("forged plan"); - assert!(matches!( - error, - RadrootsSdkError::InvalidRequest { ref message } - if message.contains("contract or kind") - )); - let error = sdk - .farms() - .enqueue_prepared_publish( - &actor, - forged_address, - TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) - .expect("transport targets"), - Some( - SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000446") - .expect("idempotency"), - ), - ) - .await - .expect_err("forged configured-signer plan"); - assert!(matches!( - error, - RadrootsSdkError::InvalidRequest { ref message } - if message.contains("farm address") - )); - assert_eq!( - sdk._event_store - .status_summary() - .await - .expect("event store status") - .total_events, - 0 - ); - assert_eq!( - sdk._outbox - .status_summary(0) - .await - .expect("outbox status") - .total_events, - 0 - ); - let journal_count: i64 = - sqlx::query_scalar("SELECT COUNT(*) FROM sdk_runtime_operation_journal") - .fetch_one(sdk._event_store.pool()) - .await - .expect("journal count"); - assert_eq!(journal_count, 0); -} - -#[tokio::test] -async fn farm_client_prepare_reports_clock_errors() { - let sdk = crate::RadrootsClient::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::RadrootsClient::builder() - .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500)) - .build() - .await - .expect("sdk"); - let error = sdk - .farms() - .enqueue_publish_with_explicit_signer( - FarmEnqueuePublishRequest::new( - farmer_actor(), - farm("AAAAAAAAAAAAAAAAAAAAA!", "Invalid Enqueue Farm"), - TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) - .expect("transport targets"), - ), - &FixtureSigner::new(farmer_pubkey()), - ) - .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::RadrootsClient::builder() - .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500)) - .build() - .await - .expect("sdk"); - let signer = FixtureSigner::new(farmer_pubkey()); - let actor = farmer_actor(); - let receipt = sdk - .farms() - .enqueue_publish_with_explicit_signer( - FarmEnqueuePublishRequest::new( - actor.clone(), - farm(FARM_A_D_TAG, "Enqueued Farm"), - TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) - .expect("transport targets"), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000235") - .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_with_explicit_signer( - &actor, - plan, - TargetPolicy::try_nostr_relays([RELAY_B], NostrRelayUrlPolicy::Public) - .expect("prepared transport targets"), - Some( - SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000236") - .expect("prepared idempotency"), - ), - &signer, - ) - .await - .expect("enqueue prepared farm"); - assert_eq!(prepared.signed_event_id, prepared.expected_event_id); - assert_eq!(prepared.local_event_seq, 2); -} - -#[tokio::test] -async fn farm_configured_local_signer_enqueues_publish_without_explicit_signer() { - let sdk = crate::RadrootsClient::builder() - .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500)) - .signer_provider(RadrootsSdkSignerProvider::LocalKey( - RadrootsSdkLocalKeySigner::from_signer( - FixtureSigner::new(farmer_pubkey()), - farmer_pubkey(), - ) - .expect("signer"), - )) - .build() - .await - .expect("sdk"); - let actor = Actor::from_public_key_hex( - farmer_pubkey(), - ActorSource::ExplicitPublicKey, - [AuthorRole::Farmer], - ) - .expect("actor"); - - let receipt = sdk - .farms() - .enqueue_publish( - FarmEnqueuePublishRequest::new( - actor, - farm(FARM_C_D_TAG, "Configured Farm"), - TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) - .expect("transport targets"), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000236") - .expect("idempotency"), - ) - .await - .expect("enqueue farm"); - - assert_eq!(receipt.signed_event_id, receipt.expected_event_id); - assert_eq!(receipt.state, SdkMutationState::StoredAndQueued); -} - -#[tokio::test] -async fn farm_configured_enqueue_reports_prepare_and_signer_errors() { - let configured_sdk = crate::RadrootsClient::builder() - .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500)) - .signer_provider(RadrootsSdkSignerProvider::LocalKey( - RadrootsSdkLocalKeySigner::from_signer( - FixtureSigner::new(farmer_pubkey()), - farmer_pubkey(), - ) - .expect("signer"), - )) - .build() - .await - .expect("configured sdk"); - let actor = Actor::from_public_key_hex( - farmer_pubkey(), - ActorSource::ExplicitPublicKey, - [AuthorRole::Farmer], - ) - .expect("actor"); - - assert!(matches!( - configured_sdk - .farms() - .enqueue_publish(FarmEnqueuePublishRequest::new( - actor.clone(), - farm("AAAAAAAAAAAAAAAAAAAAA!", "Invalid Configured Farm"), - TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) - .expect("transport targets"), - )) - .await, - Err(RadrootsSdkError::InvalidRequest { .. }) - )); - - let no_signer_sdk = crate::RadrootsClient::builder() - .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500)) - .build() - .await - .expect("no signer sdk"); - let plan = no_signer_sdk - .farms() - .prepare_publish(FarmPreparePublishRequest::new( - actor.clone(), - farm(FARM_A_D_TAG, "Missing Configured Signer Farm"), - )) - .expect("plan"); - assert!(matches!( - no_signer_sdk - .farms() - .enqueue_prepared_publish( - &actor, - plan, - TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) - .expect("transport targets"), - Some( - SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000237") - .expect("idempotency"), - ), - ) - .await, - Err(RadrootsSdkError::SignerUnavailable { .. }) - )); -} - -#[tokio::test] -async fn farm_private_location_default_client_and_lookup_report_store_edges() { - let tempdir = tempfile::tempdir().expect("tempdir"); - let geocoder = fixture_geocoder(&tempdir, Some("Fixture Town")).await; - let empty_geocoder = fixture_geocoder(&tempdir, None).await; - let blank_geocoder = fixture_geocoder(&tempdir, Some(" ")).await; - let sdk = crate::RadrootsClient::builder() - .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500)) - .build() - .await - .expect("sdk"); - let actor = farmer_actor(); - let request = FarmPrivateLocationUpsertRequest::new( - actor.clone(), - FARM_A_D_TAG, - SdkExactLocation::new(12.26, -34.51), - ); - - assert!(matches!( - sdk.farms().upsert_private_location(request).await, - Err(RadrootsSdkError::GeoNames { - kind: crate::RadrootsSdkGeoNamesErrorKind::Configuration, - .. - }) - )); - 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!( - sdk.farms() - .private_location(&farm_a_addr) - .await - .expect("missing location"), - None - ); - - let stored = sdk - .farms() - .upsert_private_location_with_geocoder( - FarmPrivateLocationUpsertRequest::new( - actor.clone(), - FARM_A_D_TAG, - SdkExactLocation::new(12.26, -34.51), - ) - .with_updated_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_501)), - &geocoder, - ) - .await - .expect("stored location"); - assert_eq!(stored.farm_addr, farm_a_addr); - assert_eq!(stored.public_locality.primary, "Fixture Town"); - assert_eq!( - sdk.farms() - .private_location(&farm_a_addr) - .await - .expect("stored lookup") - .expect("stored location") - .updated_at_ms, - 1_700_000_501_000 - ); - - let missing_clear = sdk - .farms() - .clear_private_location(FarmPrivateLocationClearRequest::new( - actor.clone(), - FARM_B_D_TAG, - )) - .await - .expect("clear missing location"); - assert!(!missing_clear.cleared); - assert_eq!( - missing_clear.farm_addr, - farm_addr(&actor, FARM_B_D_TAG).expect("farm b addr") - ); - - let non_farmer_actor = Actor::from_public_key_hex( - farmer_pubkey(), - ActorSource::ExplicitPublicKey, - [AuthorRole::Buyer], - ) - .expect("buyer actor"); - assert!(matches!( - sdk.farms() - .clear_private_location(FarmPrivateLocationClearRequest::new( - non_farmer_actor, - FARM_A_D_TAG, - )) - .await, - Err(RadrootsSdkError::UnauthorizedActor { .. }) - )); - assert!(matches!( - sdk.farms() - .clear_private_location(FarmPrivateLocationClearRequest::new( - actor.clone(), - "bad d tag", - )) - .await, - Err(RadrootsSdkError::InvalidRequest { .. }) - )); - - let cleared = sdk - .farms() - .clear_private_location(FarmPrivateLocationClearRequest::new( - actor.clone(), - FARM_A_D_TAG, - )) - .await - .expect("clear stored location"); - assert!(cleared.cleared); - assert_eq!(cleared.farm_addr, farm_a_addr); - assert_eq!( - sdk.farms() - .private_location(&farm_a_addr) - .await - .expect("location cleared"), - None - ); - - assert!(matches!( - sdk.farms() - .upsert_private_location_with_geocoder( - FarmPrivateLocationUpsertRequest::new( - actor.clone(), - FARM_B_D_TAG, - SdkExactLocation::new(12.26, -34.51), - ) - .with_updated_at(RadrootsSdkTimestamp::from_unix_seconds(u64::MAX)), - &geocoder, - ) - .await, - Err(RadrootsSdkError::TimestampOutOfRange { .. }) - )); - assert!(matches!( - sdk.farms() - .upsert_private_location_with_geocoder( - FarmPrivateLocationUpsertRequest::new( - actor.clone(), - FARM_B_D_TAG, - SdkExactLocation::new(12.26, -34.51), - ), - &empty_geocoder, - ) - .await, - Err(RadrootsSdkError::GeoNames { - kind: crate::RadrootsSdkGeoNamesErrorKind::Lookup, - .. - }) - )); - assert!(matches!( - sdk.farms() - .upsert_private_location_with_geocoder( - FarmPrivateLocationUpsertRequest::new( - actor.clone(), - "bad d tag", - SdkExactLocation::new(12.26, -34.51), - ), - &geocoder, - ) - .await, - Err(RadrootsSdkError::InvalidRequest { .. }) - )); - assert!(matches!( - sdk.farms() - .upsert_private_location_with_geocoder( - FarmPrivateLocationUpsertRequest::new( - actor.clone(), - FARM_B_D_TAG, - SdkExactLocation::new(12.26, -34.51), - ), - &blank_geocoder, - ) - .await, - Err(RadrootsSdkError::GeoNames { - kind: crate::RadrootsSdkGeoNamesErrorKind::Lookup, - .. - }) - )); - let clock_error_sdk = crate::RadrootsClient::builder() - .clock(crate::RadrootsSdkClock::BeforeUnixEpoch) - .build() - .await - .expect("clock error sdk"); - assert!(matches!( - clock_error_sdk - .farms() - .upsert_private_location_with_geocoder( - FarmPrivateLocationUpsertRequest::new( - actor.clone(), - FARM_B_D_TAG, - SdkExactLocation::new(12.26, -34.51), - ), - &geocoder, - ) - .await, - Err(RadrootsSdkError::ClockBeforeUnixEpoch) - )); - - sdk._private_store.pool().close().await; - assert!(matches!( - sdk.farms() - .upsert_private_location_with_geocoder( - FarmPrivateLocationUpsertRequest::new( - actor.clone(), - FARM_C_D_TAG, - SdkExactLocation::new(12.26, -34.51), - ), - &geocoder, - ) - .await, - Err(RadrootsSdkError::PrivateStore { .. }) - )); - assert!(matches!( - sdk.farms().private_location(&farm_a_addr).await, - Err(RadrootsSdkError::PrivateStore { .. }) - )); - assert!(matches!( - sdk.farms() - .clear_private_location(FarmPrivateLocationClearRequest::new(actor, FARM_A_D_TAG)) - .await, - Err(RadrootsSdkError::PrivateStore { .. }) - )); -}