sdk

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

commit 2dd31d71067eed4281d22bbfbaff5125223fd56f
parent 933b2de1308ab748607682924f1cda22fee327fd
Author: triesap <tyson@radroots.org>
Date:   Mon, 15 Jun 2026 17:12:34 -0700

sdk: enrich order status receipts

- Make order status requests carry typed order IDs with a parse constructor for user input.
- Expose local source, exact event count, applied limit, and typed event IDs on receipts.
- Replace issue counts with typed redacted reducer issues and event ID details.
- Expand runtime tests for missing, invalid, limited, accepted, malformed, and conflict states.

Diffstat:
Mcrates/sdk/examples/runtime_local.rs | 2+-
Mcrates/sdk/src/lib.rs | 2+-
Mcrates/sdk/src/orders_runtime.rs | 677++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/sdk/tests/orders_runtime.rs | 158+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
4 files changed, 790 insertions(+), 49 deletions(-)

diff --git a/crates/sdk/examples/runtime_local.rs b/crates/sdk/examples/runtime_local.rs @@ -103,7 +103,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { .await?; let order_status = sdk .orders() - .status(OrderStatusRequest::new("example-order-1")) + .status(OrderStatusRequest::parse("example-order-1")?) .await?; assert_eq!( diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -61,7 +61,7 @@ pub use crate::listings_runtime::{ pub use crate::orders_runtime::{ ORDER_STATUS_DEFAULT_LIMIT, ORDER_STATUS_MAX_LIMIT, OrderFulfillmentStatusKind, OrderPaymentStateKind, OrderSettlementStateKind, OrderStatusKind, OrderStatusReceipt, - OrderStatusRequest, + OrderStatusRequest, SdkOrderStatusIssue, SdkOrderStatusIssueKind, SdkOrderStatusSource, }; #[cfg(feature = "runtime")] pub use crate::product_clients::{ListingsClient, OrdersClient, SyncClient}; diff --git a/crates/sdk/src/orders_runtime.rs b/crates/sdk/src/orders_runtime.rs @@ -1,11 +1,15 @@ #[cfg(feature = "runtime")] use crate::{OrdersClient, RadrootsSdkError}; #[cfg(feature = "runtime")] -use radroots_events::{ids::RadrootsOrderId, order::RadrootsOrderFulfillmentState}; +use radroots_events::{ + ids::{RadrootsEventId, RadrootsOrderId}, + order::RadrootsOrderFulfillmentState, +}; #[cfg(feature = "runtime")] use radroots_trade::order::{ - RadrootsOrderPaymentState, RadrootsOrderProjection, RadrootsOrderSettlementState, - RadrootsOrderStatus, RadrootsOrderStoreQueryError, order_projection_for_order_id, + RadrootsOrderIssue, RadrootsOrderPaymentState, RadrootsOrderProjectionQueryResult, + RadrootsOrderSettlementState, RadrootsOrderStatus, RadrootsOrderStoreQueryError, + order_projection_query_for_order_id, }; #[cfg(feature = "runtime")] @@ -16,25 +20,33 @@ pub const ORDER_STATUS_MAX_LIMIT: u32 = 1_000; #[cfg(feature = "runtime")] #[derive(Clone, Debug, PartialEq, Eq)] pub struct OrderStatusRequest { - pub order_id: String, + pub order_id: RadrootsOrderId, pub limit: u32, } #[cfg(feature = "runtime")] impl OrderStatusRequest { - pub fn new(order_id: impl Into<String>) -> Self { + pub fn new(order_id: RadrootsOrderId) -> Self { Self { - order_id: order_id.into(), + order_id, limit: ORDER_STATUS_DEFAULT_LIMIT, } } + pub fn parse(order_id: &str) -> Result<Self, RadrootsSdkError> { + RadrootsOrderId::parse(order_id) + .map(Self::new) + .map_err(|error| RadrootsSdkError::InvalidRequest { + message: format!("order_id is invalid: {error}"), + }) + } + pub fn with_limit(mut self, limit: u32) -> Self { self.limit = limit; self } - fn validate(&self) -> Result<RadrootsOrderId, RadrootsSdkError> { + fn validate(&self) -> Result<(), RadrootsSdkError> { if self.limit == 0 || self.limit > ORDER_STATUS_MAX_LIMIT { return Err(RadrootsSdkError::InvalidRequest { message: format!( @@ -42,31 +54,37 @@ impl OrderStatusRequest { ), }); } - RadrootsOrderId::parse(self.order_id.as_str()).map_err(|error| { - RadrootsSdkError::InvalidRequest { - message: format!("order_id is invalid: {error}"), - } - }) + Ok(()) } } #[cfg(feature = "runtime")] #[derive(Clone, Debug, PartialEq, Eq)] pub struct OrderStatusReceipt { - pub order_id: String, + pub order_id: RadrootsOrderId, + pub source: SdkOrderStatusSource, pub found: bool, + pub event_count: usize, + pub limit_applied: u32, pub status: OrderStatusKind, pub fulfillment_status: Option<OrderFulfillmentStatusKind>, pub payment_state: OrderPaymentStateKind, pub settlement_state: OrderSettlementStateKind, pub lifecycle_terminal: bool, - pub request_event_id: Option<String>, - pub decision_event_id: Option<String>, - pub fulfillment_event_id: Option<String>, - pub cancellation_event_id: Option<String>, - pub receipt_event_id: Option<String>, - pub last_event_id: Option<String>, - pub issue_count: usize, + pub event_ids: Vec<RadrootsEventId>, + pub request_event_id: Option<RadrootsEventId>, + pub decision_event_id: Option<RadrootsEventId>, + pub fulfillment_event_id: Option<RadrootsEventId>, + pub cancellation_event_id: Option<RadrootsEventId>, + pub receipt_event_id: Option<RadrootsEventId>, + pub last_event_id: Option<RadrootsEventId>, + pub issues: Vec<SdkOrderStatusIssue>, +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SdkOrderStatusSource { + LocalEventStore, } #[cfg(feature = "runtime")] @@ -114,39 +132,191 @@ pub enum OrderSettlementStateKind { } #[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SdkOrderStatusIssue { + pub kind: SdkOrderStatusIssueKind, + pub event_ids: Vec<RadrootsEventId>, +} + +#[cfg(feature = "runtime")] +impl SdkOrderStatusIssue { + fn new(kind: SdkOrderStatusIssueKind, event_ids: Vec<RadrootsEventId>) -> Self { + Self { kind, event_ids } + } + + fn single(kind: SdkOrderStatusIssueKind, event_id: RadrootsEventId) -> Self { + Self::new(kind, vec![event_id]) + } +} + +#[cfg(feature = "runtime")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SdkOrderStatusIssueKind { + MissingRequest, + MultipleRequests, + RequestPayloadInvalid, + RequestOrderIdMismatch, + RequestAuthorMismatch, + RequestListingAddressInvalid, + RequestSellerListingMismatch, + DecisionPayloadInvalid, + DecisionOrderIdMismatch, + DecisionAuthorMismatch, + DecisionCounterpartyMismatch, + DecisionBuyerMismatch, + DecisionSellerMismatch, + DecisionListingAddressInvalid, + DecisionListingMismatch, + DecisionRootMismatch, + DecisionPreviousMismatch, + DecisionMissingInventoryCommitments, + DecisionInventoryCommitmentMismatch, + DecisionMissingReason, + ConflictingDecisions, + RevisionProposalWithoutAcceptedDecision, + RevisionProposalPayloadInvalid, + RevisionProposalOrderIdMismatch, + RevisionProposalAuthorMismatch, + RevisionProposalCounterpartyMismatch, + RevisionProposalBuyerMismatch, + RevisionProposalSellerMismatch, + RevisionProposalListingAddressInvalid, + RevisionProposalListingMismatch, + RevisionProposalRootMismatch, + RevisionProposalPreviousMismatch, + RevisionDecisionWithoutProposal, + RevisionDecisionPayloadInvalid, + RevisionDecisionOrderIdMismatch, + RevisionDecisionAuthorMismatch, + RevisionDecisionCounterpartyMismatch, + RevisionDecisionBuyerMismatch, + RevisionDecisionSellerMismatch, + RevisionDecisionListingAddressInvalid, + RevisionDecisionListingMismatch, + RevisionDecisionRootMismatch, + RevisionDecisionPreviousMismatch, + RevisionDecisionRevisionIdMismatch, + FulfillmentWithoutAcceptedDecision, + FulfillmentPayloadInvalid, + FulfillmentOrderIdMismatch, + FulfillmentAuthorMismatch, + FulfillmentCounterpartyMismatch, + FulfillmentBuyerMismatch, + FulfillmentSellerMismatch, + FulfillmentListingAddressInvalid, + FulfillmentListingMismatch, + FulfillmentRootMismatch, + FulfillmentPreviousMismatch, + FulfillmentStatusNotPublishable, + FulfillmentUnsupportedTransition, + ForkedFulfillments, + CancellationWithoutCancellableOrder, + CancellationPayloadInvalid, + CancellationOrderIdMismatch, + CancellationAuthorMismatch, + CancellationCounterpartyMismatch, + CancellationBuyerMismatch, + CancellationSellerMismatch, + CancellationListingAddressInvalid, + CancellationListingMismatch, + CancellationRootMismatch, + CancellationPreviousMismatch, + CancellationAfterFulfillment, + ReceiptWithoutEligibleFulfillment, + ReceiptPayloadInvalid, + ReceiptOrderIdMismatch, + ReceiptAuthorMismatch, + ReceiptCounterpartyMismatch, + ReceiptBuyerMismatch, + ReceiptSellerMismatch, + ReceiptListingAddressInvalid, + ReceiptListingMismatch, + ReceiptRootMismatch, + ReceiptPreviousMismatch, + PaymentWithoutAcceptedAgreement, + PaymentPayloadInvalid, + PaymentOrderIdMismatch, + PaymentAuthorMismatch, + PaymentCounterpartyMismatch, + PaymentBuyerMismatch, + PaymentSellerMismatch, + PaymentListingAddressInvalid, + PaymentListingMismatch, + PaymentRootMismatch, + PaymentPreviousMismatch, + PaymentAgreementMismatch, + PaymentQuoteMismatch, + PaymentQuoteVersionMismatch, + PaymentEconomicsDigestMismatch, + PaymentAmountMismatch, + PaymentCurrencyMismatch, + PaymentAfterCancellation, + RevisionAfterPayment, + DuplicatePayments, + SettlementWithoutValidPayment, + SettlementPayloadInvalid, + SettlementOrderIdMismatch, + SettlementAuthorMismatch, + SettlementCounterpartyMismatch, + SettlementBuyerMismatch, + SettlementSellerMismatch, + SettlementListingAddressInvalid, + SettlementListingMismatch, + SettlementRootMismatch, + SettlementPreviousMismatch, + SettlementPaymentEventMismatch, + SettlementAgreementMismatch, + SettlementQuoteMismatch, + SettlementQuoteVersionMismatch, + SettlementEconomicsDigestMismatch, + SettlementAmountMismatch, + SettlementCurrencyMismatch, + DuplicateSettlements, + ForkedLifecycle, +} + +#[cfg(feature = "runtime")] impl<'sdk> OrdersClient<'sdk> { pub async fn status( &self, request: OrderStatusRequest, ) -> Result<OrderStatusReceipt, RadrootsSdkError> { - let order_id = request.validate()?; - let projection = - order_projection_for_order_id(&self.sdk._event_store, &order_id, request.limit) - .await - .map_err(projection_error)?; - Ok(OrderStatusReceipt::from_projection(projection)) + request.validate()?; + let query_result = order_projection_query_for_order_id( + &self.sdk._event_store, + &request.order_id, + request.limit, + ) + .await + .map_err(projection_error)?; + Ok(OrderStatusReceipt::from_query_result(query_result)) } } #[cfg(feature = "runtime")] impl OrderStatusReceipt { - fn from_projection(projection: RadrootsOrderProjection) -> Self { + fn from_query_result(query_result: RadrootsOrderProjectionQueryResult) -> Self { + let projection = query_result.projection; let found = projection.status != RadrootsOrderStatus::Missing; Self { - order_id: projection.order_id.into_string(), + order_id: projection.order_id, + source: SdkOrderStatusSource::LocalEventStore, found, + event_count: query_result.event_count, + limit_applied: query_result.limit_applied, status: projection.status.into(), fulfillment_status: projection.fulfillment_status.map(Into::into), payment_state: projection.payment.state.into(), settlement_state: projection.payment.settlement_state.into(), lifecycle_terminal: projection.lifecycle_terminal, - request_event_id: projection.request_event_id.map(Into::into), - decision_event_id: projection.decision_event_id.map(Into::into), - fulfillment_event_id: projection.fulfillment_event_id.map(Into::into), - cancellation_event_id: projection.cancellation_event_id.map(Into::into), - receipt_event_id: projection.receipt_event_id.map(Into::into), - last_event_id: projection.last_event_id.map(Into::into), - issue_count: projection.issues.len(), + event_ids: query_result.event_ids, + request_event_id: projection.request_event_id, + decision_event_id: projection.decision_event_id, + fulfillment_event_id: projection.fulfillment_event_id, + cancellation_event_id: projection.cancellation_event_id, + receipt_event_id: projection.receipt_event_id, + last_event_id: projection.last_event_id, + issues: projection.issues.into_iter().map(Into::into).collect(), } } } @@ -208,6 +378,443 @@ impl From<RadrootsOrderSettlementState> for OrderSettlementStateKind { } #[cfg(feature = "runtime")] +impl From<RadrootsOrderIssue> for SdkOrderStatusIssue { + fn from(issue: RadrootsOrderIssue) -> Self { + match issue { + RadrootsOrderIssue::MissingRequest => { + Self::new(SdkOrderStatusIssueKind::MissingRequest, Vec::new()) + } + RadrootsOrderIssue::MultipleRequests { event_ids } => { + Self::new(SdkOrderStatusIssueKind::MultipleRequests, event_ids) + } + RadrootsOrderIssue::RequestPayloadInvalid { event_id } => { + Self::single(SdkOrderStatusIssueKind::RequestPayloadInvalid, event_id) + } + RadrootsOrderIssue::RequestOrderIdMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::RequestOrderIdMismatch, event_id) + } + RadrootsOrderIssue::RequestAuthorMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::RequestAuthorMismatch, event_id) + } + RadrootsOrderIssue::RequestListingAddressInvalid { event_id } => Self::single( + SdkOrderStatusIssueKind::RequestListingAddressInvalid, + event_id, + ), + RadrootsOrderIssue::RequestSellerListingMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::RequestSellerListingMismatch, + event_id, + ), + RadrootsOrderIssue::DecisionPayloadInvalid { event_id } => { + Self::single(SdkOrderStatusIssueKind::DecisionPayloadInvalid, event_id) + } + RadrootsOrderIssue::DecisionOrderIdMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::DecisionOrderIdMismatch, event_id) + } + RadrootsOrderIssue::DecisionAuthorMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::DecisionAuthorMismatch, event_id) + } + RadrootsOrderIssue::DecisionCounterpartyMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::DecisionCounterpartyMismatch, + event_id, + ), + RadrootsOrderIssue::DecisionBuyerMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::DecisionBuyerMismatch, event_id) + } + RadrootsOrderIssue::DecisionSellerMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::DecisionSellerMismatch, event_id) + } + RadrootsOrderIssue::DecisionListingAddressInvalid { event_id } => Self::single( + SdkOrderStatusIssueKind::DecisionListingAddressInvalid, + event_id, + ), + RadrootsOrderIssue::DecisionListingMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::DecisionListingMismatch, event_id) + } + RadrootsOrderIssue::DecisionRootMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::DecisionRootMismatch, event_id) + } + RadrootsOrderIssue::DecisionPreviousMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::DecisionPreviousMismatch, event_id) + } + RadrootsOrderIssue::DecisionMissingInventoryCommitments { event_id } => Self::single( + SdkOrderStatusIssueKind::DecisionMissingInventoryCommitments, + event_id, + ), + RadrootsOrderIssue::DecisionInventoryCommitmentMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::DecisionInventoryCommitmentMismatch, + event_id, + ), + RadrootsOrderIssue::DecisionMissingReason { event_id } => { + Self::single(SdkOrderStatusIssueKind::DecisionMissingReason, event_id) + } + RadrootsOrderIssue::ConflictingDecisions { event_ids } => { + Self::new(SdkOrderStatusIssueKind::ConflictingDecisions, event_ids) + } + RadrootsOrderIssue::RevisionProposalWithoutAcceptedDecision { event_id } => { + Self::single( + SdkOrderStatusIssueKind::RevisionProposalWithoutAcceptedDecision, + event_id, + ) + } + RadrootsOrderIssue::RevisionProposalPayloadInvalid { event_id } => Self::single( + SdkOrderStatusIssueKind::RevisionProposalPayloadInvalid, + event_id, + ), + RadrootsOrderIssue::RevisionProposalOrderIdMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::RevisionProposalOrderIdMismatch, + event_id, + ), + RadrootsOrderIssue::RevisionProposalAuthorMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::RevisionProposalAuthorMismatch, + event_id, + ), + RadrootsOrderIssue::RevisionProposalCounterpartyMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::RevisionProposalCounterpartyMismatch, + event_id, + ), + RadrootsOrderIssue::RevisionProposalBuyerMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::RevisionProposalBuyerMismatch, + event_id, + ), + RadrootsOrderIssue::RevisionProposalSellerMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::RevisionProposalSellerMismatch, + event_id, + ), + RadrootsOrderIssue::RevisionProposalListingAddressInvalid { event_id } => Self::single( + SdkOrderStatusIssueKind::RevisionProposalListingAddressInvalid, + event_id, + ), + RadrootsOrderIssue::RevisionProposalListingMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::RevisionProposalListingMismatch, + event_id, + ), + RadrootsOrderIssue::RevisionProposalRootMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::RevisionProposalRootMismatch, + event_id, + ), + RadrootsOrderIssue::RevisionProposalPreviousMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::RevisionProposalPreviousMismatch, + event_id, + ), + RadrootsOrderIssue::RevisionDecisionWithoutProposal { event_id } => Self::single( + SdkOrderStatusIssueKind::RevisionDecisionWithoutProposal, + event_id, + ), + RadrootsOrderIssue::RevisionDecisionPayloadInvalid { event_id } => Self::single( + SdkOrderStatusIssueKind::RevisionDecisionPayloadInvalid, + event_id, + ), + RadrootsOrderIssue::RevisionDecisionOrderIdMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::RevisionDecisionOrderIdMismatch, + event_id, + ), + RadrootsOrderIssue::RevisionDecisionAuthorMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::RevisionDecisionAuthorMismatch, + event_id, + ), + RadrootsOrderIssue::RevisionDecisionCounterpartyMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::RevisionDecisionCounterpartyMismatch, + event_id, + ), + RadrootsOrderIssue::RevisionDecisionBuyerMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::RevisionDecisionBuyerMismatch, + event_id, + ), + RadrootsOrderIssue::RevisionDecisionSellerMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::RevisionDecisionSellerMismatch, + event_id, + ), + RadrootsOrderIssue::RevisionDecisionListingAddressInvalid { event_id } => Self::single( + SdkOrderStatusIssueKind::RevisionDecisionListingAddressInvalid, + event_id, + ), + RadrootsOrderIssue::RevisionDecisionListingMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::RevisionDecisionListingMismatch, + event_id, + ), + RadrootsOrderIssue::RevisionDecisionRootMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::RevisionDecisionRootMismatch, + event_id, + ), + RadrootsOrderIssue::RevisionDecisionPreviousMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::RevisionDecisionPreviousMismatch, + event_id, + ), + RadrootsOrderIssue::RevisionDecisionRevisionIdMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::RevisionDecisionRevisionIdMismatch, + event_id, + ), + RadrootsOrderIssue::FulfillmentWithoutAcceptedDecision { event_id } => Self::single( + SdkOrderStatusIssueKind::FulfillmentWithoutAcceptedDecision, + event_id, + ), + RadrootsOrderIssue::FulfillmentPayloadInvalid { event_id } => { + Self::single(SdkOrderStatusIssueKind::FulfillmentPayloadInvalid, event_id) + } + RadrootsOrderIssue::FulfillmentOrderIdMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::FulfillmentOrderIdMismatch, + event_id, + ), + RadrootsOrderIssue::FulfillmentAuthorMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::FulfillmentAuthorMismatch, event_id) + } + RadrootsOrderIssue::FulfillmentCounterpartyMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::FulfillmentCounterpartyMismatch, + event_id, + ), + RadrootsOrderIssue::FulfillmentBuyerMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::FulfillmentBuyerMismatch, event_id) + } + RadrootsOrderIssue::FulfillmentSellerMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::FulfillmentSellerMismatch, event_id) + } + RadrootsOrderIssue::FulfillmentListingAddressInvalid { event_id } => Self::single( + SdkOrderStatusIssueKind::FulfillmentListingAddressInvalid, + event_id, + ), + RadrootsOrderIssue::FulfillmentListingMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::FulfillmentListingMismatch, + event_id, + ), + RadrootsOrderIssue::FulfillmentRootMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::FulfillmentRootMismatch, event_id) + } + RadrootsOrderIssue::FulfillmentPreviousMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::FulfillmentPreviousMismatch, + event_id, + ), + RadrootsOrderIssue::FulfillmentStatusNotPublishable { event_id } => Self::single( + SdkOrderStatusIssueKind::FulfillmentStatusNotPublishable, + event_id, + ), + RadrootsOrderIssue::FulfillmentUnsupportedTransition { event_id } => Self::single( + SdkOrderStatusIssueKind::FulfillmentUnsupportedTransition, + event_id, + ), + RadrootsOrderIssue::ForkedFulfillments { event_ids } => { + Self::new(SdkOrderStatusIssueKind::ForkedFulfillments, event_ids) + } + RadrootsOrderIssue::CancellationWithoutCancellableOrder { event_id } => Self::single( + SdkOrderStatusIssueKind::CancellationWithoutCancellableOrder, + event_id, + ), + RadrootsOrderIssue::CancellationPayloadInvalid { event_id } => Self::single( + SdkOrderStatusIssueKind::CancellationPayloadInvalid, + event_id, + ), + RadrootsOrderIssue::CancellationOrderIdMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::CancellationOrderIdMismatch, + event_id, + ), + RadrootsOrderIssue::CancellationAuthorMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::CancellationAuthorMismatch, + event_id, + ), + RadrootsOrderIssue::CancellationCounterpartyMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::CancellationCounterpartyMismatch, + event_id, + ), + RadrootsOrderIssue::CancellationBuyerMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::CancellationBuyerMismatch, event_id) + } + RadrootsOrderIssue::CancellationSellerMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::CancellationSellerMismatch, + event_id, + ), + RadrootsOrderIssue::CancellationListingAddressInvalid { event_id } => Self::single( + SdkOrderStatusIssueKind::CancellationListingAddressInvalid, + event_id, + ), + RadrootsOrderIssue::CancellationListingMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::CancellationListingMismatch, + event_id, + ), + RadrootsOrderIssue::CancellationRootMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::CancellationRootMismatch, event_id) + } + RadrootsOrderIssue::CancellationPreviousMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::CancellationPreviousMismatch, + event_id, + ), + RadrootsOrderIssue::CancellationAfterFulfillment { event_id } => Self::single( + SdkOrderStatusIssueKind::CancellationAfterFulfillment, + event_id, + ), + RadrootsOrderIssue::ReceiptWithoutEligibleFulfillment { event_id } => Self::single( + SdkOrderStatusIssueKind::ReceiptWithoutEligibleFulfillment, + event_id, + ), + RadrootsOrderIssue::ReceiptPayloadInvalid { event_id } => { + Self::single(SdkOrderStatusIssueKind::ReceiptPayloadInvalid, event_id) + } + RadrootsOrderIssue::ReceiptOrderIdMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::ReceiptOrderIdMismatch, event_id) + } + RadrootsOrderIssue::ReceiptAuthorMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::ReceiptAuthorMismatch, event_id) + } + RadrootsOrderIssue::ReceiptCounterpartyMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::ReceiptCounterpartyMismatch, + event_id, + ), + RadrootsOrderIssue::ReceiptBuyerMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::ReceiptBuyerMismatch, event_id) + } + RadrootsOrderIssue::ReceiptSellerMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::ReceiptSellerMismatch, event_id) + } + RadrootsOrderIssue::ReceiptListingAddressInvalid { event_id } => Self::single( + SdkOrderStatusIssueKind::ReceiptListingAddressInvalid, + event_id, + ), + RadrootsOrderIssue::ReceiptListingMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::ReceiptListingMismatch, event_id) + } + RadrootsOrderIssue::ReceiptRootMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::ReceiptRootMismatch, event_id) + } + RadrootsOrderIssue::ReceiptPreviousMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::ReceiptPreviousMismatch, event_id) + } + RadrootsOrderIssue::PaymentWithoutAcceptedAgreement { event_id } => Self::single( + SdkOrderStatusIssueKind::PaymentWithoutAcceptedAgreement, + event_id, + ), + RadrootsOrderIssue::PaymentPayloadInvalid { event_id } => { + Self::single(SdkOrderStatusIssueKind::PaymentPayloadInvalid, event_id) + } + RadrootsOrderIssue::PaymentOrderIdMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::PaymentOrderIdMismatch, event_id) + } + RadrootsOrderIssue::PaymentAuthorMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::PaymentAuthorMismatch, event_id) + } + RadrootsOrderIssue::PaymentCounterpartyMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::PaymentCounterpartyMismatch, + event_id, + ), + RadrootsOrderIssue::PaymentBuyerMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::PaymentBuyerMismatch, event_id) + } + RadrootsOrderIssue::PaymentSellerMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::PaymentSellerMismatch, event_id) + } + RadrootsOrderIssue::PaymentListingAddressInvalid { event_id } => Self::single( + SdkOrderStatusIssueKind::PaymentListingAddressInvalid, + event_id, + ), + RadrootsOrderIssue::PaymentListingMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::PaymentListingMismatch, event_id) + } + RadrootsOrderIssue::PaymentRootMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::PaymentRootMismatch, event_id) + } + RadrootsOrderIssue::PaymentPreviousMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::PaymentPreviousMismatch, event_id) + } + RadrootsOrderIssue::PaymentAgreementMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::PaymentAgreementMismatch, event_id) + } + RadrootsOrderIssue::PaymentQuoteMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::PaymentQuoteMismatch, event_id) + } + RadrootsOrderIssue::PaymentQuoteVersionMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::PaymentQuoteVersionMismatch, + event_id, + ), + RadrootsOrderIssue::PaymentEconomicsDigestMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::PaymentEconomicsDigestMismatch, + event_id, + ), + RadrootsOrderIssue::PaymentAmountMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::PaymentAmountMismatch, event_id) + } + RadrootsOrderIssue::PaymentCurrencyMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::PaymentCurrencyMismatch, event_id) + } + RadrootsOrderIssue::PaymentAfterCancellation { event_id } => { + Self::single(SdkOrderStatusIssueKind::PaymentAfterCancellation, event_id) + } + RadrootsOrderIssue::RevisionAfterPayment { event_id } => { + Self::single(SdkOrderStatusIssueKind::RevisionAfterPayment, event_id) + } + RadrootsOrderIssue::DuplicatePayments { event_ids } => { + Self::new(SdkOrderStatusIssueKind::DuplicatePayments, event_ids) + } + RadrootsOrderIssue::SettlementWithoutValidPayment { event_id } => Self::single( + SdkOrderStatusIssueKind::SettlementWithoutValidPayment, + event_id, + ), + RadrootsOrderIssue::SettlementPayloadInvalid { event_id } => { + Self::single(SdkOrderStatusIssueKind::SettlementPayloadInvalid, event_id) + } + RadrootsOrderIssue::SettlementOrderIdMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::SettlementOrderIdMismatch, event_id) + } + RadrootsOrderIssue::SettlementAuthorMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::SettlementAuthorMismatch, event_id) + } + RadrootsOrderIssue::SettlementCounterpartyMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::SettlementCounterpartyMismatch, + event_id, + ), + RadrootsOrderIssue::SettlementBuyerMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::SettlementBuyerMismatch, event_id) + } + RadrootsOrderIssue::SettlementSellerMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::SettlementSellerMismatch, event_id) + } + RadrootsOrderIssue::SettlementListingAddressInvalid { event_id } => Self::single( + SdkOrderStatusIssueKind::SettlementListingAddressInvalid, + event_id, + ), + RadrootsOrderIssue::SettlementListingMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::SettlementListingMismatch, event_id) + } + RadrootsOrderIssue::SettlementRootMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::SettlementRootMismatch, event_id) + } + RadrootsOrderIssue::SettlementPreviousMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::SettlementPreviousMismatch, + event_id, + ), + RadrootsOrderIssue::SettlementPaymentEventMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::SettlementPaymentEventMismatch, + event_id, + ), + RadrootsOrderIssue::SettlementAgreementMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::SettlementAgreementMismatch, + event_id, + ), + RadrootsOrderIssue::SettlementQuoteMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::SettlementQuoteMismatch, event_id) + } + RadrootsOrderIssue::SettlementQuoteVersionMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::SettlementQuoteVersionMismatch, + event_id, + ), + RadrootsOrderIssue::SettlementEconomicsDigestMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::SettlementEconomicsDigestMismatch, + event_id, + ), + RadrootsOrderIssue::SettlementAmountMismatch { event_id } => { + Self::single(SdkOrderStatusIssueKind::SettlementAmountMismatch, event_id) + } + RadrootsOrderIssue::SettlementCurrencyMismatch { event_id } => Self::single( + SdkOrderStatusIssueKind::SettlementCurrencyMismatch, + event_id, + ), + RadrootsOrderIssue::DuplicateSettlements { event_ids } => { + Self::new(SdkOrderStatusIssueKind::DuplicateSettlements, event_ids) + } + RadrootsOrderIssue::ForkedLifecycle { event_ids } => { + Self::new(SdkOrderStatusIssueKind::ForkedLifecycle, event_ids) + } + } + } +} + +#[cfg(feature = "runtime")] fn projection_error(error: RadrootsOrderStoreQueryError) -> RadrootsSdkError { let message = match error { RadrootsOrderStoreQueryError::Store(_) => "order status store query failed", diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs @@ -24,7 +24,7 @@ use radroots_sdk::protocol::order::{ use radroots_sdk::{ ORDER_STATUS_DEFAULT_LIMIT, ORDER_STATUS_MAX_LIMIT, OrderPaymentStateKind, OrderSettlementStateKind, OrderStatusKind, OrderStatusRequest, RadrootsSdk, RadrootsSdkError, - RadrootsSdkTimestamp, + RadrootsSdkTimestamp, SdkOrderStatusIssueKind, SdkOrderStatusSource, }; const BUYER_SECRET_KEY_HEX: &str = @@ -55,6 +55,10 @@ fn order_id(raw: &str) -> RadrootsOrderId { RadrootsOrderId::parse(raw).expect("order id") } +fn status_request(raw: &str) -> OrderStatusRequest { + OrderStatusRequest::parse(raw).expect("order status request") +} + fn listing_address() -> RadrootsListingAddress { RadrootsListingAddress::parse(format!( "{KIND_LISTING}:{SELLER_PUBLIC_KEY_HEX}:AAAAAAAAAAAAAAAAAAAAAg" @@ -189,21 +193,25 @@ fn signed_order_decision_event( #[tokio::test] async fn order_status_returns_not_found_for_missing_local_order() { let (_tempdir, sdk, _store) = directory_sdk_and_store().await; - let request = OrderStatusRequest::new("order-1"); + let request = status_request("order-1"); assert_eq!(request.limit, ORDER_STATUS_DEFAULT_LIMIT); let receipt = sdk.orders().status(request).await.expect("status"); assert!(!receipt.found); - assert_eq!(receipt.order_id, "order-1"); + assert_eq!(receipt.order_id.as_str(), "order-1"); + assert_eq!(receipt.source, SdkOrderStatusSource::LocalEventStore); + assert_eq!(receipt.event_count, 0); + assert_eq!(receipt.limit_applied, ORDER_STATUS_DEFAULT_LIMIT); + assert!(receipt.event_ids.is_empty()); assert_eq!(receipt.status, OrderStatusKind::Missing); assert_eq!(receipt.payment_state, OrderPaymentStateKind::NotRecorded); assert_eq!( receipt.settlement_state, OrderSettlementStateKind::NotRequired ); - assert_eq!(receipt.issue_count, 0); + assert!(receipt.issues.is_empty()); } #[tokio::test] @@ -212,12 +220,12 @@ async fn order_status_rejects_invalid_limits_before_querying() { let zero = sdk .orders() - .status(OrderStatusRequest::new("order-1").with_limit(0)) + .status(status_request("order-1").with_limit(0)) .await .expect_err("zero limit"); let too_large = sdk .orders() - .status(OrderStatusRequest::new("order-1").with_limit(ORDER_STATUS_MAX_LIMIT + 1)) + .status(status_request("order-1").with_limit(ORDER_STATUS_MAX_LIMIT + 1)) .await .expect_err("too large"); @@ -225,6 +233,13 @@ async fn order_status_rejects_invalid_limits_before_querying() { assert!(matches!(too_large, RadrootsSdkError::InvalidRequest { .. })); } +#[test] +fn order_status_parse_rejects_invalid_order_ids() { + let error = OrderStatusRequest::parse("bad order id").expect_err("invalid order id"); + + assert!(matches!(error, RadrootsSdkError::InvalidRequest { .. })); +} + #[tokio::test] async fn order_status_projects_local_request_and_decision_events() { let (_tempdir, sdk, store) = directory_sdk_and_store().await; @@ -244,29 +259,148 @@ async fn order_status_projects_local_request_and_decision_events() { let receipt = sdk .orders() - .status(OrderStatusRequest::new("order-1").with_limit(1_000)) + .status(status_request("order-1").with_limit(1_000)) .await .expect("status"); assert!(receipt.found); + assert_eq!(receipt.order_id.as_str(), "order-1"); + assert_eq!(receipt.source, SdkOrderStatusSource::LocalEventStore); + assert_eq!(receipt.event_count, 2); + assert_eq!(receipt.limit_applied, 1_000); + assert_eq!( + receipt + .event_ids + .iter() + .map(RadrootsEventId::as_str) + .collect::<Vec<_>>(), + vec![request_event.id.as_str(), decision_event.id.as_str()] + ); assert_eq!(receipt.status, OrderStatusKind::Accepted); assert_eq!( - receipt.request_event_id.as_deref(), + receipt + .request_event_id + .as_ref() + .map(RadrootsEventId::as_str), Some(request_event.id.as_str()) ); assert_eq!( - receipt.decision_event_id.as_deref(), + receipt + .decision_event_id + .as_ref() + .map(RadrootsEventId::as_str), Some(decision_event.id.as_str()) ); assert_eq!( - receipt.last_event_id.as_deref(), + receipt.last_event_id.as_ref().map(RadrootsEventId::as_str), Some(decision_event.id.as_str()) ); - assert_eq!(receipt.issue_count, 0); + assert!(receipt.issues.is_empty()); assert!(!receipt.lifecycle_terminal); } #[tokio::test] +async fn order_status_reports_limited_local_results() { + let (_tempdir, sdk, store) = directory_sdk_and_store().await; + let request_event = signed_order_request_event("order-1", 25); + let request_event_id = RadrootsEventId::parse(request_event.id.as_str()).expect("request id"); + let decision_event = signed_order_decision_event("order-1", &request_event_id, 26); + + for (event, observed_at_ms) in [(request_event.clone(), 2_500), (decision_event, 2_600)] { + store + .ingest_event(RadrootsEventIngest::new(event, observed_at_ms)) + .await + .expect("ingest"); + } + + let receipt = sdk + .orders() + .status(status_request("order-1").with_limit(1)) + .await + .expect("status"); + + assert!(receipt.found); + assert_eq!(receipt.status, OrderStatusKind::Requested); + assert_eq!(receipt.event_count, 1); + assert_eq!(receipt.limit_applied, 1); + assert_eq!( + receipt + .event_ids + .iter() + .map(RadrootsEventId::as_str) + .collect::<Vec<_>>(), + vec![request_event.id.as_str()] + ); + assert_eq!( + receipt + .request_event_id + .as_ref() + .map(RadrootsEventId::as_str), + Some(request_event.id.as_str()) + ); + assert!(receipt.decision_event_id.is_none()); + assert_eq!( + receipt.last_event_id.as_ref().map(RadrootsEventId::as_str), + Some(request_event.id.as_str()) + ); + assert!(receipt.issues.is_empty()); +} + +#[tokio::test] +async fn order_status_reports_typed_reducer_issues() { + let (_tempdir, sdk, store) = directory_sdk_and_store().await; + let first_request_event = signed_order_request_event("order-1", 27); + let second_request_event = signed_order_request_event("order-1", 28); + + for (event, observed_at_ms) in [ + (first_request_event.clone(), 2_700), + (second_request_event.clone(), 2_800), + ] { + store + .ingest_event(RadrootsEventIngest::new(event, observed_at_ms)) + .await + .expect("ingest"); + } + + let receipt = sdk + .orders() + .status(status_request("order-1")) + .await + .expect("status"); + + assert!(receipt.found); + assert_eq!(receipt.status, OrderStatusKind::Invalid); + assert_eq!(receipt.event_count, 2); + assert_eq!( + receipt + .event_ids + .iter() + .map(RadrootsEventId::as_str) + .collect::<Vec<_>>(), + vec![ + first_request_event.id.as_str(), + second_request_event.id.as_str() + ] + ); + let issue = receipt + .issues + .iter() + .find(|issue| issue.kind == SdkOrderStatusIssueKind::MultipleRequests) + .expect("multiple request issue"); + assert_eq!( + issue + .event_ids + .iter() + .map(RadrootsEventId::as_str) + .collect::<Vec<_>>(), + vec![ + first_request_event.id.as_str(), + second_request_event.id.as_str() + ] + ); +} + +#[tokio::test] async fn order_status_maps_malformed_local_data_to_sanitized_error() { let (_tempdir, sdk, store) = directory_sdk_and_store().await; let request_event = signed_order_request_event("order-1", 30); @@ -283,7 +417,7 @@ async fn order_status_maps_malformed_local_data_to_sanitized_error() { let error = sdk .orders() - .status(OrderStatusRequest::new("order-1")) + .status(status_request("order-1")) .await .expect_err("projection error"); let message = error.to_string();