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:
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]