cli

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

commit 0b275639a022405ee3a45103ae27701a18259c68
parent 947114ee0542e77ebb07941f5dd1ae03db20146b
Author: triesap <tyson@radroots.org>
Date:   Tue, 26 May 2026 08:09:57 +0000

market: require primary bin readiness

- include primary-bin identity in checkout readiness
- disable basket actions when listing bin identity is missing
- cover missing and restored bin readiness in market commands
- keep order submit as the closed final guardrail

Diffstat:
Msrc/domain/runtime.rs | 46+++++++++++++++++++++++++++++++++++++++++++++-
Msrc/runtime/find.rs | 4+++-
Msrc/runtime/listing.rs | 4+++-
Mtests/support/mod.rs | 16++++++++++++++++
Mtests/target_cli.rs | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
5 files changed, 139 insertions(+), 5 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1074,6 +1074,7 @@ impl MarketReadinessView { pub fn from_market_projection( listing_addr: Option<&str>, + primary_bin_id: Option<&str>, title: Option<&str>, category: Option<&str>, available_amount: Option<i64>, @@ -1089,12 +1090,15 @@ impl MarketReadinessView { && title.is_some_and(|title| !title.trim().is_empty()) && category.is_some_and(|category| !category.trim().is_empty()); let inventory_available = available_amount.is_some_and(|amount| amount > 0); + let primary_bin_available = + primary_bin_id.is_some_and(|primary_bin_id| !primary_bin_id.trim().is_empty()); let price_available = price_amount.is_finite() && price_amount > 0.0 && !price_currency.trim().is_empty() && price_per_amount.is_finite() && price_per_amount > 0.0; - let checkout_enabled = marketplace_eligible && inventory_available && price_available; + let checkout_enabled = + marketplace_eligible && inventory_available && primary_bin_available && price_available; let mut reason_codes = Vec::new(); if !protocol_valid { reason_codes.push("listing_protocol_invalid".to_owned()); @@ -1107,6 +1111,9 @@ impl MarketReadinessView { if !inventory_available { reason_codes.push("listing_inventory_unavailable".to_owned()); } + if !primary_bin_available { + reason_codes.push("listing_primary_bin_missing".to_owned()); + } if !price_available { reason_codes.push("listing_price_unavailable".to_owned()); } @@ -1130,6 +1137,7 @@ mod market_readiness_tests { fn market_readiness_separates_protocol_marketplace_and_checkout_state() { let enabled = MarketReadinessView::from_market_projection( Some(LISTING_ADDR), + Some("bin-1"), Some("Eggs"), Some("eggs"), Some(1), @@ -1144,6 +1152,7 @@ mod market_readiness_tests { let invalid = MarketReadinessView::from_market_projection( None, + Some("bin-1"), Some("Eggs"), Some("eggs"), Some(1), @@ -1158,6 +1167,7 @@ mod market_readiness_tests { let ineligible = MarketReadinessView::from_market_projection( Some(LISTING_ADDR), + Some("bin-1"), Some(" "), Some("eggs"), Some(1), @@ -1175,6 +1185,7 @@ mod market_readiness_tests { let checkout_disabled = MarketReadinessView::from_market_projection( Some(LISTING_ADDR), + Some("bin-1"), Some("Eggs"), Some("eggs"), Some(0), @@ -1189,6 +1200,39 @@ mod market_readiness_tests { checkout_disabled.reason_codes, vec!["listing_checkout_disabled", "listing_inventory_unavailable"] ); + + let primary_bin_missing = MarketReadinessView::from_market_projection( + Some(LISTING_ADDR), + None, + Some("Eggs"), + Some("eggs"), + Some(1), + 6.0, + "USD", + 1.0, + ); + assert!(primary_bin_missing.protocol_valid); + assert!(primary_bin_missing.marketplace_eligible); + assert!(!primary_bin_missing.checkout_enabled); + assert_eq!( + primary_bin_missing.reason_codes, + vec!["listing_checkout_disabled", "listing_primary_bin_missing"] + ); + + let primary_bin_blank = MarketReadinessView::from_market_projection( + Some(LISTING_ADDR), + Some(" "), + Some("Eggs"), + Some("eggs"), + Some(1), + 6.0, + "USD", + 1.0, + ); + assert_eq!( + primary_bin_blank.reason_codes, + vec!["listing_checkout_disabled", "listing_primary_bin_missing"] + ); } } diff --git a/src/runtime/find.rs b/src/runtime/find.rs @@ -81,12 +81,14 @@ pub fn search(config: &RuntimeConfig, args: &FindQueryArgs) -> Result<FindView, .into_iter() .map(|row| { let listing_addr = row.listing_addr.and_then(non_empty); + let primary_bin_id = row.primary_bin_id.and_then(non_empty); let available_amount = row.qty_avail; let price_amount = row.price_amt; let price_currency = row.price_currency; let price_per_amount = row.price_qty_amt; let readiness = MarketReadinessView::from_market_projection( listing_addr.as_deref(), + primary_bin_id.as_deref(), Some(row.title.as_str()), Some(row.category.as_str()), available_amount, @@ -99,7 +101,7 @@ pub fn search(config: &RuntimeConfig, args: &FindQueryArgs) -> Result<FindView, product_key: row.key, readiness, listing_addr, - primary_bin_id: row.primary_bin_id.and_then(non_empty), + primary_bin_id, title: row.title, category: row.category, summary: non_empty(row.summary), diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -1655,12 +1655,14 @@ pub fn get( }; let listing_addr = row.listing_addr.and_then(non_empty); + let primary_bin_id = row.primary_bin_id.and_then(non_empty); let available_amount = row.qty_avail; let price_amount = row.price_amt; let price_currency = row.price_currency; let price_per_amount = row.price_qty_amt; let readiness = MarketReadinessView::from_market_projection( listing_addr.as_deref(), + primary_bin_id.as_deref(), Some(row.title.as_str()), Some(row.category.as_str()), available_amount, @@ -1677,7 +1679,7 @@ pub fn get( listing_id: Some(row.id), product_key: Some(row.key), listing_addr, - primary_bin_id: row.primary_bin_id.and_then(non_empty), + primary_bin_id, title: Some(row.title), category: Some(row.category), description: non_empty(row.summary), diff --git a/tests/support/mod.rs b/tests/support/mod.rs @@ -375,6 +375,22 @@ pub fn update_orderable_listing_available_amount( .expect("update listing available amount"); } +pub fn update_orderable_listing_primary_bin_id( + sandbox: &RadrootsCliSandbox, + listing_addr: &str, + primary_bin_id: Option<&str>, +) { + let executor = SqliteExecutor::open(sandbox.replica_db_path()).expect("open replica db"); + let params = serde_json::to_string(&serde_json::json!([primary_bin_id, listing_addr])) + .expect("update listing primary bin params"); + executor + .exec( + "UPDATE trade_product SET primary_bin_id = ? WHERE listing_addr = ?;", + params.as_str(), + ) + .expect("update listing primary bin"); +} + pub fn replace_latest_listing_event_id( sandbox: &RadrootsCliSandbox, listing_addr: &str, diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -35,8 +35,8 @@ use support::{ identity_public, identity_secret, json_from_stdout, make_listing_publishable, make_listing_publishable_with_seller, ndjson_from_stdout, radroots, remove_orderable_listing, replace_latest_listing_event_id, seed_orderable_listing, toml_string, - update_orderable_listing_available_amount, write_public_identity_profile, - write_secret_identity_profile, + update_orderable_listing_available_amount, update_orderable_listing_primary_bin_id, + write_public_identity_profile, write_secret_identity_profile, }; const LISTING_ADDR: &str = @@ -5707,6 +5707,76 @@ fn market_checkout_readiness_gates_buyer_intent_actions() { .and_then(Value::as_array) .is_none_or(Vec::is_empty) ); + + update_orderable_listing_available_amount(&sandbox, LISTING_ADDR, 5); + update_orderable_listing_primary_bin_id(&sandbox, LISTING_ADDR, None); + + let no_bin_search = + sandbox.json_success(&["--format", "json", "market", "product", "search", "eggs"]); + let no_bin_result = &no_bin_search["result"]["results"][0]; + assert_eq!(no_bin_result["primary_bin_id"], Value::Null); + assert_eq!(no_bin_result["checkout_enabled"], false); + assert_eq!( + no_bin_result["reason_codes"][0], + "listing_checkout_disabled" + ); + assert_eq!( + no_bin_result["reason_codes"][1], + "listing_primary_bin_missing" + ); + assert!( + no_bin_search["result"]["actions"] + .as_array() + .expect("no-bin search actions") + .iter() + .all(|action| action != "radroots basket create") + ); + + let no_bin_listing = sandbox.json_success(&[ + "--format", + "json", + "market", + "listing", + "get", + "pasture-eggs", + ]); + assert_eq!(no_bin_listing["result"]["primary_bin_id"], Value::Null); + assert_eq!(no_bin_listing["result"]["checkout_enabled"], false); + assert_eq!( + no_bin_listing["result"]["reason_codes"][1], + "listing_primary_bin_missing" + ); + assert!( + no_bin_listing["result"] + .get("actions") + .and_then(Value::as_array) + .is_none_or(Vec::is_empty) + ); + + update_orderable_listing_primary_bin_id(&sandbox, LISTING_ADDR, Some("bin-1")); + + let restored_listing = sandbox.json_success(&[ + "--format", + "json", + "market", + "listing", + "get", + "pasture-eggs", + ]); + assert_eq!(restored_listing["result"]["primary_bin_id"], "bin-1"); + assert_eq!(restored_listing["result"]["checkout_enabled"], true); + assert!( + restored_listing["result"] + .get("reason_codes") + .is_none_or(Value::is_null) + ); + assert!( + restored_listing["result"]["actions"] + .as_array() + .expect("restored listing actions") + .iter() + .any(|action| action == "radroots basket create") + ); } #[test]