cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 3295e3f051cbef9ede766a36cdd6aa423ff68d77
parent d6e4a59e0c073a27634b013185bc1e57c4e4d998
Author: triesap <tyson@radroots.org>
Date:   Wed, 17 Jun 2026 15:33:36 -0700

order: migrate status to SDK projection

- route public order status through the CLI SDK adapter

- map SDK order status receipts into the CLI status view

- keep relay status private for non-MVP preflight paths

- update tests for local projection status semantics

Diffstat:
Msrc/ops/exec/order.rs | 38++++++++++++++++++--------------------
Msrc/registry/mod.rs | 2--
Msrc/runtime/order.rs | 392++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/signer_runtime_modes.rs | 23++++++++++++++---------
Mtests/target_cli.rs | 98++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
5 files changed, 499 insertions(+), 54 deletions(-)

diff --git a/src/ops/exec/order.rs b/src/ops/exec/order.rs @@ -554,7 +554,7 @@ impl OperationService<OrderStatusGetRequest> for OrderOperationService<'_> { key: required_order_key(&request)?, }; let view = crate::runtime::order::status(self.config, &args).map_err(|error| { - OperationAdapterError::runtime_failure(request.operation_id(), error) + OperationAdapterError::sdk_adapter_failure(request.operation_id(), error) })?; status_result::<OrderStatusGetResult>(request.operation_id(), &view) } @@ -1996,7 +1996,7 @@ mod tests { } #[test] - fn order_status_get_requires_relay_configuration() { + fn order_status_get_uses_local_sdk_projection_without_relay() { let dir = tempdir().expect("tempdir"); let config = sample_config(dir.path()); let service = OperationAdapter::new(OrderOperationService::new(&config)); @@ -2005,26 +2005,24 @@ mod tests { OrderStatusGetRequest::from_data(data(&[("order_id", "ord_pending")])), ) .expect("order status request"); - let error = service.execute(status).expect_err("status unconfigured"); - let output_error = error.to_output_error(); - - assert_eq!(output_error.code, "operation_unavailable"); - assert!(output_error.message.contains("configured relay")); - let detail = output_error.detail.as_ref().expect("status detail"); - assert_eq!(detail["state"], "unconfigured"); - assert_eq!(detail["order_id"], "ord_pending"); - assert_eq!(detail["fetched_count"], 0); - assert_eq!(detail["decoded_count"], 0); - assert_eq!(detail["skipped_count"], 0); - let envelope = crate::out::envelope::OutputEnvelope::failure( - "order.status.get", - output_error, - OperationContext::default().envelope_context("req_order_status"), - ); + let envelope = service + .execute(status) + .expect("status result") + .to_envelope(OperationContext::default().envelope_context("req_order_status")) + .expect("status envelope"); + + assert_eq!(envelope.operation_id, "order.status.get"); + assert_eq!(envelope.result["state"], "missing"); + assert_eq!(envelope.result["source"], "SDK local order projection"); assert_eq!( - envelope.next_actions[0].command.as_deref(), - Some("radroots --relay wss://relay.example.com order status get ord_pending") + envelope.result["actor_context_source"], + "sdk_local_projection" ); + assert_eq!(envelope.result["order_id"], "ord_pending"); + assert_eq!(envelope.result["fetched_count"], 0); + assert_eq!(envelope.result["decoded_count"], 0); + assert_eq!(envelope.result["skipped_count"], 0); + assert!(envelope.next_actions.is_empty()); } #[test] diff --git a/src/registry/mod.rs b/src/registry/mod.rs @@ -194,7 +194,6 @@ pub fn network_requirement(operation_id: &str) -> NetworkRequirement { | "listing.update" | "listing.archive" | "order.submit" - | "order.status.get" | "order.event.list" | "validation.receipt.get" | "validation.receipt.list" @@ -577,7 +576,6 @@ mod tests { "order.revision.decline", "order.fulfillment.update", "order.receipt.record", - "order.status.get", "order.event.list", "validation.receipt.get", "validation.receipt.list", diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -71,6 +71,10 @@ use radroots_sdk::client::{ use radroots_sdk::config::{ RadrootsSdkConfig, SdkEnvironment, SdkTransportMode, SignerConfig as SdkSignerConfig, }; +use radroots_sdk::{ + OrderFulfillmentStatusKind, OrderPaymentStateKind, OrderSettlementStateKind, OrderStatusKind, + OrderStatusReceipt, OrderStatusRequest, SdkOrderStatusIssue, +}; use radroots_sql_core::SqliteExecutor; use radroots_trade::order::{ RadrootsListingInventoryAccountingIssue, RadrootsListingInventoryAccountingProjection, @@ -110,6 +114,7 @@ use crate::runtime::sync::{ RelayIngestScope, freshness_for_scope, freshness_requires_refresh, market_refresh, relay_provenance_relays_for_scope, }; +use crate::runtime::sdk::{CliSdkAdapterError, CliSdkSession}; use crate::view::runtime::{ OrderAppRecordExportView, OrderAppRecordListView, OrderAppRecordSummaryView, OrderCancellationView, OrderDecisionView, OrderDraftItemView, OrderEventListEntryView, @@ -137,6 +142,7 @@ const ORDER_PAYMENT_SOURCE: &str = "direct Nostr relay payment publish · local const ORDER_SETTLEMENT_SOURCE: &str = "direct Nostr relay settlement publish · local key"; const ORDER_EVENT_LIST_SOURCE: &str = "direct Nostr relay fetch · selected seller identity"; const ORDER_STATUS_SOURCE: &str = "direct Nostr relay status fetch · active order reducer"; +const ORDER_STATUS_SDK_SOURCE: &str = "SDK local order projection"; const ORDER_EVENT_LIST_RELAY_ACTION: &str = "radroots --relay wss://relay.example.com order event list"; const ORDER_BUYER_ACTOR_SOURCE_RESOLVED_ACCOUNT: &str = "resolved_account"; @@ -145,6 +151,7 @@ const ORDER_APP_RECORD_LIST_LIMIT: u32 = 500; const ORDER_ACTOR_CONTEXT_ORDER_DRAFT: &str = "order_draft"; const ORDER_ACTOR_CONTEXT_RESOLVED_ACCOUNT: &str = "resolved_account"; const ORDER_ACTOR_CONTEXT_NETWORK_ONLY: &str = "network_only"; +const ORDER_ACTOR_CONTEXT_SDK_LOCAL: &str = "sdk_local_projection"; const ORDERS_DIR: &str = "orders/drafts"; const APP_ORDER_ALREADY_SUBMITTED_ISSUE: &str = "app_order_already_submitted"; const APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE: &str = "app_order_signed_evidence_conflict"; @@ -1352,7 +1359,7 @@ pub fn decide( } if resolution.requests.len() == 1 { let request = resolution.requests[0].clone(); - let status_view = status( + let status_view = relay_status( config, &OrderStatusArgs { key: args.key.clone(), @@ -2219,6 +2226,16 @@ pub fn settlement_decision( pub fn status( config: &RuntimeConfig, args: &OrderStatusArgs, +) -> Result<OrderStatusView, CliSdkAdapterError> { + let request = OrderStatusRequest::parse(args.key.as_str())?; + let session = CliSdkSession::connect(config)?; + let receipt = session.block_on(session.sdk().orders().status(request))?; + Ok(sdk_order_status_view(receipt)) +} + +fn relay_status( + config: &RuntimeConfig, + args: &OrderStatusArgs, ) -> Result<OrderStatusView, RuntimeError> { if config.relay.urls.is_empty() { return Ok(OrderStatusView { @@ -2311,6 +2328,271 @@ pub fn status( Ok(view) } +fn sdk_order_status_view(receipt: OrderStatusReceipt) -> OrderStatusView { + let state = sdk_order_status_state(receipt.status).to_owned(); + let reducer_issues = receipt + .issues + .iter() + .map(sdk_order_status_issue_view) + .collect::<Vec<_>>(); + let reason = sdk_order_status_reason(receipt.status, receipt.order_id.as_str()); + let fulfillment = sdk_order_status_fulfillment_view(&receipt, reducer_issues.as_slice()); + let lifecycle = sdk_order_status_lifecycle_view(&receipt, reducer_issues.as_slice()); + let payment = Some(sdk_order_status_payment_view(&receipt, reducer_issues.as_slice())); + + OrderStatusView { + state, + source: ORDER_STATUS_SDK_SOURCE.to_owned(), + order_id: receipt.order_id.to_string(), + actor_context_source: ORDER_ACTOR_CONTEXT_SDK_LOCAL.to_owned(), + request_event_id: sdk_event_id_string(receipt.request_event_id.as_ref()), + decision_event_id: sdk_event_id_string(receipt.decision_event_id.as_ref()), + agreement_event_id: sdk_order_status_agreement_event_id(&receipt), + listing_event_id: None, + listing_addr: None, + buyer_pubkey: None, + seller_pubkey: None, + economics: None, + last_event_id: sdk_event_id_string(receipt.last_event_id.as_ref()), + revision: None, + inventory: None, + fulfillment, + lifecycle: Some(lifecycle), + payment, + reducer_issues, + target_relays: Vec::new(), + connected_relays: Vec::new(), + failed_relays: Vec::new(), + fetched_count: 0, + decoded_count: receipt.event_count, + skipped_count: 0, + reason, + actions: Vec::new(), + } +} + +fn sdk_order_status_state(status: OrderStatusKind) -> &'static str { + match status { + OrderStatusKind::Missing => "missing", + OrderStatusKind::Requested => "requested", + OrderStatusKind::Accepted => "accepted", + OrderStatusKind::Declined => "declined", + OrderStatusKind::Cancelled => "cancelled", + OrderStatusKind::Completed => "completed", + OrderStatusKind::Disputed => "disputed", + OrderStatusKind::Invalid => "invalid", + _ => "unknown", + } +} + +fn sdk_order_status_reason(status: OrderStatusKind, order_id: &str) -> Option<String> { + match status { + OrderStatusKind::Missing => { + Some(format!("no local SDK order events matched `{order_id}`")) + } + OrderStatusKind::Invalid => Some(format!( + "local SDK order events for `{order_id}` failed reducer validation" + )), + _ => None, + } +} + +fn sdk_order_status_agreement_event_id(receipt: &OrderStatusReceipt) -> Option<String> { + match receipt.status { + OrderStatusKind::Accepted + | OrderStatusKind::Cancelled + | OrderStatusKind::Completed + | OrderStatusKind::Disputed => sdk_event_id_string(receipt.decision_event_id.as_ref()), + _ => None, + } +} + +fn sdk_order_status_fulfillment_view( + receipt: &OrderStatusReceipt, + issues: &[OrderIssueView], +) -> Option<OrderStatusFulfillmentView> { + let fulfillment_issues = issues + .iter() + .filter(|issue| { + issue.code.starts_with("fulfillment_") || issue.code == "forked_fulfillments" + }) + .cloned() + .collect::<Vec<_>>(); + if !fulfillment_issues.is_empty() { + return Some(OrderStatusFulfillmentView { + state: "invalid".to_owned(), + event_id: sdk_event_id_string(receipt.fulfillment_event_id.as_ref()), + root_event_id: sdk_event_id_string(receipt.request_event_id.as_ref()), + prev_event_id: sdk_event_id_string(receipt.decision_event_id.as_ref()), + terminal: false, + inventory_released: false, + issues: fulfillment_issues, + }); + } + let fulfillment_status = receipt.fulfillment_status?; + Some(OrderStatusFulfillmentView { + state: sdk_fulfillment_status_state(fulfillment_status).to_owned(), + event_id: sdk_event_id_string(receipt.fulfillment_event_id.as_ref()), + root_event_id: sdk_event_id_string(receipt.request_event_id.as_ref()), + prev_event_id: sdk_event_id_string(receipt.decision_event_id.as_ref()), + terminal: matches!( + fulfillment_status, + OrderFulfillmentStatusKind::Delivered | OrderFulfillmentStatusKind::SellerCancelled + ), + inventory_released: matches!( + fulfillment_status, + OrderFulfillmentStatusKind::SellerCancelled + ), + issues: Vec::new(), + }) +} + +fn sdk_order_status_payment_view( + receipt: &OrderStatusReceipt, + issues: &[OrderIssueView], +) -> OrderStatusPaymentView { + let payment_issues = issues + .iter() + .filter(|issue| { + issue.code.starts_with("payment_") || issue.code.starts_with("settlement_") + }) + .cloned() + .collect::<Vec<_>>(); + OrderStatusPaymentView { + state: sdk_payment_state(receipt.payment_state).to_owned(), + settlement_state: sdk_settlement_state(receipt.settlement_state).to_owned(), + payment_event_id: None, + settlement_event_id: None, + agreement_event_id: sdk_order_status_agreement_event_id(receipt), + quote_id: None, + quote_version: None, + economics_digest: None, + amount: None, + currency: None, + method: None, + reference: None, + paid_at: None, + reason: None, + issues: payment_issues, + } +} + +fn sdk_order_status_lifecycle_view( + receipt: &OrderStatusReceipt, + issues: &[OrderIssueView], +) -> OrderStatusLifecycleView { + let cancellation = receipt.cancellation_event_id.as_ref().map(|event_id| { + OrderStatusLifecycleCancellationView { + event_id: event_id.to_string(), + root_event_id: sdk_event_id_string(receipt.request_event_id.as_ref()), + prev_event_id: sdk_event_id_string(receipt.decision_event_id.as_ref()), + reason: None, + } + }); + let receipt_view = receipt.receipt_event_id.as_ref().map(|event_id| { + OrderStatusLifecycleReceiptView { + event_id: event_id.to_string(), + root_event_id: sdk_event_id_string(receipt.request_event_id.as_ref()), + prev_event_id: sdk_event_id_string(receipt.fulfillment_event_id.as_ref()), + received: matches!(receipt.status, OrderStatusKind::Completed), + issue: None, + received_at: None, + } + }); + + OrderStatusLifecycleView { + phase: sdk_order_status_lifecycle_phase(receipt).to_owned(), + terminal: receipt.lifecycle_terminal, + event_id: sdk_event_id_string(receipt.last_event_id.as_ref()), + root_event_id: sdk_event_id_string(receipt.request_event_id.as_ref()), + prev_event_id: None, + cancellation, + receipt: receipt_view, + settlement_required: !matches!( + receipt.settlement_state, + OrderSettlementStateKind::NotRequired + ), + settlement_reason: None, + issues: issues.to_vec(), + } +} + +fn sdk_order_status_lifecycle_phase(receipt: &OrderStatusReceipt) -> &'static str { + match receipt.status { + OrderStatusKind::Missing => "missing", + OrderStatusKind::Requested => "requested", + OrderStatusKind::Accepted => match receipt.fulfillment_status { + Some(OrderFulfillmentStatusKind::Preparing) + | Some(OrderFulfillmentStatusKind::OutForDelivery) => "fulfillment_in_progress", + Some( + OrderFulfillmentStatusKind::ReadyForPickup + | OrderFulfillmentStatusKind::Delivered + | OrderFulfillmentStatusKind::SellerCancelled, + ) => "fulfilled", + Some(OrderFulfillmentStatusKind::AcceptedNotFulfilled) | None => "accepted", + Some(_) => "accepted", + }, + OrderStatusKind::Declined => "declined", + OrderStatusKind::Cancelled => "cancelled", + OrderStatusKind::Completed => "completed", + OrderStatusKind::Disputed => "disputed", + OrderStatusKind::Invalid => "invalid", + _ => "unknown", + } +} + +fn sdk_fulfillment_status_state(status: OrderFulfillmentStatusKind) -> &'static str { + match status { + OrderFulfillmentStatusKind::AcceptedNotFulfilled => "accepted_not_fulfilled", + OrderFulfillmentStatusKind::Preparing => "preparing", + OrderFulfillmentStatusKind::ReadyForPickup => "ready_for_pickup", + OrderFulfillmentStatusKind::OutForDelivery => "out_for_delivery", + OrderFulfillmentStatusKind::Delivered => "delivered", + OrderFulfillmentStatusKind::SellerCancelled => "seller_cancelled", + _ => "unknown", + } +} + +fn sdk_payment_state(state: OrderPaymentStateKind) -> &'static str { + match state { + OrderPaymentStateKind::NotRecorded => "not_recorded", + OrderPaymentStateKind::Recorded => "recorded", + OrderPaymentStateKind::Settled => "settled", + OrderPaymentStateKind::Rejected => "rejected", + OrderPaymentStateKind::Invalid => "invalid", + _ => "unknown", + } +} + +fn sdk_settlement_state(state: OrderSettlementStateKind) -> &'static str { + match state { + OrderSettlementStateKind::NotRequired => "not_required", + OrderSettlementStateKind::Pending => "pending", + OrderSettlementStateKind::Accepted => "accepted", + OrderSettlementStateKind::Rejected => "rejected", + OrderSettlementStateKind::Invalid => "invalid", + _ => "unknown", + } +} + +fn sdk_order_status_issue_view(issue: &SdkOrderStatusIssue) -> OrderIssueView { + let code = issue.code(); + OrderIssueView { + code: code.clone(), + field: "sdk_order_status".to_owned(), + message: format!("SDK order status reported `{code}`"), + event_ids: issue + .event_ids + .iter() + .map(RadrootsEventId::to_string) + .collect(), + } +} + +fn sdk_event_id_string(event_id: Option<&RadrootsEventId>) -> Option<String> { + event_id.map(RadrootsEventId::to_string) +} + enum OrderStatusRecord { Request { listing_event_id: Option<String>, @@ -12739,6 +13021,10 @@ mod tests { use radroots_nostr::prelude::{radroots_event_from_nostr, radroots_nostr_build_event}; use radroots_runtime_paths::RadrootsMigrationReport; use radroots_secret_vault::RadrootsSecretBackend; + use radroots_sdk::{ + OrderPaymentStateKind, OrderSettlementStateKind, OrderStatusKind, OrderStatusReceipt, + SdkOrderStatusIssue, SdkOrderStatusIssueKind, SdkOrderStatusSource, + }; use radroots_trade::order::{ RadrootsListingInventoryAccountingInputs, RadrootsListingInventoryBinAvailability, RadrootsOrderCancellationRecord, RadrootsOrderDecisionRecord, @@ -12780,7 +13066,7 @@ mod tests { order_status_reduction_from_receipt_with_context, order_submit_dry_run_view, order_submit_existing_request_view_from_receipt, order_submit_listing_provenance_preflight_view, proposed_accept_decision_record, - resolve_local_order_fulfillment_signing_identity, + resolve_local_order_fulfillment_signing_identity, sdk_order_status_view, seller_order_request_resolution_from_receipt, }; use crate::cli::global::{ @@ -14552,6 +14838,108 @@ mod tests { } #[test] + fn sdk_order_status_view_reports_found_local_projection() { + let request_event_id = test_event_id_char('1'); + let decision_event_id = test_event_id_char('2'); + let receipt = OrderStatusReceipt { + order_id: test_order_id("ord_AAAAAAAAAAAAAAAAAAAAAg"), + source: SdkOrderStatusSource::LocalEventStore, + found: true, + event_count: 2, + limit_applied: 500, + status: OrderStatusKind::Accepted, + fulfillment_status: None, + payment_state: OrderPaymentStateKind::NotRecorded, + settlement_state: OrderSettlementStateKind::NotRequired, + lifecycle_terminal: false, + event_ids: vec![request_event_id.clone(), decision_event_id.clone()], + request_event_id: Some(request_event_id.clone()), + decision_event_id: Some(decision_event_id.clone()), + fulfillment_event_id: None, + cancellation_event_id: None, + receipt_event_id: None, + last_event_id: Some(decision_event_id.clone()), + issues: Vec::new(), + }; + + let view = sdk_order_status_view(receipt); + + assert_eq!(view.state, "accepted"); + assert_eq!(view.source, "SDK local order projection"); + assert_eq!(view.actor_context_source, "sdk_local_projection"); + assert_eq!( + view.request_event_id.as_deref(), + Some(request_event_id.as_str()) + ); + assert_eq!( + view.decision_event_id.as_deref(), + Some(decision_event_id.as_str()) + ); + assert_eq!( + view.agreement_event_id.as_deref(), + Some(decision_event_id.as_str()) + ); + assert_eq!(view.last_event_id.as_deref(), Some(decision_event_id.as_str())); + assert_eq!(view.fetched_count, 0); + assert_eq!(view.decoded_count, 2); + assert_eq!(view.skipped_count, 0); + assert!(view.target_relays.is_empty()); + assert!(view.connected_relays.is_empty()); + assert!(view.failed_relays.is_empty()); + assert!(view.reducer_issues.is_empty()); + let lifecycle = view.lifecycle.expect("lifecycle"); + assert_eq!(lifecycle.phase, "accepted"); + assert!(!lifecycle.terminal); + assert!(!lifecycle.settlement_required); + } + + #[test] + fn sdk_order_status_view_maps_stable_issue_codes() { + let request_event_id = test_event_id_char('1'); + let fork_event_id = test_event_id_char('3'); + let receipt = OrderStatusReceipt { + order_id: test_order_id("ord_AAAAAAAAAAAAAAAAAAAAAg"), + source: SdkOrderStatusSource::LocalEventStore, + found: true, + event_count: 2, + limit_applied: 500, + status: OrderStatusKind::Invalid, + fulfillment_status: None, + payment_state: OrderPaymentStateKind::NotRecorded, + settlement_state: OrderSettlementStateKind::NotRequired, + lifecycle_terminal: false, + event_ids: vec![request_event_id, fork_event_id.clone()], + request_event_id: None, + decision_event_id: None, + fulfillment_event_id: None, + cancellation_event_id: None, + receipt_event_id: None, + last_event_id: Some(fork_event_id.clone()), + issues: vec![SdkOrderStatusIssue { + kind: SdkOrderStatusIssueKind::MultipleRequests, + event_ids: vec![fork_event_id.clone()], + }], + }; + + let view = sdk_order_status_view(receipt); + + assert_eq!(view.state, "invalid"); + assert_eq!( + view.reason.as_deref(), + Some( + "local SDK order events for `ord_AAAAAAAAAAAAAAAAAAAAAg` failed reducer validation" + ) + ); + assert_eq!(view.reducer_issues.len(), 1); + assert_eq!(view.reducer_issues[0].code, "multiple_requests"); + assert_eq!(view.reducer_issues[0].field, "sdk_order_status"); + assert_eq!( + view.reducer_issues[0].event_ids, + vec![fork_event_id.to_string()] + ); + } + + #[test] fn order_status_from_receipt_reports_requested() { let fixture = order_status_fixture(); let receipt = DirectRelayFetchReceipt { diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -2225,15 +2225,20 @@ fn local_order_failure_envelopes_are_structured_and_actionable() { assert_no_daemon_runtime_reference(&submit, &submit_args); let status_args = ["--format", "json", "order", "status", "get", "ord_missing"]; - let (status_output, status) = sandbox.json_output(&status_args); - assert!(!status_output.status.success()); - assert_eq!(status["errors"][0]["code"], "operation_unavailable"); - assert_eq!(status["errors"][0]["detail"]["state"], "unconfigured"); - assert_eq!(status["errors"][0]["detail"]["order_id"], "ord_missing"); - assert_eq!(status["errors"][0]["detail"]["fetched_count"], 0); - assert_eq!( - status["next_actions"][0]["command"], - "radroots --relay wss://relay.example.com order status get ord_missing" + let status = sandbox.json_success(&status_args); + assert_eq!(status["operation_id"], "order.status.get"); + assert_eq!(status["result"]["state"], "missing"); + assert_eq!(status["result"]["source"], "SDK local order projection"); + assert_eq!( + status["result"]["actor_context_source"], + "sdk_local_projection" + ); + assert_eq!(status["result"]["order_id"], "ord_missing"); + assert_eq!(status["result"]["fetched_count"], 0); + assert_eq!(status["result"]["decoded_count"], 0); + assert_eq!( + status["result"]["reason"], + "no local SDK order events matched `ord_missing`" ); assert_no_daemon_runtime_reference(&status, &status_args); diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -3386,19 +3386,6 @@ fn online_requires_relay_for_external_network_operations() { ["--format", "json", "--online", "order", "event", "list"].as_slice(), ), ( - "order.status.get", - [ - "--format", - "json", - "--online", - "order", - "status", - "get", - "ord_missing", - ] - .as_slice(), - ), - ( "order.cancel", [ "--format", @@ -3519,6 +3506,75 @@ fn online_requires_relay_for_external_network_operations() { } #[test] +fn order_status_get_uses_sdk_local_projection_without_relay_fetch() { + let sandbox = RadrootsCliSandbox::new(); + let local = sandbox.json_success(&[ + "--format", + "json", + "--online", + "order", + "status", + "get", + "ord_missing", + ]); + + assert_eq!(local["operation_id"], "order.status.get"); + assert_eq!(local["result"]["state"], "missing"); + assert_eq!(local["result"]["source"], "SDK local order projection"); + assert_eq!( + local["result"]["actor_context_source"], + "sdk_local_projection" + ); + assert_eq!(local["result"]["fetched_count"], 0); + assert_eq!(local["result"]["decoded_count"], 0); + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind closed relay"); + let closed_relay = format!("ws://{}", listener.local_addr().expect("relay addr")); + drop(listener); + let with_closed_relay = sandbox.json_success(&[ + "--format", + "json", + "--relay", + closed_relay.as_str(), + "order", + "status", + "get", + "ord_missing", + ]); + + assert_eq!(with_closed_relay["operation_id"], "order.status.get"); + assert_eq!(with_closed_relay["result"]["state"], "missing"); + assert_eq!( + with_closed_relay["result"]["source"], + "SDK local order projection" + ); + assert_eq!(with_closed_relay["result"]["fetched_count"], 0); + assert_eq!(with_closed_relay["result"]["decoded_count"], 0); +} + +#[test] +fn order_status_get_invalid_order_id_uses_sdk_error_contract() { + let sandbox = RadrootsCliSandbox::new(); + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "order", + "status", + "get", + "bad order id", + ]); + + assert!(!output.status.success()); + assert_eq!(value["operation_id"], "order.status.get"); + assert_eq!(value["result"], Value::Null); + assert_eq!(value["errors"][0]["code"], "invalid_order_id"); + assert_eq!(value["errors"][0]["exit_code"], 2); + assert_eq!(value["errors"][0]["detail"]["class"], "request"); + assert_eq!(value["errors"][0]["detail"]["retryable"], false); + assert_eq!(value["errors"][0]["detail"]["detail"]["value"], "bad order id"); +} + +#[test] fn radrootsd_sync_push_failure_exposes_nostr_relay_recovery_action() { let sandbox = RadrootsCliSandbox::new(); let (json_output, value) = sandbox.json_output(&[ @@ -6911,26 +6967,26 @@ fn order_status_and_event_list_use_draft_context_after_account_override_drift() .as_str() .expect("drift account id"); - let status_relay = RelayFetchServer::with_events(vec![event.clone()]); let status = sandbox.json_success(&[ "--format", "json", "--account-id", drift_account_id, - "--relay", - status_relay.endpoint(), "order", "status", "get", order_id, ]); - status_relay.join(); assert_eq!(status["operation_id"], "order.status.get"); - assert_eq!(status["result"]["actor_context_source"], "order_draft"); - assert_eq!(status["result"]["state"], "requested"); - assert_eq!(status["result"]["request_event_id"], event.id.to_string()); - assert_eq!(status["result"]["buyer_pubkey"], buyer.public_key_hex()); + assert_eq!(status["result"]["source"], "SDK local order projection"); + assert_eq!( + status["result"]["actor_context_source"], + "sdk_local_projection" + ); + assert_eq!(status["result"]["state"], "missing"); + assert_eq!(status["result"]["fetched_count"], 0); + assert_eq!(status["result"]["decoded_count"], 0); let event_list_relay = RelayFetchServer::with_events(vec![event]); let events = sandbox.json_success(&[