cli

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

commit 9070dd4cacfa59690e164c56906d038a4b74eb06
parent 15ae628874eb77b7a7086fbea8577b782e235016
Author: triesap <tyson@radroots.org>
Date:   Tue, 28 Apr 2026 18:50:20 +0000

test: cover order decision preflight

Diffstat:
Msrc/operation_order.rs | 60+++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/runtime/order.rs | 252++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/target_cli.rs | 47+++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 355 insertions(+), 4 deletions(-)

diff --git a/src/operation_order.rs b/src/operation_order.rs @@ -474,11 +474,12 @@ mod tests { use serde_json::{Map, Value}; use tempfile::tempdir; - use super::OrderOperationService; + use super::{OrderOperationService, decision_result}; + use crate::domain::runtime::OrderDecisionView; use crate::operation_adapter::{ OperationAdapter, OperationContext, OperationData, OperationRequest, OrderAcceptRequest, - OrderDeclineRequest, OrderEventListRequest, OrderEventWatchRequest, OrderGetRequest, - OrderListRequest, OrderStatusGetRequest, OrderSubmitRequest, + OrderAcceptResult, OrderDeclineRequest, OrderEventListRequest, OrderEventWatchRequest, + OrderGetRequest, OrderListRequest, OrderStatusGetRequest, OrderSubmitRequest, }; use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, @@ -573,6 +574,25 @@ mod tests { } #[test] + fn order_decision_already_decided_maps_to_validation_failure() { + let view = already_decided_view(); + let error = match decision_result::<OrderAcceptResult>("order.accept", &view) { + Ok(_) => panic!("already decided view should fail validation"), + Err(error) => error, + }; + let output_error = error.to_output_error(); + + assert_eq!(output_error.code, "validation_failed"); + assert_eq!(output_error.exit_code, 10); + let detail = output_error.detail.expect("validation detail"); + assert_eq!(detail["state"], "already_decided"); + assert_eq!(detail["operation_id"], "order.accept"); + assert_eq!(detail["event_id"], "d".repeat(64)); + assert_eq!(detail["event_kind"], 3423); + assert_eq!(detail["actions"][0], "radroots order status get ord_test"); + } + + #[test] fn order_decline_requires_reason_before_approval() { let dir = tempdir().expect("tempdir"); let config = sample_config(dir.path()); @@ -763,4 +783,38 @@ mod tests { .map(|(key, value)| ((*key).to_owned(), Value::String((*value).to_owned()))) .collect::<Map<String, Value>>() } + + fn already_decided_view() -> OrderDecisionView { + OrderDecisionView { + state: "already_decided".to_owned(), + source: "test".to_owned(), + order_id: "ord_test".to_owned(), + listing_addr: Some("30402:seller:listing".to_owned()), + buyer_pubkey: Some("b".repeat(64)), + seller_pubkey: Some("s".repeat(64)), + decision: "accepted".to_owned(), + request_event_id: Some("r".repeat(64)), + listing_event_id: Some("l".repeat(64)), + root_event_id: Some("r".repeat(64)), + prev_event_id: Some("r".repeat(64)), + event_id: Some("d".repeat(64)), + event_kind: Some(3423), + dry_run: false, + target_relays: vec!["ws://relay.test".to_owned()], + connected_relays: vec!["ws://relay.test".to_owned()], + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), + fetched_count: 2, + decoded_count: 2, + skipped_count: 0, + idempotency_key: None, + signer_mode: Some("local".to_owned()), + reason: Some( + "order accept refused because order `ord_test` already has a visible `accepted` seller decision" + .to_owned(), + ), + issues: Vec::new(), + actions: vec!["radroots order status get ord_test".to_owned()], + } + } } diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -2524,6 +2524,8 @@ impl From<OrderGetView> for OrderNewView { #[cfg(test)] mod tests { + use std::path::{Path, PathBuf}; + use radroots_events::RadrootsNostrEventPtr; use radroots_events::kinds::KIND_TRADE_ORDER_DECISION; use radroots_events::trade::{ @@ -2537,16 +2539,27 @@ mod tests { }; use radroots_identity::RadrootsIdentity; use radroots_nostr::prelude::{radroots_event_from_nostr, radroots_nostr_build_event}; + use radroots_runtime_paths::RadrootsMigrationReport; + use radroots_secret_vault::RadrootsSecretBackend; use radroots_trade::order::canonicalize_active_order_decision_for_signer; + use tempfile::tempdir; use super::{ ORDER_DRAFT_KIND, OrderDraft, OrderDraftDocument, OrderDraftItem, - accepted_order_decision_payload_from_request, collect_issues, + SellerOrderRequestResolution, accepted_order_decision_payload_from_request, collect_issues, declined_order_decision_payload_from_request, inspect_document, next_order_id, + order_decision_dry_run_view, order_decision_preflight_view_from_status, order_history_entry_from_event, order_history_from_receipt, order_request_filter, order_status_from_receipt, seller_order_request_resolution_from_receipt, }; + use crate::runtime::config::{ + AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, + LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, + PathsConfig, RelayConfig, RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, + SignerBackend, SignerConfig, Verbosity, + }; use crate::runtime::direct_relay::DirectRelayFetchReceipt; + use crate::runtime_args::{OrderDecisionArg, OrderDecisionArgs}; #[test] fn generated_order_id_uses_stable_prefix() { @@ -3057,6 +3070,67 @@ mod tests { } #[test] + fn order_decision_dry_run_view_preserves_ready_preflight_without_publish_fields() { + let dir = tempdir().expect("tempdir"); + let mut config = sample_config(dir.path()); + config.output.dry_run = true; + config.relay.urls = vec!["ws://relay.test".to_owned()]; + let fixture = order_status_fixture(); + let resolution = request_resolution_for_fixture(&fixture); + let request = resolution.requests[0].clone(); + let status_view = order_status_from_receipt( + fixture.order_id.as_str(), + DirectRelayFetchReceipt { + target_relays: vec!["ws://relay.test".to_owned()], + connected_relays: vec!["ws://relay.test".to_owned()], + failed_relays: Vec::new(), + events: vec![fixture.request_event.clone()], + }, + ); + let args = OrderDecisionArgs { + key: fixture.order_id.clone(), + decision: OrderDecisionArg::Accept, + reason: None, + idempotency_key: Some("idem_dry_run".to_owned()), + }; + + let view = order_decision_dry_run_view(&config, &args, &request, &status_view); + + assert_eq!(view.state, "dry_run"); + assert_eq!(view.dry_run, true); + assert_eq!(view.order_id, fixture.order_id); + assert_eq!( + view.request_event_id.as_deref(), + Some(request.request_event_id.as_str()) + ); + assert_eq!( + view.root_event_id.as_deref(), + Some(request.request_event_id.as_str()) + ); + assert_eq!( + view.prev_event_id.as_deref(), + Some(request.request_event_id.as_str()) + ); + assert_eq!( + view.listing_addr.as_deref(), + Some(fixture.listing_addr.as_str()) + ); + assert_eq!(view.event_id, None); + assert_eq!(view.event_kind, None); + assert!(view.acknowledged_relays.is_empty()); + assert_eq!(view.target_relays, vec!["ws://relay.test"]); + assert_eq!(view.connected_relays, vec!["ws://relay.test"]); + assert_eq!(view.fetched_count, 1); + assert_eq!(view.decoded_count, 1); + assert_eq!(view.skipped_count, 0); + assert_eq!(view.idempotency_key.as_deref(), Some("idem_dry_run")); + assert_eq!( + view.actions, + vec![format!("radroots order status get {}", fixture.order_id)] + ); + } + + #[test] fn order_status_from_receipt_reports_accepted() { let fixture = order_status_fixture(); let decision_event = signed_order_decision_event( @@ -3097,6 +3171,73 @@ mod tests { } #[test] + fn order_decision_preflight_rejects_existing_decision() { + let dir = tempdir().expect("tempdir"); + let mut config = sample_config(dir.path()); + config.relay.urls = vec!["ws://relay.test".to_owned()]; + let fixture = order_status_fixture(); + let resolution = request_resolution_for_fixture(&fixture); + let request = resolution.requests[0].clone(); + let decision_event = signed_order_decision_event( + &fixture.seller, + &fixture.request_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + RadrootsTradeOrderDecision::Accepted { + inventory_commitments: vec![RadrootsTradeInventoryCommitment { + bin_id: "bin-1".to_owned(), + bin_count: 2, + }], + }, + ); + let decision_event_id = decision_event.id.to_string(); + let status_view = order_status_from_receipt( + fixture.order_id.as_str(), + DirectRelayFetchReceipt { + target_relays: vec!["ws://relay.test".to_owned()], + connected_relays: vec!["ws://relay.test".to_owned()], + failed_relays: Vec::new(), + events: vec![fixture.request_event.clone(), decision_event], + }, + ); + let args = OrderDecisionArgs { + key: fixture.order_id.clone(), + decision: OrderDecisionArg::Decline, + reason: Some("out of stock".to_owned()), + idempotency_key: None, + }; + + let view = order_decision_preflight_view_from_status( + &config, + &args, + &request, + &resolution, + &status_view, + ) + .expect("existing decision preflight view"); + + assert_eq!(view.state, "already_decided"); + assert_eq!(view.event_id.as_deref(), Some(decision_event_id.as_str())); + assert_eq!(view.event_kind, Some(KIND_TRADE_ORDER_DECISION)); + assert_eq!( + view.request_event_id.as_deref(), + Some(request.request_event_id.as_str()) + ); + assert_eq!(view.target_relays, vec!["ws://relay.test"]); + assert_eq!(view.connected_relays, vec!["ws://relay.test"]); + assert_eq!(view.fetched_count, 2); + assert_eq!(view.decoded_count, 2); + assert!( + view.reason + .as_deref() + .expect("reason") + .contains("already has a visible `accepted` seller decision") + ); + } + + #[test] fn order_status_from_receipt_reports_declined() { let fixture = order_status_fixture(); let decision_event = signed_order_decision_event( @@ -3287,6 +3428,115 @@ mod tests { } } + fn request_resolution_for_fixture( + fixture: &OrderStatusFixture, + ) -> SellerOrderRequestResolution { + seller_order_request_resolution_from_receipt( + fixture.seller_pubkey.as_str(), + fixture.order_id.as_str(), + DirectRelayFetchReceipt { + target_relays: vec!["ws://relay.test".to_owned()], + connected_relays: vec!["ws://relay.test".to_owned()], + failed_relays: Vec::new(), + events: vec![fixture.request_event.clone()], + }, + ) + .expect("seller order request resolution") + } + + fn sample_config(root: &Path) -> RuntimeConfig { + let data = root.join("data"); + let logs = root.join("logs"); + let secrets = root.join("secrets"); + RuntimeConfig { + output: OutputConfig { + format: OutputFormat::Human, + verbosity: Verbosity::Normal, + color: true, + dry_run: false, + }, + interaction: InteractionConfig { + input_enabled: true, + assume_yes: false, + stdin_tty: false, + stdout_tty: false, + prompts_allowed: false, + confirmations_allowed: false, + }, + paths: PathsConfig { + profile: "interactive_user".into(), + profile_source: "test".into(), + allowed_profiles: vec!["interactive_user".into(), "repo_local".into()], + root_source: "test".into(), + repo_local_root: None, + repo_local_root_source: None, + subordinate_path_override_source: "runtime_config".into(), + app_namespace: "apps/cli".into(), + shared_accounts_namespace: "shared/accounts".into(), + shared_identities_namespace: "shared/identities".into(), + app_config_path: root.join("config/apps/cli/config.toml"), + workspace_config_path: None, + app_data_root: data.join("apps/cli"), + app_logs_root: logs.join("apps/cli"), + shared_accounts_data_root: data.join("shared/accounts"), + shared_accounts_secrets_root: secrets.join("shared/accounts"), + default_identity_path: secrets.join("shared/identities/default.json"), + }, + migration: MigrationConfig { + report: RadrootsMigrationReport::empty(), + }, + logging: LoggingConfig { + filter: "info".into(), + directory: None, + stdout: false, + }, + account: AccountConfig { + selector: None, + store_path: data.join("shared/accounts/store.json"), + secrets_dir: secrets.join("shared/accounts"), + secret_backend: RadrootsSecretBackend::EncryptedFile, + secret_fallback: None, + }, + account_secret_contract: AccountSecretContractConfig { + default_backend: "host_vault".into(), + default_fallback: Some("encrypted_file".into()), + allowed_backends: vec!["host_vault".into(), "encrypted_file".into()], + host_vault_policy: Some("desktop".into()), + uses_protected_store: true, + }, + identity: IdentityConfig { + path: secrets.join("shared/identities/default.json"), + }, + signer: SignerConfig { + backend: SignerBackend::Local, + }, + relay: RelayConfig { + urls: Vec::new(), + publish_policy: RelayPublishPolicy::Any, + source: RelayConfigSource::Defaults, + }, + local: LocalConfig { + root: data.join("apps/cli/replica"), + replica_db_path: data.join("apps/cli/replica/replica.sqlite"), + backups_dir: data.join("apps/cli/replica/backups"), + exports_dir: data.join("apps/cli/replica/exports"), + }, + myc: MycConfig { + executable: PathBuf::from("myc"), + status_timeout_ms: 2_000, + }, + hyf: HyfConfig { + enabled: false, + executable: PathBuf::from("hyfd"), + }, + rpc: RpcConfig { + url: "http://127.0.0.1:7070".into(), + bridge_bearer_token: None, + }, + capability_bindings: Vec::new(), + } + } + fn signed_order_decision_event( seller: &RadrootsIdentity, request_event: &radroots_nostr::prelude::RadrootsNostrEvent, diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -499,6 +499,53 @@ fn offline_allows_supported_external_dry_run() { } #[test] +fn offline_rejects_order_decision_dry_run() { + for (operation_id, args) in [ + ( + "order.accept", + [ + "--format", + "json", + "--offline", + "--dry-run", + "order", + "accept", + "ord_offline_decision", + ] + .as_slice(), + ), + ( + "order.decline", + [ + "--format", + "json", + "--offline", + "--dry-run", + "order", + "decline", + "ord_offline_decision", + "--reason", + "unavailable", + ] + .as_slice(), + ), + ] { + let output = radroots() + .args(args) + .output() + .expect("run offline order decision dry-run"); + let value: Value = serde_json::from_slice(&output.stdout).expect("json envelope"); + + assert_eq!(output.status.code(), Some(8)); + assert_eq!(value["operation_id"], operation_id); + assert_eq!(value["dry_run"], true); + assert_eq!(value["result"], Value::Null); + assert_eq!(value["errors"][0]["code"], "offline_forbidden"); + assert_eq!(value["errors"][0]["exit_code"], 8); + } +} + +#[test] fn listing_publish_dry_run_validates_missing_file() { let sandbox = RadrootsCliSandbox::new(); let missing = sandbox.root().join("missing-listing.toml");