lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 7b48b68af0eaa118340c4e1ef128e4bc2972f06b
parent ffe16540eed12831ee3b62b8a8f1abd6ff2f8682
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 11:31:43 +0000

sdk: refactor listing operations

- freeze publish and update plans through canonical trade and event contracts
- delegate durable listing commits to shared sync with native receipts
- preserve privacy transport idempotency and cancellation boundaries
- remove the retired duplicate listing runtime and tests

Diffstat:
Mcrates/sdk/src/client.rs | 6++++++
Mcrates/sdk/src/listing.rs | 597++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Dcrates/sdk/src/listings_runtime.rs | 430-------------------------------------------------------------------------------
Dcrates/sdk/tests/listings_runtime.rs | 1001-------------------------------------------------------------------------------
Mcrates/sdk/tests/package_boundary.rs | 37+++++++++++++++++++++++++++++++++++++
Dcrates/sdk/tests/unit/listings_runtime_tests.rs | 600-------------------------------------------------------------------------------
6 files changed, 639 insertions(+), 2032 deletions(-)

diff --git a/crates/sdk/src/client.rs b/crates/sdk/src/client.rs @@ -245,6 +245,12 @@ impl Client { Ok(self.sync()?.map(crate::farm::Operations::new)) } + /// Returns listing commit operations when canonical synchronization is configured. + #[cfg(feature = "sync")] + pub fn listing(&self) -> Result<Option<crate::listing::Operations<'_>>> { + Ok(self.sync()?.map(crate::listing::Operations::new)) + } + /// Returns whether explicit close completed successfully or reached the /// lower storage commit point. #[must_use] diff --git a/crates/sdk/src/listing.rs b/crates/sdk/src/listing.rs @@ -1 +1,596 @@ -//! Listing product operations. +//! Side-effect-free listing planning and canonical durable enqueue operations. + +use std::{error, fmt}; + +use radroots_event::{EventDraft, contract::AuthorRole, id::ClassifiedListingAddress}; +use radroots_signing::Actor; +use radroots_trade::operational_listing::{ + RadrootsOperationalListingEditDocumentV1, RadrootsOperationalListingEditError, + RadrootsOperationalListingLifecycleState, RadrootsOperationalListingMutation, + RadrootsOperationalListingMutationError, build_operational_listing_mutation_draft, + canonicalize_operational_listing_edit, +}; + +/// Supported public listing mutation intent. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Action { + /// Publish the first public version of a listing. + Publish, + /// Replace an existing addressable listing with a new public version. + Update, +} + +/// Pure inputs for one frozen public listing plan. +#[derive(Clone, Debug)] +pub struct PrepareRequest { + actor: Actor, + document: RadrootsOperationalListingEditDocumentV1, + action: Action, + created_at_unix: u64, +} + +impl PrepareRequest { + /// Creates a public listing publication request. + #[must_use] + pub const fn publish( + actor: Actor, + document: RadrootsOperationalListingEditDocumentV1, + created_at_unix: u64, + ) -> Self { + Self { + actor, + document, + action: Action::Publish, + created_at_unix, + } + } + + /// Creates a public listing replacement request. + #[must_use] + pub const fn update( + actor: Actor, + document: RadrootsOperationalListingEditDocumentV1, + created_at_unix: u64, + ) -> Self { + Self { + actor, + document, + action: Action::Update, + created_at_unix, + } + } +} + +/// Frozen, replay-stable public listing mutation plan. +#[derive(Clone, Debug)] +pub struct Plan { + actor: Actor, + action: Action, + address: ClassifiedListingAddress, + lifecycle: RadrootsOperationalListingLifecycleState, + draft: EventDraft, +} + +impl Plan { + /// Returns the exact authorized actor carried into signing. + #[must_use] + pub const fn actor(&self) -> &Actor { + &self.actor + } + + /// Returns the requested listing mutation intent. + #[must_use] + pub const fn action(&self) -> Action { + self.action + } + + /// Returns the canonical addressable listing identity. + #[must_use] + pub const fn address(&self) -> &ClassifiedListingAddress { + &self.address + } + + /// Returns the lower-owned lifecycle state resulting from the mutation. + #[must_use] + pub const fn lifecycle(&self) -> RadrootsOperationalListingLifecycleState { + self.lifecycle + } + + /// Returns the frozen canonical event draft. + #[must_use] + pub const fn draft(&self) -> &EventDraft { + &self.draft + } +} + +/// Listing plan validation stage. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum PrepareErrorKind { + /// The actor does not claim the seller author role. + UnauthorizedActor, + /// The lower trade boundary rejected the untrusted edit document. + Edit, + /// The lower trade/event boundary rejected mutation preparation. + Mutation, +} + +/// One secret-safe listing 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 edit(source: RadrootsOperationalListingEditError) -> Self { + Self::with_source(PrepareErrorKind::Edit, source) + } + + fn mutation(source: RadrootsOperationalListingMutationError) -> Self { + Self::with_source(PrepareErrorKind::Mutation, 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 => "listing actor is not authorized", + PrepareErrorKind::Edit => "listing edit is invalid", + PrepareErrorKind::Mutation => "listing mutation 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 listing mutation without storage, signing, or network work. +/// +/// The canonical public model permits only coarse public locality. Exact +/// coordinates and other private artifacts are deliberately absent; hosts +/// persist those through `radroots_storage::private_artifact::PrivateArtifactStore`. +pub fn prepare(request: PrepareRequest) -> Result<Plan, PrepareError> { + if !request.actor.satisfies(AuthorRole::Seller) { + return Err(PrepareError::unauthorized_actor()); + } + let canonical = + canonicalize_operational_listing_edit(request.actor.public_key(), request.document) + .map_err(PrepareError::edit)?; + let address = canonical.public_listing_addr().clone(); + let mutation = match request.action { + Action::Publish => RadrootsOperationalListingMutation::publish(canonical), + Action::Update => RadrootsOperationalListingMutation::update(canonical), + }; + let lifecycle = mutation.lifecycle_state().map_err(PrepareError::mutation)?; + let draft = build_operational_listing_mutation_draft(&mutation, request.created_at_unix) + .map_err(PrepareError::mutation)?; + Ok(Plan { + actor: request.actor, + action: request.action, + address, + lifecycle, + 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 public listing mutation. +#[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 listing 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 public listing mutation. + 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_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; + use radroots_event::{ + envelope::kind::KIND_CLASSIFIED_LISTING, + farm::FarmRef, + id::{DTag, InventoryBinId}, + listing::operational::{ + OperationalListing, OperationalListingAvailability, OperationalListingBin, + OperationalListingDeliveryMethod, OperationalListingProduct, + OperationalListingPublicLocation, OperationalListingStatus, + }, + }; + 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 listing(seller: &str) -> OperationalListing { + OperationalListing { + d_tag: DTag::parse("AAAAAAAAAAAAAAAAAAAAAg").expect("d tag"), + published_at: None, + farm: FarmRef { + pubkey: seller.to_owned(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(), + }, + product: OperationalListingProduct { + key: "coffee".to_owned(), + title: "Coffee".to_owned(), + category: "coffee".to_owned(), + summary: Some("Single origin coffee".to_owned()), + process: None, + lot: None, + location: None, + profile: None, + year: None, + }, + primary_bin_id: InventoryBinId::parse("bin-1").expect("bin id"), + bins: vec![OperationalListingBin { + bin_id: InventoryBinId::parse("bin-1").expect("bin id"), + quantity: Quantity::try_new(Decimal::from(1000_u32), Unit::MassG) + .expect("quantity"), + price_per_canonical_unit: QuantityPrice::try_new( + Money::try_new(Decimal::from(20_u32), Currency::USD).expect("money"), + Quantity::try_new(Decimal::from(1_u32), Unit::MassG).expect("unit"), + ) + .expect("price"), + display_amount: None, + display_unit: None, + display_label: None, + display_price: None, + display_price_unit: None, + }], + resource_area: None, + plot: None, + discounts: None, + inventory_available: Some(Decimal::from(5_u32)), + availability: Some(OperationalListingAvailability::Status { + status: OperationalListingStatus::Active, + }), + delivery_method: Some(OperationalListingDeliveryMethod::Pickup), + location: Some(OperationalListingPublicLocation { + 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(), + }), + images: None, + } + } + + fn document(seller: &str) -> RadrootsOperationalListingEditDocumentV1 { + RadrootsOperationalListingEditDocumentV1::new(listing(seller)) + } + + #[test] + fn prepare_publish_and_update_are_pure_canonical_and_privacy_bounded() { + let publish_request = PrepareRequest::publish( + actor(AuthorRole::Seller), + document(PUBLIC_KEY), + 1_800_000_000, + ); + let first = prepare(publish_request.clone()).expect("publish plan"); + let replay = prepare(publish_request).expect("replayed plan"); + let update = prepare(PrepareRequest::update( + actor(AuthorRole::Seller), + document(PUBLIC_KEY), + 1_800_000_001, + )) + .expect("update plan"); + + assert_eq!(first.action(), Action::Publish); + assert_eq!(update.action(), Action::Update); + assert_eq!( + first.lifecycle(), + RadrootsOperationalListingLifecycleState::Published + ); + assert_eq!( + update.lifecycle(), + RadrootsOperationalListingLifecycleState::Published + ); + assert_eq!(first.address(), replay.address()); + assert_eq!(first.draft(), replay.draft()); + assert_eq!(first.address(), update.address()); + assert_eq!(first.draft().kind_u32(), KIND_CLASSIFIED_LISTING); + assert_eq!(first.draft().created_at_u64(), 1_800_000_000); + assert!(!first.draft().content().contains("latitude")); + assert!(!first.draft().content().contains("longitude")); + } + + #[test] + fn prepare_maps_authorization_and_lower_validation_classes_once() { + let unauthorized = prepare(PrepareRequest::publish( + actor(AuthorRole::Buyer), + document(PUBLIC_KEY), + 1_800_000_000, + )) + .expect_err("unauthorized"); + assert_eq!(unauthorized.kind(), PrepareErrorKind::UnauthorizedActor); + assert!(std::error::Error::source(&unauthorized).is_none()); + + let mut invalid = listing(PUBLIC_KEY); + invalid.product.title.clear(); + let edit = prepare(PrepareRequest::publish( + actor(AuthorRole::Seller), + RadrootsOperationalListingEditDocumentV1::new(invalid), + 1_800_000_000, + )) + .expect_err("invalid listing"); + assert_eq!(edit.kind(), PrepareErrorKind::Edit); + assert!(std::error::Error::source(&edit).is_some()); + assert_eq!(edit.to_string(), "listing edit is invalid"); + assert!(!format!("{edit:?}").contains("title")); + + let mismatch = prepare(PrepareRequest::update( + actor(AuthorRole::Seller), + document("8f"), + 1_800_000_000, + )) + .expect_err("invalid seller identity"); + assert_eq!(mismatch.kind(), PrepareErrorKind::Edit); + } + + #[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 super::*; + use crate::{ClientBuilder, transport::Profile}; + + 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_transport_outcomes() { + let storage = Arc::new(MemoryStorage::new( + SourceGeneration::new([5; 32]).expect("generation"), + )); + let signer = + Arc::new(radroots_nostr::signing::LocalSigner::generate().expect("local signer")); + let seller = Actor::new( + signer.public_key(), + ActorSource::ExplicitPublicKey, + [AuthorRole::Seller], + ) + .expect("actor"); + let plan = prepare(PrepareRequest::publish( + seller, + document(&signer.public_key().to_hex()), + 1_800_000_000, + )) + .expect("plan"); + let targets = TargetSet::new(vec![ + Target::nostr_relay("wss://listing.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.listing().expect("open").expect("listing operations"); + let request = EnqueueRequest::new( + SyncId::new([9; 16]).expect("operation id"), + IdempotencyKey::parse("listing-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([10; 16]).expect("operation id"), + IdempotencyKey::parse("listing-update-preview").expect("idempotency key"), + prepare(PrepareRequest::update( + actor(AuthorRole::Seller), + document(PUBLIC_KEY), + 1_800_000_001, + )) + .expect("plan"), + Profile::unavailable_preview(TransportId::RETICULUM), + CancellationPolicy::PreservePublishedRequest, + ); + assert_eq!( + operations.enqueue(unavailable).await, + Err(Error::InvalidPushRequest) + ); + } + } +} diff --git a/crates/sdk/src/listings_runtime.rs b/crates/sdk/src/listings_runtime.rs @@ -1,430 +0,0 @@ -#[cfg(feature = "signer-adapters")] -use crate::workflow_runtime::enqueue_configured_signed_workflow; -#[cfg(feature = "runtime")] -use crate::{ - ListingsClient, NostrRelayUrlPolicy, RadrootsSdkError, RadrootsSdkTimestamp, - SatisfactionPolicy, SdkIdempotencyKey, TargetPolicy, - workflow_runtime::{SdkWorkflowEnqueueRequest, enqueue_signed_workflow}, -}; -#[cfg(feature = "runtime")] -use radroots_event::{ - contract::AuthorRole, - draft::EventDraft, - envelope::kind::KIND_CLASSIFIED_LISTING, - id::{ClassifiedListingAddress, EventId}, - listing::operational::OperationalListing, -}; -#[cfg(feature = "runtime")] -use radroots_outbox::RadrootsOutboxEnqueueStatus; -#[cfg(feature = "runtime")] -use radroots_signing::{Actor, Signer}; -#[cfg(feature = "runtime")] -use radroots_trade::operational_listing::{ - RadrootsOperationalListingCanonicalEdit, RadrootsOperationalListingEditDocumentV1, - RadrootsOperationalListingMutation, build_operational_listing_mutation_draft, - canonicalize_operational_listing_edit, -}; -#[cfg(feature = "runtime")] -pub const LISTING_PUBLISH_OPERATION_KIND: &str = "listing.publish.v1"; - -#[cfg(feature = "runtime")] -const OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID: &str = "radroots.operational_listing.published.v1"; - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, serde::Serialize)] -#[non_exhaustive] -pub struct ListingPreparePublishRequest { - #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] - pub actor: Actor, - pub document: RadrootsOperationalListingEditDocumentV1, - pub created_at: Option<RadrootsSdkTimestamp>, -} - -#[cfg(feature = "runtime")] -impl ListingPreparePublishRequest { - pub fn new(actor: Actor, listing: OperationalListing) -> Self { - Self { - actor, - document: RadrootsOperationalListingEditDocumentV1::new(listing), - created_at: None, - } - } - - pub fn from_document(actor: Actor, document: RadrootsOperationalListingEditDocumentV1) -> Self { - Self { - actor, - document, - 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 ListingEnqueuePublishRequest { - #[serde(serialize_with = "crate::actor_json::serialize_actor_context")] - pub actor: Actor, - pub document: RadrootsOperationalListingEditDocumentV1, - pub target_policy: TargetPolicy, - pub idempotency_key: Option<SdkIdempotencyKey>, - pub created_at: Option<RadrootsSdkTimestamp>, -} - -#[cfg(feature = "runtime")] -impl ListingEnqueuePublishRequest { - pub fn new(actor: Actor, listing: OperationalListing, target_policy: TargetPolicy) -> Self { - Self::from_document( - actor, - RadrootsOperationalListingEditDocumentV1::new(listing), - target_policy, - ) - } - - pub fn from_document( - actor: Actor, - document: RadrootsOperationalListingEditDocumentV1, - target_policy: TargetPolicy, - ) -> Self { - Self { - actor, - document, - 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 ListingPublishPlan { - public_listing_addr: ClassifiedListingAddress, - expected_event_id: EventId, - frozen_draft: EventDraft, - created_at: RadrootsSdkTimestamp, -} - -#[cfg(feature = "runtime")] -impl ListingPublishPlan { - pub fn public_listing_addr(&self) -> &ClassifiedListingAddress { - &self.public_listing_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)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum SdkMutationState { - StoredAndQueued, - AlreadyQueued, -} - -#[cfg(feature = "runtime")] -impl From<RadrootsOutboxEnqueueStatus> for SdkMutationState { - fn from(value: RadrootsOutboxEnqueueStatus) -> Self { - match value { - RadrootsOutboxEnqueueStatus::Inserted => Self::StoredAndQueued, - RadrootsOutboxEnqueueStatus::Existing => Self::AlreadyQueued, - } - } -} - -#[cfg(feature = "runtime")] -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] -pub struct ListingEnqueueReceipt { - pub public_listing_addr: ClassifiedListingAddress, - 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")] -impl<'sdk> ListingsClient<'sdk> { - pub fn prepare_publish( - &self, - request: ListingPreparePublishRequest, - ) -> Result<ListingPublishPlan, RadrootsSdkError> { - let created_at = self.resolved_created_at(request.created_at)?; - listing_publish_plan(&request.actor, request.document, created_at) - } - - #[cfg(feature = "signer-adapters")] - pub async fn enqueue_publish( - &self, - request: ListingEnqueuePublishRequest, - ) -> Result<ListingEnqueueReceipt, RadrootsSdkError> { - let ListingEnqueuePublishRequest { - actor, - document, - target_policy, - idempotency_key, - created_at, - } = request; - let prepare_request = ListingPreparePublishRequest { - actor: actor.clone(), - document, - 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: ListingEnqueuePublishRequest, - signer: &dyn Signer, - ) -> Result<ListingEnqueueReceipt, RadrootsSdkError> { - let ListingEnqueuePublishRequest { - actor, - document, - target_policy, - idempotency_key, - created_at, - } = request; - let prepare_request = ListingPreparePublishRequest { - actor: actor.clone(), - document, - 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: ListingPublishPlan, - target_policy: TargetPolicy, - idempotency_key: Option<SdkIdempotencyKey>, - ) -> Result<ListingEnqueueReceipt, RadrootsSdkError> { - let metadata = validate_listing_publish_plan(&plan)?; - let enqueue = enqueue_configured_signed_workflow( - self.sdk, - SdkWorkflowEnqueueRequest { - operation_kind: LISTING_PUBLISH_OPERATION_KIND, - actor, - frozen_draft: &plan.frozen_draft, - target_policy, - satisfaction_policy: SatisfactionPolicy::AllAccepted, - idempotency_key, - }, - ) - .await?; - Ok(listing_enqueue_receipt(metadata, enqueue)) - } - - pub async fn enqueue_prepared_publish_with_explicit_signer( - &self, - actor: &Actor, - plan: ListingPublishPlan, - target_policy: TargetPolicy, - idempotency_key: Option<SdkIdempotencyKey>, - signer: &dyn Signer, - ) -> Result<ListingEnqueueReceipt, RadrootsSdkError> { - let metadata = validate_listing_publish_plan(&plan)?; - let enqueue = enqueue_signed_workflow( - self.sdk, - SdkWorkflowEnqueueRequest { - operation_kind: LISTING_PUBLISH_OPERATION_KIND, - actor, - frozen_draft: &plan.frozen_draft, - target_policy, - satisfaction_policy: SatisfactionPolicy::AllAccepted, - idempotency_key, - }, - signer, - ) - .await?; - Ok(listing_enqueue_receipt(metadata, enqueue)) - } - - 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 listing_enqueue_receipt( - metadata: ValidatedListingPublishPlanMetadata, - enqueue: crate::workflow_runtime::SdkWorkflowEnqueueReceipt, -) -> ListingEnqueueReceipt { - ListingEnqueueReceipt { - public_listing_addr: metadata.public_listing_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")] -fn canonical_listing_edit( - actor: &Actor, - document: RadrootsOperationalListingEditDocumentV1, -) -> Result<RadrootsOperationalListingCanonicalEdit, RadrootsSdkError> { - if !actor.satisfies(AuthorRole::Seller) { - return Err(RadrootsSdkError::UnauthorizedActor { - operation: "listing.prepare_publish".to_owned(), - reason: "missing role Seller".to_owned(), - }); - } - canonicalize_operational_listing_edit(actor.public_key(), document).map_err(Into::into) -} - -#[cfg(feature = "runtime")] -struct ValidatedListingPublishPlanMetadata { - public_listing_addr: ClassifiedListingAddress, - expected_event_id: EventId, -} - -#[cfg(feature = "runtime")] -fn validate_listing_publish_plan( - plan: &ListingPublishPlan, -) -> Result<ValidatedListingPublishPlanMetadata, RadrootsSdkError> { - let invalid = |reason: &str| RadrootsSdkError::InvalidRequest { - message: format!("invalid prepared listing publish plan: {reason}"), - }; - plan.frozen_draft - .validate_for_signing() - .map_err(|_| invalid("frozen draft is invalid"))?; - if plan.frozen_draft.contract_id() != OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID - || plan.frozen_draft.kind_u32() != KIND_CLASSIFIED_LISTING - { - return Err(invalid( - "contract or kind does not match Operational Listing 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 listing identifier"))?; - if d_tags.next().is_some() { - return Err(invalid( - "frozen draft contains duplicate listing identifiers", - )); - } - let public_listing_addr = ClassifiedListingAddress::parse(format!( - "{KIND_CLASSIFIED_LISTING}:{}:{d_tag}", - plan.frozen_draft.expected_pubkey().to_hex() - )) - .map_err(|_| invalid("frozen draft listing address is invalid"))?; - if plan.public_listing_addr != public_listing_addr { - return Err(invalid("listing address does not match frozen draft")); - } - Ok(ValidatedListingPublishPlanMetadata { - public_listing_addr, - expected_event_id, - }) -} - -#[cfg(feature = "runtime")] -fn listing_publish_plan( - actor: &Actor, - document: RadrootsOperationalListingEditDocumentV1, - created_at: RadrootsSdkTimestamp, -) -> Result<ListingPublishPlan, RadrootsSdkError> { - let created_at_nostr = created_at.try_into_nostr_created_at()?; - let canonical = canonical_listing_edit(actor, document)?; - let public_listing_addr = canonical.public_listing_addr().clone(); - let mutation = RadrootsOperationalListingMutation::publish(canonical); - let frozen_draft = - build_operational_listing_mutation_draft(&mutation, u64::from(created_at_nostr))?; - let expected_event_id = EventId::parse(frozen_draft.expected_event_id_hex()) - .expect("frozen listing edit produces a valid event id"); - Ok(ListingPublishPlan { - public_listing_addr, - expected_event_id, - frozen_draft, - created_at, - }) -} - -#[cfg(all(test, feature = "runtime", feature = "signer-adapters"))] -#[path = "../tests/unit/listings_runtime_tests.rs"] -mod tests; diff --git a/crates/sdk/tests/listings_runtime.rs b/crates/sdk/tests/listings_runtime.rs @@ -1,1001 +0,0 @@ -#![cfg(feature = "runtime")] - -use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; -use radroots_event::{ - contract::AuthorRole, - envelope::kind::KIND_CLASSIFIED_LISTING, - farm::FarmRef, - id::{DTag, InventoryBinId}, - listing::operational::{ - OperationalListing, OperationalListingAvailability, OperationalListingBin, - OperationalListingDeliveryMethod, OperationalListingProduct, - OperationalListingPublicLocation, OperationalListingStatus, - }, -}; -use radroots_event_store::RadrootsEventStore; -use radroots_outbox::{ - RadrootsOutbox, RadrootsOutboxDeliveryPlanStatus, RadrootsOutboxDeliveryTargetStatus, - RadrootsOutboxEventState, -}; -use radroots_sdk::{ - LISTING_PUBLISH_OPERATION_KIND, ListingEnqueuePublishRequest, ListingPreparePublishRequest, - MultiTargetProfile, NostrProfile, NostrRelayUrlPolicy, PushOutboxEventState, PushOutboxRequest, - PushOutboxTargetOutcomeKind, RadrootsClient, RadrootsSdkError, RadrootsSdkRecoveryAction, - RadrootsSdkTimestamp, ReticulumProfile, SdkIdempotencyKey, SdkMutationState, TargetPolicy, - TargetSet, TransportProfile, -}; -use radroots_signing::{Actor, actor::ActorSource}; -use radroots_trade::operational_listing::RadrootsOperationalListingEditDocumentV1; -use radroots_transport_nostr::{RadrootsMockRelayPublishAdapter, RadrootsNostrTransport}; -use sqlx::Row; - -#[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_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; -const LISTING_A_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAQ"; -const LISTING_B_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAg"; -const LISTING_C_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAw"; -const LISTING_D_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAABA"; -const LISTING_E_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAABQ"; -const LISTING_F_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAABg"; -const LISTING_G_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAABw"; -const LISTING_H_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAACA"; -const LISTING_I_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAACQ"; -const LISTING_J_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAACg"; -const LISTING_K_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAACw"; -const RELAY: &str = "wss://relay.example.com"; -const RELAY_B: &str = "wss://relay-b.example.com"; - -fn seller_pubkey() -> &'static str { - fixture_alice_pubkey() -} - -fn other_pubkey() -> &'static str { - fixture_bob_pubkey() -} - -fn actor() -> Actor { - Actor::from_public_key_hex( - seller_pubkey(), - ActorSource::ExplicitPublicKey, - [AuthorRole::Seller], - ) - .expect("actor") -} - -fn non_seller_actor() -> Actor { - Actor::from_public_key_hex( - seller_pubkey(), - ActorSource::ExplicitPublicKey, - [AuthorRole::Buyer], - ) - .expect("actor") -} - -fn listing(d_tag: &str, title: &str) -> OperationalListing { - OperationalListing { - d_tag: DTag::parse(d_tag).expect("d tag"), - published_at: None, - farm: FarmRef { - pubkey: seller_pubkey().to_owned(), - d_tag: FARM_D_TAG.to_owned(), - }, - product: OperationalListingProduct { - key: "coffee".to_owned(), - title: title.to_owned(), - category: "coffee".to_owned(), - summary: Some("Single origin coffee".to_owned()), - process: None, - lot: None, - location: None, - profile: None, - year: None, - }, - primary_bin_id: InventoryBinId::parse("bin-1").expect("bin id"), - bins: vec![OperationalListingBin { - bin_id: InventoryBinId::parse("bin-1").expect("bin id"), - quantity: Quantity::try_new(Decimal::from(1000u32), Unit::MassG) - .expect("positive fixture quantity"), - price_per_canonical_unit: QuantityPrice::try_new( - Money::try_new(Decimal::from(20u32), Currency::USD) - .expect("non-negative fixture money"), - Quantity::try_new(Decimal::from(1u32), Unit::MassG) - .expect("positive fixture pricing quantity"), - ) - .expect("non-zero fixture pricing quantity"), - display_amount: None, - display_unit: None, - display_label: None, - display_price: None, - display_price_unit: None, - }], - resource_area: None, - plot: None, - discounts: None, - inventory_available: Some(Decimal::from(5u32)), - availability: Some(OperationalListingAvailability::Status { - status: OperationalListingStatus::Active, - }), - delivery_method: Some(OperationalListingDeliveryMethod::Pickup), - location: Some(OperationalListingPublicLocation { - primary: "Victoria".to_owned(), - city: Some("Victoria".to_owned()), - region: Some("British Columbia".to_owned()), - country: Some("CA".to_owned()), - geohash: "c287g".to_owned(), - }), - images: None, - } -} - -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) -} - -#[tokio::test] -async fn prepare_publish_is_side_effect_free() { - let (_tempdir, sdk) = directory_sdk().await; - let request = ListingPreparePublishRequest::new(actor(), listing(LISTING_A_D_TAG, "Coffee")); - let prepared = sdk.listings().prepare_publish(request).expect("prepared"); - - assert_eq!(prepared.frozen_draft().kind_u32(), KIND_CLASSIFIED_LISTING); - 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.public_listing_addr().as_str(), - format!( - "{KIND_CLASSIFIED_LISTING}:{}:{LISTING_A_D_TAG}", - seller_pubkey() - ) - ); - - let paths = sdk.storage_paths().expect("paths"); - let event_store = RadrootsEventStore::open_file(&paths.runtime_path) - .await - .expect("event store"); - 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 prepare_publish_rejects_non_seller_actor() { - let (_tempdir, sdk) = directory_sdk().await; - let request = - ListingPreparePublishRequest::new(non_seller_actor(), listing(LISTING_B_D_TAG, "Coffee")); - - let error = sdk - .listings() - .prepare_publish(request) - .expect_err("non seller"); - - assert!(matches!(error, RadrootsSdkError::UnauthorizedActor { .. })); -} - -#[tokio::test] -async fn enqueue_publish_stores_event_and_queues_signed_outbox_without_publish() { - let (_tempdir, sdk) = directory_sdk().await; - let request = ListingEnqueuePublishRequest::new( - actor(), - listing(LISTING_B_D_TAG, "Coffee"), - TargetPolicy::default_profile(), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000224") - .expect("idempotency key"); - let prepared = sdk - .listings() - .prepare_publish(ListingPreparePublishRequest::new( - actor(), - listing(LISTING_B_D_TAG, "Coffee"), - )) - .expect("prepared"); - let receipt = sdk - .listings() - .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(seller_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.public_listing_addr, prepared.public_listing_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"); - assert!( - event_store - .valid_event(&receipt.signed_event_id.to_hex()) - .await - .expect("event lookup") - .is_some() - ); - - 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!(outbox_event.signed_event.is_some()); -} - -#[tokio::test] -async fn enqueue_publish_default_profile_rejects_empty_transport_targets() { - let (_tempdir, sdk) = directory_sdk_with_relays(&[]).await; - let request = ListingEnqueuePublishRequest::new( - actor(), - listing(LISTING_A_D_TAG, "Coffee"), - TargetPolicy::default_profile(), - ); - - let error = sdk - .listings() - .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(seller_pubkey())) - .await - .expect_err("empty transport profile"); - - assert!(matches!( - error, - RadrootsSdkError::EmptyTransportTargets { operation } - if operation == "publish transport profile" - )); -} - -#[tokio::test] -async fn prepare_then_enqueue_prepared_uses_same_event_id() { - let (_tempdir, sdk) = directory_sdk().await; - let actor = actor(); - let prepared = sdk - .listings() - .prepare_publish(ListingPreparePublishRequest::new( - actor.clone(), - listing(LISTING_G_D_TAG, "Coffee"), - )) - .expect("prepared"); - let receipt = sdk - .listings() - .enqueue_prepared_publish_with_explicit_signer( - &actor, - prepared.clone(), - TargetPolicy::default_profile(), - Some( - SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000238") - .expect("idempotency"), - ), - &FixtureSigner::new(seller_pubkey()), - ) - .await - .expect("prepared enqueue"); - - assert_eq!(&receipt.expected_event_id, prepared.expected_event_id()); - assert_eq!(&receipt.signed_event_id, prepared.expected_event_id()); - - let paths = sdk.storage_paths().expect("paths"); - let event_store = RadrootsEventStore::open_file(&paths.runtime_path) - .await - .expect("event store"); - assert!( - event_store - .valid_event(&prepared.expected_event_id().to_hex()) - .await - .expect("event lookup") - .is_some() - ); - - 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.event_id, prepared.expected_event_id().to_hex()); -} - -#[tokio::test] -async fn enqueue_receipt_debug_omits_signed_event_payload_material() { - let (_tempdir, sdk) = directory_sdk().await; - let request = ListingEnqueuePublishRequest::new( - actor(), - listing(LISTING_A_D_TAG, "Coffee"), - TargetPolicy::default_profile(), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000225") - .expect("idempotency key"); - let receipt = sdk - .listings() - .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(seller_pubkey())) - .await - .expect("enqueue"); - let debug = format!("{receipt:?}"); - - assert!(debug.contains("ListingEnqueueReceipt")); - assert!(debug.contains("StoredAndQueued")); - assert!(!debug.contains("01890f0e-6c00-7000-8000-000000000225")); - assert!(!debug.contains("raw_json")); - assert!(!debug.contains("\"tags\"")); - assert!(!debug.contains("\"content\"")); - assert!(!debug.contains(&"f".repeat(128))); -} - -#[test] -fn mutation_state_debug_uses_product_state_names() { - assert_eq!( - format!("{:?}", SdkMutationState::StoredAndQueued), - "StoredAndQueued" - ); - assert_eq!( - format!("{:?}", SdkMutationState::AlreadyQueued), - "AlreadyQueued" - ); -} - -#[tokio::test] -async fn listing_runtime_dtos_serialize_deterministically() { - let (_tempdir, sdk) = directory_sdk().await; - let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_123); - let prepare_request = ListingPreparePublishRequest::from_document( - actor(), - RadrootsOperationalListingEditDocumentV1::new(listing( - LISTING_A_D_TAG, - "Serialized Coffee", - )), - ) - .with_created_at(created_at); - let prepare_json = serde_json::to_value(&prepare_request).expect("prepare request json"); - assert_struct_serialize_error_paths(&prepare_request, 3); - - assert_eq!(prepare_json["actor"]["pubkey"], seller_pubkey()); - assert_eq!( - prepare_json["actor"]["roles"], - serde_json::json!(["seller"]) - ); - assert_eq!(prepare_json["actor"]["source"], "explicit_public_key"); - assert_eq!(prepare_json["created_at"], 1_700_000_123); - assert_eq!( - prepare_json["document"]["listing"]["product"]["title"], - "Serialized Coffee" - ); - - let enqueue_request = ListingEnqueuePublishRequest::new( - actor(), - listing(LISTING_B_D_TAG, "Queued Coffee"), - TargetPolicy::default_profile(), - ) - .try_with_nostr_targets([RELAY, RELAY_B], NostrRelayUrlPolicy::Public) - .expect("relay targets") - .with_idempotency_key( - SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000226").expect("idempotency"), - ) - .with_created_at(created_at); - let enqueue_json = serde_json::to_value(&enqueue_request).expect("enqueue request json"); - assert_struct_serialize_error_paths(&enqueue_request, 5); - - assert_eq!(enqueue_json["target_policy"]["kind"], "explicit"); - assert_eq!( - enqueue_json["target_policy"]["targets"], - serde_json::json!([ - { - "kind": "nostr", - "uri": RELAY, - "scope": null, - "label": null, - "fingerprint": "a1997ec4596596af6ffc65e6a30ab7cffa53ea71f524c1c86d64018b96d130af" - }, - { - "kind": "nostr", - "uri": RELAY_B, - "scope": null, - "label": null, - "fingerprint": "5136077cfe7eddcbfaddc5d7bf1f42cdbb8191f3691b86ccc3a81047851cef05" - } - ]) - ); - assert_eq!( - enqueue_json["target_policy"]["canonical_targets"], - serde_json::json!([ - "5136077cfe7eddcbfaddc5d7bf1f42cdbb8191f3691b86ccc3a81047851cef05", - "a1997ec4596596af6ffc65e6a30ab7cffa53ea71f524c1c86d64018b96d130af" - ]) - ); - assert_eq!( - enqueue_json["idempotency_key"], - serde_json::json!({ "value": "<redacted>", "len": 36 }) - ); - assert!( - !enqueue_json - .to_string() - .contains("01890f0e-6c00-7000-8000-000000000226") - ); - - let try_key_request = ListingEnqueuePublishRequest::from_document( - actor(), - RadrootsOperationalListingEditDocumentV1::new(listing(LISTING_C_D_TAG, "Queued Coffee")), - TargetPolicy::default_profile(), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000227") - .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 receipt = sdk - .listings() - .enqueue_publish_with_explicit_signer(enqueue_request, &FixtureSigner::new(seller_pubkey())) - .await - .expect("enqueue"); - let receipt_json = serde_json::to_value(&receipt).expect("receipt json"); - - assert_eq!(receipt_json["state"], "stored_and_queued"); - assert_eq!(receipt_json["local_event_seq"], 1); - assert!(receipt_json["idempotency_digest_prefix"].is_string()); -} - -#[tokio::test] -async fn enqueue_publish_convenience_matches_prepare_plus_enqueue_prepared() { - let (_prepared_tempdir, prepared_sdk) = directory_sdk().await; - let prepared_actor = actor(); - let prepared_plan = prepared_sdk - .listings() - .prepare_publish(ListingPreparePublishRequest::new( - prepared_actor.clone(), - listing(LISTING_H_D_TAG, "Coffee"), - )) - .expect("prepared plan"); - let prepared_receipt = prepared_sdk - .listings() - .enqueue_prepared_publish_with_explicit_signer( - &prepared_actor, - prepared_plan, - TargetPolicy::default_profile(), - Some( - SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000239") - .expect("idempotency"), - ), - &FixtureSigner::new(seller_pubkey()), - ) - .await - .expect("prepared enqueue"); - - let (_convenience_tempdir, convenience_sdk) = directory_sdk().await; - let convenience_request = ListingEnqueuePublishRequest::new( - actor(), - listing(LISTING_H_D_TAG, "Coffee"), - TargetPolicy::default_profile(), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000239") - .expect("idempotency"); - let convenience_receipt = convenience_sdk - .listings() - .enqueue_publish_with_explicit_signer( - convenience_request, - &FixtureSigner::new(seller_pubkey()), - ) - .await - .expect("convenience enqueue"); - - assert_eq!(convenience_receipt, prepared_receipt); -} - -#[tokio::test] -async fn enqueue_prepared_publish_returns_structured_actor_errors() { - let (_tempdir, sdk) = directory_sdk().await; - let prepared = sdk - .listings() - .prepare_publish(ListingPreparePublishRequest::new( - actor(), - listing(LISTING_I_D_TAG, "Coffee"), - )) - .expect("prepared"); - let error = sdk - .listings() - .enqueue_prepared_publish_with_explicit_signer( - &non_seller_actor(), - prepared, - TargetPolicy::default_profile(), - Some( - SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-00000000023e") - .expect("idempotency key"), - ), - &FixtureSigner::new(seller_pubkey()), - ) - .await - .expect_err("actor error"); - - assert!(matches!(error, RadrootsSdkError::UnauthorizedActor { .. })); -} - -#[tokio::test] -async fn enqueue_prepared_publish_returns_sanitized_signer_errors() { - let (_tempdir, sdk) = directory_sdk().await; - let actor = actor(); - let prepared = sdk - .listings() - .prepare_publish(ListingPreparePublishRequest::new( - actor.clone(), - listing(LISTING_J_D_TAG, "Coffee"), - )) - .expect("prepared"); - let error = sdk - .listings() - .enqueue_prepared_publish_with_explicit_signer( - &actor, - prepared, - TargetPolicy::default_profile(), - Some( - SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-00000000023f") - .expect("idempotency key"), - ), - &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")); -} - -#[tokio::test] -async fn explicit_historical_created_at_does_not_backdate_observed_at_ms() { - let (_tempdir, sdk) = directory_sdk().await; - let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_600_000_000); - let observed_at_ms = 1_700_000_000_000; - let request = ListingEnqueuePublishRequest::new( - actor(), - listing(LISTING_K_D_TAG, "Coffee"), - TargetPolicy::default_profile(), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000023a") - .expect("idempotency") - .with_created_at(created_at); - - let receipt = sdk - .listings() - .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(seller_pubkey())) - .await - .expect("enqueue"); - - let paths = sdk.storage_paths().expect("paths"); - let event_store = RadrootsEventStore::open_file(&paths.runtime_path) - .await - .expect("event store"); - 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.created_at, 1_600_000_000); - assert_eq!(stored_event.inserted_at_ms, observed_at_ms); - assert_eq!(stored_event.updated_at_ms, observed_at_ms); - - 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.draft.created_at_u64(), 1_600_000_000); - assert_eq!( - outbox_event.event_store_ingested_at_ms, - Some(observed_at_ms) - ); - assert_eq!(outbox_event.created_at_ms, observed_at_ms); - assert_eq!(outbox_event.updated_at_ms, observed_at_ms); -} - -#[tokio::test] -async fn enqueue_publish_returns_sanitized_signer_errors() { - let (_tempdir, sdk) = directory_sdk().await; - let request = ListingEnqueuePublishRequest::new( - actor(), - listing(LISTING_C_D_TAG, "Coffee"), - TargetPolicy::default_profile(), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000023d") - .expect("idempotency key"); - let error = sdk - .listings() - .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")); -} - -#[tokio::test] -async fn enqueue_publish_reports_preflight_idempotency_conflict_without_mutation() { - let (_tempdir, sdk) = directory_sdk().await; - let first = ListingEnqueuePublishRequest::new( - actor(), - listing(LISTING_D_D_TAG, "Coffee"), - TargetPolicy::default_profile(), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000228") - .expect("idempotency key"); - sdk.listings() - .enqueue_publish_with_explicit_signer(first, &FixtureSigner::new(seller_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 = ListingEnqueuePublishRequest::new( - actor(), - listing(LISTING_E_D_TAG, "Changed"), - TargetPolicy::default_profile(), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000228") - .expect("idempotency key"); - let error = sdk - .listings() - .enqueue_publish_with_explicit_signer(second, &FixtureSigner::new(seller_pubkey())) - .await - .expect_err("conflict"); - - assert!(matches!( - error, - RadrootsSdkError::IdempotencyConflict { ref operation_kind, .. } - if operation_kind == LISTING_PUBLISH_OPERATION_KIND - )); - assert_eq!( - error.recovery_actions(), - vec![RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey] - ); - assert!( - !error - .to_string() - .contains("01890f0e-6c00-7000-8000-000000000228") - ); - 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 enqueue_publish_rolls_back_event_and_journal_when_outbox_conflicts_after_ingest() { - let (_tempdir, sdk) = directory_sdk().await; - let paths = sdk.storage_paths().expect("paths"); - let idempotency_key = "01890f0e-6c00-7000-8000-000000000120"; - let seeded_event_store = RadrootsEventStore::open_file(&paths.runtime_path) - .await - .expect("seed event store"); - let conflicting_operation = sqlx::query( - "INSERT INTO outbox_operations(operation_kind, expected_pubkey, semantic_scope, trade_id, mutation_id, canonical_payload_sha256, idempotency_key, operation_idempotency_digest, status, created_at_ms, updated_at_ms) VALUES (?, ?, 'generic_event', NULL, NULL, NULL, ?, ?, 'queued', ?, ?)", - ) - .bind(LISTING_PUBLISH_OPERATION_KIND) - .bind(seller_pubkey()) - .bind(idempotency_key) - .bind("conflicting-digest") - .bind(1_700_000_000_000i64) - .bind(1_700_000_000_000i64) - .execute(seeded_event_store.pool()) - .await - .expect("seed conflicting operation") - .last_insert_rowid(); - sqlx::query( - "INSERT INTO outbox_event(operation_id, event_id, expected_pubkey, draft_json, state, attempt_count, next_attempt_after_ms, event_store_ingested, event_store_inserted, created_at_ms, updated_at_ms) VALUES (?, ?, ?, '{}', 'signed', 0, ?, 0, 0, ?, ?)", - ) - .bind(conflicting_operation) - .bind("0".repeat(64)) - .bind(seller_pubkey()) - .bind(1_700_000_000_000i64) - .bind(1_700_000_000_000i64) - .bind(1_700_000_000_000i64) - .execute(seeded_event_store.pool()) - .await - .expect("seed conflicting event"); - - let request = ListingEnqueuePublishRequest::new( - actor(), - listing(LISTING_E_D_TAG, "Rollback Coffee"), - TargetPolicy::default_profile(), - ) - .try_with_idempotency_key(idempotency_key) - .expect("idempotency key"); - let prepared = sdk - .listings() - .prepare_publish(ListingPreparePublishRequest::new( - actor(), - listing(LISTING_E_D_TAG, "Rollback Coffee"), - )) - .expect("prepared"); - let error = sdk - .listings() - .enqueue_publish_with_explicit_signer(request, &FixtureSigner::new(seller_pubkey())) - .await - .expect_err("outbox conflict"); - assert!(matches!( - error, - RadrootsSdkError::IdempotencyConflict { .. } - )); - - let event_store = RadrootsEventStore::open_file(&paths.runtime_path) - .await - .expect("event store"); - assert!( - event_store - .raw_event(&prepared.expected_event_id().to_hex()) - .await - .expect("event lookup") - .is_none() - ); - let journal_row = sqlx::query( - "SELECT state, last_error_code FROM sdk_runtime_operation_journal WHERE operation_kind = ? AND actor_pubkey = ? AND idempotency_key = ?", - ) - .bind(LISTING_PUBLISH_OPERATION_KIND) - .bind(seller_pubkey()) - .bind(idempotency_key) - .fetch_one(event_store.pool()) - .await - .expect("journal row"); - let journal_state: String = journal_row.try_get("state").expect("journal state"); - let last_error_code: String = journal_row - .try_get("last_error_code") - .expect("journal error code"); - assert_eq!(journal_state, "failed_recoverable"); - assert_eq!(last_error_code, "idempotency_conflict"); - let recovery_count: i64 = sqlx::query( - "SELECT COUNT(*) FROM sdk_runtime_recovery_receipt WHERE recovery_code = 'idempotency_conflict' AND operation_kind = ? AND actor_pubkey = ? AND idempotency_key = ?", - ) - .bind(LISTING_PUBLISH_OPERATION_KIND) - .bind(seller_pubkey()) - .bind(idempotency_key) - .fetch_one(event_store.pool()) - .await - .expect("recovery count") - .try_get(0) - .expect("recovery count value"); - assert_eq!(recovery_count, 1); - let outbox = RadrootsOutbox::open_file(&paths.runtime_path) - .await - .expect("outbox"); - assert_eq!( - outbox - .status_summary(0) - .await - .expect("outbox status after rollback") - .total_events, - 1 - ); -} - -#[tokio::test] -async fn enqueue_publish_uses_explicit_idempotency_key_across_equivalent_target_order() { - let (_tempdir, sdk) = directory_sdk().await; - let first = ListingEnqueuePublishRequest::new( - actor(), - listing(LISTING_F_D_TAG, "Coffee"), - TargetPolicy::default_profile(), - ) - .try_with_nostr_targets([RELAY_B, RELAY], NostrRelayUrlPolicy::Public) - .expect("first transport targets") - .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000023b") - .expect("first idempotency key"); - let second = ListingEnqueuePublishRequest::new( - actor(), - listing(LISTING_F_D_TAG, "Coffee"), - TargetPolicy::explicit( - TargetSet::nostr_relays([RELAY, RELAY_B], NostrRelayUrlPolicy::Public) - .expect("second transport targets"), - ), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000023b") - .expect("second idempotency key"); - - let first_receipt = sdk - .listings() - .enqueue_publish_with_explicit_signer(first, &FixtureSigner::new(seller_pubkey())) - .await - .expect("first enqueue"); - let second_receipt = sdk - .listings() - .enqueue_publish_with_explicit_signer(second, &FixtureSigner::new(seller_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 listing_multi_target_profile_publishes_after_nostr_success_and_retains_reticulum() { - let (_tempdir, sdk) = multi_target_directory_sdk().await; - let enqueue_receipt = sdk - .listings() - .enqueue_publish_with_explicit_signer( - ListingEnqueuePublishRequest::new( - actor(), - listing(LISTING_G_D_TAG, "MultiTarget Coffee"), - TargetPolicy::default_profile(), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-00000000023c") - .expect("idempotency"), - &FixtureSigner::new(seller_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 - })); -} diff --git a/crates/sdk/tests/package_boundary.rs b/crates/sdk/tests/package_boundary.rs @@ -4,6 +4,7 @@ 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 LISTING: &str = include_str!("../src/listing.rs"); const SYNC: &str = include_str!("../src/sync.rs"); const TRANSPORT: &str = include_str!("../src/transport.rs"); @@ -230,6 +231,42 @@ fn farm_operations_preserve_pure_planning_commit_and_privacy_boundaries() { assert!(!source_root.join("farms_runtime.rs").exists()); } +#[test] +fn listing_operations_reuse_trade_event_sync_and_privacy_boundaries() { + for required in [ + "canonicalize_operational_listing_edit", + "RadrootsOperationalListingMutation", + "RadrootsOperationalListingLifecycleState", + "build_operational_listing_mutation_draft", + "radroots_sync::PushRequest::new", + "PrivateArtifactStore", + ] { + assert!( + LISTING.contains(required), + "missing listing boundary `{required}`" + ); + } + for forbidden in [ + "SdkMutationState", + "ListingEnqueueReceipt", + "enqueue_signed_workflow", + "local_event_seq", + "outbox_event_id", + "latitude:", + "longitude:", + ] { + assert!( + !LISTING.contains(forbidden), + "listing source contains retired duplicate or private representation `{forbidden}`" + ); + } + assert!( + !std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src/listings_runtime.rs") + .exists() + ); +} + fn dependency_names(manifest: &str) -> BTreeSet<&str> { let dependencies = manifest .split_once("[dependencies]") diff --git a/crates/sdk/tests/unit/listings_runtime_tests.rs b/crates/sdk/tests/unit/listings_runtime_tests.rs @@ -1,600 +0,0 @@ -use super::*; -use crate::{RadrootsSdkLocalKeySigner, RadrootsSdkSignerProvider}; -use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; -use radroots_event::{ - contract::AuthorRole, - farm::FarmRef, - farm::resource_area::ResourceAreaRef, - id::{DTag, InventoryBinId}, - listing::operational::{ - OperationalListingAvailability, OperationalListingBin, OperationalListingDeliveryMethod, - OperationalListingProduct, OperationalListingPublicLocation, OperationalListingStatus, - }, -}; - -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_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; -const LISTING_A_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAQ"; -const LISTING_B_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAg"; -const LISTING_C_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAw"; -const RELAY_A: &str = "wss://relay-a.radroots.test"; -const RELAY_B: &str = "wss://relay-b.radroots.test"; - -fn seller_pubkey() -> &'static str { - fixture_alice_pubkey() -} - -fn actor() -> Actor { - Actor::from_public_key_hex( - seller_pubkey(), - ActorSource::ExplicitPublicKey, - [AuthorRole::Seller], - ) - .expect("actor") -} - -fn listing(d_tag: &str, title: &str) -> OperationalListing { - listing_for_seller(seller_pubkey(), d_tag, title) -} - -fn listing_for_seller(seller: &str, d_tag: &str, title: &str) -> OperationalListing { - OperationalListing { - d_tag: DTag::parse(d_tag).expect("d tag"), - published_at: None, - farm: FarmRef { - pubkey: seller.to_owned(), - d_tag: FARM_D_TAG.to_owned(), - }, - product: OperationalListingProduct { - key: "lettuce".to_owned(), - title: title.to_owned(), - category: "greens".to_owned(), - summary: Some("Fresh greens".to_owned()), - process: None, - lot: None, - location: None, - profile: None, - year: None, - }, - primary_bin_id: InventoryBinId::parse("bin-1").expect("bin id"), - bins: vec![OperationalListingBin { - bin_id: InventoryBinId::parse("bin-1").expect("bin id"), - quantity: Quantity::try_new(Decimal::from(12u32), Unit::Each) - .expect("positive fixture quantity"), - price_per_canonical_unit: QuantityPrice::try_new( - Money::try_new(Decimal::from(4u32), Currency::USD) - .expect("non-negative fixture money"), - Quantity::try_new(Decimal::from(1u32), Unit::Each) - .expect("positive fixture pricing quantity"), - ) - .expect("non-zero fixture pricing quantity"), - display_amount: None, - display_unit: None, - display_label: None, - display_price: None, - display_price_unit: None, - }], - resource_area: None, - plot: None, - discounts: None, - inventory_available: Some(Decimal::from(12u32)), - availability: Some(OperationalListingAvailability::Status { - status: OperationalListingStatus::Active, - }), - delivery_method: Some(OperationalListingDeliveryMethod::Pickup), - location: Some(OperationalListingPublicLocation { - primary: "Victoria".to_owned(), - city: Some("Victoria".to_owned()), - region: Some("British Columbia".to_owned()), - country: Some("CA".to_owned()), - geohash: "c287g".to_owned(), - }), - images: None, - } -} - -#[test] -fn listing_runtime_request_builders_and_serializers_cover_success_paths() { - let created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_321); - let prepare = - ListingPreparePublishRequest::new(actor(), listing(LISTING_A_D_TAG, "Serialized Greens")) - .with_created_at(created_at); - assert_struct_serialize_error_paths(&prepare, 3); - let prepare_json = serde_json::to_value(&prepare).expect("prepare json"); - assert_eq!(prepare_json["actor"]["pubkey"], seller_pubkey()); - assert_eq!(prepare_json["created_at"], 1_700_000_321); - - let enqueue = ListingEnqueuePublishRequest::from_document( - actor(), - RadrootsOperationalListingEditDocumentV1::new(listing(LISTING_B_D_TAG, "Queued Greens")), - 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-00000000022f").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-00000000022f") - ); - - let try_key = ListingEnqueuePublishRequest::new( - actor(), - listing(LISTING_C_D_TAG, "Try Key Greens"), - TargetPolicy::default_profile(), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000230") - .expect("try key"); - assert_eq!( - serde_json::to_value(&try_key).expect("try key json")["idempotency_key"]["len"], - "01890f0e-6c00-7000-8000-000000000230".len() - ); -} - -#[test] -fn listing_request_builders_reject_invalid_options_and_timestamp_bounds() { - let invalid_key = ListingEnqueuePublishRequest::new( - actor(), - listing(LISTING_A_D_TAG, "Invalid Key Greens"), - TargetPolicy::default_profile(), - ) - .try_with_idempotency_key(""); - assert!(invalid_key.is_err()); - - let timestamp_error = listing_publish_plan( - &actor(), - RadrootsOperationalListingEditDocumentV1::new(listing(LISTING_B_D_TAG, "Future Greens")), - RadrootsSdkTimestamp::from_unix_seconds(u64::MAX), - ) - .expect_err("timestamp error"); - assert!(matches!( - timestamp_error, - RadrootsSdkError::TimestampOutOfRange { .. } - )); - - let mut invalid_resource_area_listing = - listing(LISTING_C_D_TAG, "Invalid Resource Area Greens"); - invalid_resource_area_listing.resource_area = Some(ResourceAreaRef { - pubkey: seller_pubkey().to_owned(), - d_tag: "bad d tag".to_owned(), - }); - let mutation_error = listing_publish_plan( - &actor(), - RadrootsOperationalListingEditDocumentV1::new(invalid_resource_area_listing), - RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000), - ) - .expect_err("mutation error"); - assert!(matches!( - mutation_error, - RadrootsSdkError::ListingMutation { message } if message.contains("failed to encode") - )); -} - -#[tokio::test] -async fn listing_client_prepare_resolves_default_and_explicit_created_at() { - let sdk = crate::RadrootsClient::builder() - .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_400)) - .build() - .await - .expect("sdk"); - let default_plan = sdk - .listings() - .prepare_publish(ListingPreparePublishRequest::new( - actor(), - listing(LISTING_A_D_TAG, "Default Clock Greens"), - )) - .expect("default plan"); - assert_eq!( - default_plan.created_at(), - RadrootsSdkTimestamp::from_unix_seconds(1_700_000_400) - ); - - let explicit_plan = sdk - .listings() - .prepare_publish( - ListingPreparePublishRequest::new( - actor(), - listing(LISTING_B_D_TAG, "Explicit Clock Greens"), - ) - .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_401)), - ) - .expect("explicit plan"); - assert_eq!( - explicit_plan.created_at(), - RadrootsSdkTimestamp::from_unix_seconds(1_700_000_401) - ); -} - -#[tokio::test] -async fn listing_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 = actor(); - let plan = sdk - .listings() - .prepare_publish(ListingPreparePublishRequest::new( - actor.clone(), - listing(LISTING_A_D_TAG, "Sealed Plan Greens"), - )) - .expect("plan"); - assert_eq!( - serde_json::to_value(&plan).expect("plan json"), - serde_json::json!({ - "public_listing_addr": plan.public_listing_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", - seller_pubkey(), - ) - .expect("valid foreign draft"); - let mut forged_contract_kind = plan.clone(); - forged_contract_kind.frozen_draft = foreign_draft; - assert!(matches!( - validate_listing_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_listing_publish_plan(&forged_event_id), - Err(RadrootsSdkError::InvalidRequest { ref message }) - if message.contains("expected event ID") - )); - - let mut forged_address = plan.clone(); - forged_address.public_listing_addr = ClassifiedListingAddress::parse(format!( - "{KIND_CLASSIFIED_LISTING}:{}:{LISTING_B_D_TAG}", - seller_pubkey() - )) - .expect("alternate listing address"); - assert!(matches!( - validate_listing_publish_plan(&forged_address), - Err(RadrootsSdkError::InvalidRequest { ref message }) - if message.contains("listing address") - )); - - let mut forged_created_at = plan.clone(); - forged_created_at.created_at = RadrootsSdkTimestamp::from_unix_seconds(1_700_000_451); - assert!(matches!( - validate_listing_publish_plan(&forged_created_at), - Err(RadrootsSdkError::InvalidRequest { ref message }) - if message.contains("created-at timestamp") - )); - - let alternate_plan = sdk - .listings() - .prepare_publish(ListingPreparePublishRequest::new( - actor.clone(), - listing(LISTING_B_D_TAG, "Alternate Plan Greens"), - )) - .expect("alternate plan"); - let mut forged_draft = plan; - forged_draft.frozen_draft = alternate_plan.frozen_draft; - assert!(matches!( - validate_listing_publish_plan(&forged_draft), - Err(RadrootsSdkError::InvalidRequest { ref message }) - if message.contains("expected event ID") - )); - - let error = sdk - .listings() - .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-000000000245") - .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 - .listings() - .enqueue_prepared_publish( - &actor, - forged_address, - TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) - .expect("transport targets"), - Some( - SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000246") - .expect("idempotency"), - ), - ) - .await - .expect_err("forged configured-signer plan"); - assert!(matches!( - error, - RadrootsSdkError::InvalidRequest { ref message } - if message.contains("listing 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 listing_client_prepare_reports_clock_errors() { - let sdk = crate::RadrootsClient::builder() - .clock(crate::RadrootsSdkClock::BeforeUnixEpoch) - .build() - .await - .expect("sdk"); - let error = sdk - .listings() - .prepare_publish(ListingPreparePublishRequest::new( - actor(), - listing(LISTING_A_D_TAG, "Clock Error Greens"), - )) - .expect_err("clock error"); - assert!(matches!(error, RadrootsSdkError::ClockBeforeUnixEpoch)); -} - -#[tokio::test] -async fn listing_enqueue_publish_reports_prepare_errors_before_signing() { - let sdk = crate::RadrootsClient::builder() - .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500)) - .build() - .await - .expect("sdk"); - let error = sdk - .listings() - .enqueue_publish_with_explicit_signer( - ListingEnqueuePublishRequest::new( - actor(), - listing(LISTING_A_D_TAG, "Future Enqueue Greens"), - TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) - .expect("transport targets"), - ) - .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(u64::MAX)), - &FixtureSigner::new(seller_pubkey()), - ) - .await - .expect_err("prepare error"); - assert!(matches!( - error, - RadrootsSdkError::TimestampOutOfRange { .. } - )); - - let mut invalid_listing = listing(LISTING_B_D_TAG, "Invalid Inventory Greens"); - invalid_listing.inventory_available = None; - let error = sdk - .listings() - .enqueue_publish_with_explicit_signer( - ListingEnqueuePublishRequest::new( - actor(), - invalid_listing, - TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) - .expect("transport targets"), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000239") - .expect("idempotency"), - &FixtureSigner::new(fixture_bob_pubkey()), - ) - .await - .expect_err("invalid listing model"); - assert!(matches!( - error, - RadrootsSdkError::ListingValidation { - kind: crate::RadrootsSdkListingValidationErrorKind::MissingInventory, - ref message, - } if message == "missing listing inventory" - )); - 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 listing_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(seller_pubkey()); - let actor = actor(); - let receipt = sdk - .listings() - .enqueue_publish_with_explicit_signer( - ListingEnqueuePublishRequest::new( - actor.clone(), - listing(LISTING_A_D_TAG, "Enqueued Greens"), - TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) - .expect("transport targets"), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000231") - .expect("idempotency"), - &signer, - ) - .await - .expect("enqueue listing"); - assert_eq!(receipt.signed_event_id, receipt.expected_event_id); - assert_eq!(receipt.state, SdkMutationState::StoredAndQueued); - - let plan = sdk - .listings() - .prepare_publish(ListingPreparePublishRequest::from_document( - actor.clone(), - RadrootsOperationalListingEditDocumentV1::new(listing( - LISTING_B_D_TAG, - "Prepared Greens", - )), - )) - .expect("prepared listing"); - let prepared = sdk - .listings() - .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-000000000232") - .expect("prepared idempotency"), - ), - &signer, - ) - .await - .expect("enqueue prepared listing"); - assert_eq!(prepared.signed_event_id, prepared.expected_event_id); - assert_eq!(prepared.local_event_seq, 2); -} - -#[tokio::test] -async fn listing_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(seller_pubkey()), - seller_pubkey(), - ) - .expect("signer"), - )) - .build() - .await - .expect("sdk"); - let actor = Actor::from_public_key_hex( - seller_pubkey(), - ActorSource::ExplicitPublicKey, - [AuthorRole::Seller], - ) - .expect("actor"); - - let receipt = sdk - .listings() - .enqueue_publish( - ListingEnqueuePublishRequest::new( - actor, - listing_for_seller(seller_pubkey(), LISTING_C_D_TAG, "Configured Greens"), - TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) - .expect("transport targets"), - ) - .try_with_idempotency_key("01890f0e-6c00-7000-8000-000000000232") - .expect("idempotency"), - ) - .await - .expect("enqueue listing"); - - assert_eq!(receipt.signed_event_id, receipt.expected_event_id); - assert_eq!(receipt.state, SdkMutationState::StoredAndQueued); -} - -#[tokio::test] -async fn listing_configured_enqueue_reports_missing_signer_after_prepare() { - let sdk = crate::RadrootsClient::builder() - .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500)) - .build() - .await - .expect("sdk"); - let actor = actor(); - assert!(matches!( - sdk.listings() - .enqueue_publish( - ListingEnqueuePublishRequest::new( - actor.clone(), - listing(LISTING_A_D_TAG, "Configured Prepare Error Greens"), - TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) - .expect("transport targets"), - ) - .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(u64::MAX)), - ) - .await, - Err(RadrootsSdkError::TimestampOutOfRange { .. }) - )); - let plan = sdk - .listings() - .prepare_publish(ListingPreparePublishRequest::new( - actor.clone(), - listing(LISTING_A_D_TAG, "Missing Signer Greens"), - )) - .expect("plan"); - - assert!(matches!( - sdk.listings() - .enqueue_prepared_publish( - &actor, - plan, - TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) - .expect("transport targets"), - Some( - SdkIdempotencyKey::new("01890f0e-6c00-7000-8000-000000000233") - .expect("idempotency"), - ), - ) - .await, - Err(RadrootsSdkError::SignerUnavailable { .. }) - )); -}