cli

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

commit 2894c333555a208a0c4449d1ea952c1a8bf33c59
parent 1bf34ff31134165995d533e9e4854a18b6417f10
Author: triesap <tyson@radroots.org>
Date:   Wed, 29 Apr 2026 22:29:42 +0000

tests: cover order fulfillment cli

Diffstat:
Msrc/runtime/order.rs | 213++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/signer_runtime_modes.rs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/target_cli.rs | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 394 insertions(+), 1 deletion(-)

diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -5031,8 +5031,10 @@ mod tests { order_history_from_receipt, order_request_filter, order_status_from_receipt, order_status_from_receipt_with_context, order_status_reduction_from_receipt_with_context, order_submit_dry_run_view, order_submit_existing_request_view_from_receipt, - proposed_accept_decision_record, seller_order_request_resolution_from_receipt, + proposed_accept_decision_record, resolve_local_order_fulfillment_signing_identity, + seller_order_request_resolution_from_receipt, }; + use crate::runtime::accounts; use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, @@ -5040,6 +5042,7 @@ mod tests { SignerBackend, SignerConfig, Verbosity, }; use crate::runtime::direct_relay::DirectRelayFetchReceipt; + use crate::runtime::signer::ActorWriteBindingError; use crate::runtime_args::{ OrderDecisionArg, OrderDecisionArgs, OrderFulfillmentArgs, OrderSubmitArgs, }; @@ -6324,6 +6327,203 @@ mod tests { } #[test] + fn order_fulfillment_preflight_rejects_missing_order() { + 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 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::new(), + }, + ); + let args = fulfillment_args_for_fixture(&fixture, "ready_for_pickup"); + + let view = + order_fulfillment_preflight_view_from_status(&config, &args, &status_view, None, None) + .expect("missing fulfillment preflight"); + + assert_eq!(view.state, "missing"); + assert_eq!(view.event_id, None); + assert!( + view.reason + .as_deref() + .expect("reason") + .contains("no active order events") + ); + assert_eq!( + view.actions, + vec![format!("radroots order status get {}", fixture.order_id)] + ); + } + + #[test] + fn order_fulfillment_preflight_rejects_requested_order() { + 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 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 = fulfillment_args_for_fixture(&fixture, "ready_for_pickup"); + + let view = + order_fulfillment_preflight_view_from_status(&config, &args, &status_view, None, None) + .expect("requested fulfillment preflight"); + + assert_eq!(view.state, "requested"); + let request_event_id = fixture.request_event.id.to_string(); + assert_eq!( + view.request_event_id.as_deref(), + Some(request_event_id.as_str()) + ); + assert!(view.event_id.is_none()); + assert!( + view.reason + .as_deref() + .expect("reason") + .contains("has no accepted seller decision") + ); + } + + #[test] + fn order_fulfillment_preflight_rejects_declined_order() { + 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 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::Declined { + reason: "out of stock".to_owned(), + }, + ); + 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.clone()], + }, + ); + let args = fulfillment_args_for_fixture(&fixture, "ready_for_pickup"); + + let view = + order_fulfillment_preflight_view_from_status(&config, &args, &status_view, None, None) + .expect("declined fulfillment preflight"); + + assert_eq!(view.state, "declined"); + let decision_event_id = decision_event.id.to_string(); + assert_eq!( + view.decision_event_id.as_deref(), + Some(decision_event_id.as_str()) + ); + assert!(view.event_id.is_none()); + assert!( + view.reason + .as_deref() + .expect("reason") + .contains("was declined") + ); + } + + #[test] + fn order_fulfillment_preflight_rejects_invalid_order_state() { + 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 accepted_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 declined_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::Declined { + reason: "out of stock".to_owned(), + }, + ); + 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(), + accepted_event, + declined_event, + ], + }, + ); + let args = fulfillment_args_for_fixture(&fixture, "ready_for_pickup"); + + let view = + order_fulfillment_preflight_view_from_status(&config, &args, &status_view, None, None) + .expect("invalid fulfillment preflight"); + + assert_eq!(view.state, "invalid"); + assert!(view.event_id.is_none()); + assert_eq!(view.issues.len(), 1); + assert_eq!(view.issues[0].code, "conflicting_decisions"); + assert!( + view.reason + .as_deref() + .expect("reason") + .contains("failed reducer validation") + ); + } + + #[test] + fn order_fulfillment_signing_rejects_selected_non_seller_account() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + accounts::create_or_migrate_default_account(&config).expect("create selected account"); + let fixture = order_status_fixture(); + + let error = resolve_local_order_fulfillment_signing_identity( + &config, + fixture.seller_pubkey.as_str(), + ) + .expect_err("non seller account rejected"); + + let ActorWriteBindingError::Unconfigured(reason) = error; + assert!(reason.contains("cannot sign order seller_pubkey")); + } + + #[test] fn order_status_from_receipt_rejects_wrong_decision_counterparty() { let fixture = order_status_fixture(); let wrong_buyer = RadrootsIdentity::generate(); @@ -7018,6 +7218,17 @@ mod tests { .expect("seller order request resolution") } + fn fulfillment_args_for_fixture( + fixture: &OrderStatusFixture, + state: &str, + ) -> OrderFulfillmentArgs { + OrderFulfillmentArgs { + key: fixture.order_id.clone(), + state: state.to_owned(), + idempotency_key: None, + } + } + fn sample_config(root: &Path) -> RuntimeConfig { let data = root.join("data"); let logs = root.join("logs"); diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -800,6 +800,102 @@ fn local_order_event_list_attempts_configured_direct_relay() { } #[test] +fn local_order_fulfillment_update_requires_configured_relay_before_publish_preflight() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--approval-token", + "approve", + "order", + "fulfillment", + "update", + "ord_missing_relay", + "--state", + "ready_for_pickup", + ]); + + assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(3)); + assert_eq!(value["operation_id"], "order.fulfillment.update"); + assert_eq!(value["result"], serde_json::Value::Null); + assert_eq!(value["errors"][0]["code"], "operation_unavailable"); + assert_eq!(value["errors"][0]["detail"]["class"], "operation"); + assert_contains( + &value["errors"][0]["message"], + "requires at least one configured relay", + ); + assert_no_removed_command_reference(&value, &["order", "fulfillment", "update"]); + assert_no_daemon_runtime_reference(&value, &["order", "fulfillment", "update"]); +} + +#[test] +fn local_order_fulfillment_update_requires_selected_seller_account_before_relay_fetch() { + let sandbox = RadrootsCliSandbox::new(); + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--relay", + "ws://127.0.0.1:9", + "--approval-token", + "approve", + "order", + "fulfillment", + "update", + "ord_no_account", + "--state", + "ready_for_pickup", + ]); + + assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(3)); + assert_eq!(value["operation_id"], "order.fulfillment.update"); + assert_eq!(value["result"], serde_json::Value::Null); + assert_eq!(value["errors"][0]["code"], "operation_unavailable"); + assert_eq!(value["errors"][0]["detail"]["class"], "operation"); + assert_contains( + &value["errors"][0]["message"], + "requires a selected seller account", + ); + assert_no_removed_command_reference(&value, &["order", "fulfillment", "update"]); + assert_no_daemon_runtime_reference(&value, &["order", "fulfillment", "update"]); +} + +#[test] +fn local_order_fulfillment_update_dry_run_attempts_configured_direct_relay() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + let relay = "ws://127.0.0.1:9"; + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--dry-run", + "--relay", + relay, + "order", + "fulfillment", + "update", + "ord_direct_fulfillment", + "--state", + "ready_for_pickup", + ]); + + assert!(!output.status.success()); + assert_eq!(value["dry_run"], true); + assert_direct_relay_connection_failure( + &value, + "order.fulfillment.update", + &["order", "fulfillment", "update"], + ); + assert_eq!(value["errors"][0]["detail"]["state"], "unavailable"); + assert_eq!(value["errors"][0]["detail"]["target_relays"][0], relay); +} + +#[test] fn watch_only_farm_publish_dry_run_fails_as_account_watch_only() { let sandbox = RadrootsCliSandbox::new(); let public_identity = identity_public(13); diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -476,6 +476,21 @@ fn offline_forbids_external_network_operations() { "order.submit", ["--format", "json", "--offline", "order", "submit"].as_slice(), ), + ( + "order.fulfillment.update", + [ + "--format", + "json", + "--offline", + "order", + "fulfillment", + "update", + "ord_offline_fulfillment", + "--state", + "ready_for_pickup", + ] + .as_slice(), + ), ] { let output = radroots() .args(args) @@ -544,6 +559,22 @@ fn offline_rejects_order_decision_dry_run() { ] .as_slice(), ), + ( + "order.fulfillment.update", + [ + "--format", + "json", + "--offline", + "--dry-run", + "order", + "fulfillment", + "update", + "ord_offline_decision", + "--state", + "ready_for_pickup", + ] + .as_slice(), + ), ] { let output = radroots() .args(args) @@ -643,6 +674,21 @@ fn online_requires_relay_for_external_network_operations() { ] .as_slice(), ), + ( + "order.fulfillment.update", + [ + "--format", + "json", + "--online", + "order", + "fulfillment", + "update", + "ord_missing", + "--state", + "ready_for_pickup", + ] + .as_slice(), + ), ] { let output = radroots() .args(args) @@ -1099,6 +1145,18 @@ fn required_approval_token_rejects_absent_empty_and_whitespace_values() { "order.decline", &["order", "decline", "--reason", "out_of_stock"], ); + assert_required_approval_token_rejected( + &sandbox, + "order.fulfillment.update", + &[ + "order", + "fulfillment", + "update", + "ord_pending_fulfillment", + "--state", + "ready_for_pickup", + ], + ); } fn assert_required_approval_token_rejected( @@ -1125,6 +1183,34 @@ fn assert_required_approval_token_rejected( } #[test] +fn order_fulfillment_update_requires_state_before_approval() { + let sandbox = RadrootsCliSandbox::new(); + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "order", + "fulfillment", + "update", + "ord_missing_state", + ]); + + assert_eq!(output.status.code(), Some(2)); + assert_eq!(value["operation_id"], "order.fulfillment.update"); + assert_eq!(value["result"], Value::Null); + assert_eq!(value["errors"][0]["code"], "invalid_input"); + assert_eq!(value["errors"][0]["exit_code"], 2); + assert!( + value["errors"][0]["message"] + .as_str() + .expect("message") + .contains("state") + ); + assert_no_removed_command_reference(&value, &["order", "fulfillment", "update"]); + assert_no_daemon_runtime_reference(&value, &["order", "fulfillment", "update"]); +} + +#[test] fn order_submit_missing_order_returns_not_found_while_read_view_stays_successful() { let sandbox = RadrootsCliSandbox::new();