commit 78412ac9835332fe05f08638391aa618ce96a815
parent 4ec2fbfd6b8e6e59bb7c39cbfe95423733ad2310
Author: triesap <tyson@radroots.org>
Date: Tue, 26 May 2026 10:27:23 +0000
cli: enforce verified primary bin gates
- require protocol-valid readiness for verified primary bins
- make basket readiness check replica primary-bin integrity
- block stale order submit on invalid verified bin state
- cover invalid basket quote and stale submit paths
Diffstat:
4 files changed, 412 insertions(+), 51 deletions(-)
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -1095,7 +1095,8 @@ impl MarketReadinessView {
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 primary_bin_verified = primary_bin_available
+ let primary_bin_verified = protocol_valid
+ && primary_bin_available
&& primary_bin_id.is_some_and(|primary_bin_id| {
verified_primary_bin_id.is_some_and(|verified_primary_bin_id| {
verified_primary_bin_id.trim() == primary_bin_id.trim()
@@ -1178,7 +1179,7 @@ mod market_readiness_tests {
assert!(!invalid.protocol_valid);
assert!(!invalid.marketplace_eligible);
assert!(!invalid.checkout_enabled);
- assert!(invalid.primary_bin_verified);
+ assert!(!invalid.primary_bin_verified);
assert_eq!(invalid.reason_codes, vec!["listing_protocol_invalid"]);
let ineligible = MarketReadinessView::from_market_projection(
diff --git a/src/operation_basket.rs b/src/operation_basket.rs
@@ -4,6 +4,9 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use radroots_events::trade::RadrootsTradeOrderEconomics;
+use radroots_replica_db::{ReplicaSql, trade_product};
+use radroots_replica_db_schema::trade_product::{ITradeProductFieldsFilter, ITradeProductFindMany};
+use radroots_sql_core::SqliteExecutor;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -89,6 +92,7 @@ struct BasketQuote {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct BasketIssue {
+ code: String,
field: String,
message: String,
}
@@ -99,6 +103,12 @@ struct LoadedBasket {
document: BasketDocument,
}
+#[derive(Debug, Clone)]
+struct BasketProductBinState {
+ primary_bin_id: Option<String>,
+ verified_primary_bin_id: Option<String>,
+}
+
pub struct BasketOperationService<'a> {
config: &'a RuntimeConfig,
}
@@ -150,7 +160,12 @@ impl OperationService<BasketCreateRequest> for BasketOperationService<'_> {
quote: None,
};
save_basket(file.as_path(), &document)?;
- json_operation_result::<BasketCreateResult>(basket_view(&document, file.as_path(), None))
+ json_operation_result::<BasketCreateResult>(basket_view(
+ self.config,
+ &document,
+ file.as_path(),
+ None,
+ )?)
}
}
@@ -169,10 +184,11 @@ impl OperationService<BasketGetRequest> for BasketOperationService<'_> {
));
};
json_operation_result::<BasketGetResult>(basket_view(
+ self.config,
&loaded.document,
loaded.file.as_path(),
None,
- ))
+ )?)
}
}
@@ -224,10 +240,11 @@ impl OperationService<BasketItemAddRequest> for BasketOperationService<'_> {
loaded.document.quote = None;
save_basket(loaded.file.as_path(), &loaded.document)?;
json_operation_result::<BasketItemAddResult>(basket_view(
+ self.config,
&loaded.document,
loaded.file.as_path(),
Some("updated"),
- ))
+ )?)
}
}
@@ -272,10 +289,11 @@ impl OperationService<BasketItemUpdateRequest> for BasketOperationService<'_> {
loaded.document.quote = None;
save_basket(loaded.file.as_path(), &loaded.document)?;
json_operation_result::<BasketItemUpdateResult>(basket_view(
+ self.config,
&loaded.document,
loaded.file.as_path(),
Some("updated"),
- ))
+ )?)
}
}
@@ -318,10 +336,11 @@ impl OperationService<BasketItemRemoveRequest> for BasketOperationService<'_> {
loaded.document.quote = None;
save_basket(loaded.file.as_path(), &loaded.document)?;
json_operation_result::<BasketItemRemoveResult>(basket_view(
+ self.config,
&loaded.document,
loaded.file.as_path(),
Some("updated"),
- ))
+ )?)
}
}
@@ -363,10 +382,11 @@ impl OperationService<BasketAdjustmentAddRequest> for BasketOperationService<'_>
loaded.document.quote = None;
save_basket(loaded.file.as_path(), &loaded.document)?;
json_operation_result::<BasketAdjustmentAddResult>(basket_view(
+ self.config,
&loaded.document,
loaded.file.as_path(),
Some("updated"),
- ))
+ )?)
}
}
@@ -408,10 +428,11 @@ impl OperationService<BasketAdjustmentRemoveRequest> for BasketOperationService<
loaded.document.quote = None;
save_basket(loaded.file.as_path(), &loaded.document)?;
json_operation_result::<BasketAdjustmentRemoveResult>(basket_view(
+ self.config,
&loaded.document,
loaded.file.as_path(),
Some("updated"),
- ))
+ )?)
}
}
@@ -430,9 +451,10 @@ impl OperationService<BasketValidateRequest> for BasketOperationService<'_> {
));
};
json_operation_result::<BasketValidateResult>(basket_validation_view(
+ self.config,
&loaded.document,
loaded.file.as_path(),
- ))
+ )?)
}
}
@@ -446,8 +468,9 @@ impl OperationService<BasketQuoteCreateRequest> for BasketOperationService<'_> {
let basket_id = required_basket_id(&request)?;
let mut loaded =
load_required_basket(self.config, basket_id.as_str(), request.operation_id())?;
- let issues = basket_issues(&loaded.document);
+ let issues = basket_issues(self.config, &loaded.document)?;
if !issues.is_empty() {
+ let actions = basket_actions(&loaded.document, issues.as_slice());
return json_operation_result::<BasketQuoteCreateResult>(json!({
"state": "unconfigured",
"source": BASKET_QUOTE_SOURCE,
@@ -455,7 +478,7 @@ impl OperationService<BasketQuoteCreateRequest> for BasketOperationService<'_> {
"file": loaded.file.display().to_string(),
"ready_for_quote": false,
"issues": issues,
- "actions": basket_actions(&loaded.document),
+ "actions": actions,
}));
}
@@ -685,8 +708,16 @@ where
})
}
-fn basket_view(document: &BasketDocument, file: &Path, state: Option<&str>) -> Value {
- json!({
+fn basket_view(
+ config: &RuntimeConfig,
+ document: &BasketDocument,
+ file: &Path,
+ state: Option<&str>,
+) -> Result<Value, OperationAdapterError> {
+ let issues = basket_issues(config, document)?;
+ let ready_for_quote = issues.is_empty();
+ let actions = basket_actions(document, issues.as_slice());
+ Ok(json!({
"state": state.unwrap_or("ready"),
"source": BASKET_SOURCE,
"basket_id": document.basket.basket_id,
@@ -696,25 +727,31 @@ fn basket_view(document: &BasketDocument, file: &Path, state: Option<&str>) -> V
"adjustment_count": document.basket.adjustments.len(),
"adjustments": document.basket.adjustments,
"quote": document.quote,
- "ready_for_quote": basket_issues(document).is_empty(),
- "issues": basket_issues(document),
- "actions": basket_actions(document),
- })
+ "ready_for_quote": ready_for_quote,
+ "issues": issues,
+ "actions": actions,
+ }))
}
-fn basket_validation_view(document: &BasketDocument, file: &Path) -> Value {
- let issues = basket_issues(document);
- json!({
- "state": if issues.is_empty() { "ready" } else { "unconfigured" },
+fn basket_validation_view(
+ config: &RuntimeConfig,
+ document: &BasketDocument,
+ file: &Path,
+) -> Result<Value, OperationAdapterError> {
+ let issues = basket_issues(config, document)?;
+ let ready_for_quote = issues.is_empty();
+ let actions = basket_actions(document, issues.as_slice());
+ Ok(json!({
+ "state": if ready_for_quote { "ready" } else { "unconfigured" },
"source": BASKET_SOURCE,
"basket_id": document.basket.basket_id,
"file": file.display().to_string(),
- "ready_for_quote": issues.is_empty(),
+ "ready_for_quote": ready_for_quote,
"item_count": document.basket.items.len(),
"adjustment_count": document.basket.adjustments.len(),
"issues": issues,
- "actions": basket_actions(document),
- })
+ "actions": actions,
+ }))
}
fn missing_basket_view(config: &RuntimeConfig, lookup: &str) -> Value {
@@ -749,13 +786,16 @@ fn list_basket_summaries(config: &RuntimeConfig) -> Result<Vec<Value>, Operation
continue;
}
let loaded = load_basket_path(path.as_path())?;
+ let issues = basket_issues(config, &loaded.document)?;
+ let ready_for_quote = issues.is_empty();
baskets.push(json!({
"basket_id": loaded.document.basket.basket_id,
- "state": if basket_issues(&loaded.document).is_empty() { "ready" } else { "unconfigured" },
+ "state": if ready_for_quote { "ready" } else { "unconfigured" },
"file": loaded.file.display().to_string(),
"item_count": loaded.document.basket.items.len(),
"adjustment_count": loaded.document.basket.adjustments.len(),
- "ready_for_quote": basket_issues(&loaded.document).is_empty(),
+ "ready_for_quote": ready_for_quote,
+ "issues": issues,
"quote": loaded.document.quote,
"updated_at_unix": loaded.document.basket.updated_at_unix,
}));
@@ -774,49 +814,218 @@ fn list_basket_summaries(config: &RuntimeConfig) -> Result<Vec<Value>, Operation
Ok(baskets)
}
-fn basket_issues(document: &BasketDocument) -> Vec<BasketIssue> {
+fn basket_issues(
+ config: &RuntimeConfig,
+ document: &BasketDocument,
+) -> Result<Vec<BasketIssue>, OperationAdapterError> {
let mut issues = Vec::new();
if document.basket.items.is_empty() {
- issues.push(BasketIssue {
- field: "basket.items".to_owned(),
- message: "basket must contain one item before quote creation".to_owned(),
- });
+ issues.push(basket_issue(
+ "basket_items_missing",
+ "basket.items",
+ "basket must contain one item before quote creation",
+ ));
}
if document.basket.items.len() > 1 {
- issues.push(BasketIssue {
- field: "basket.items".to_owned(),
- message: "basket quotes support exactly one item".to_owned(),
- });
+ issues.push(basket_issue(
+ "basket_items_unsupported",
+ "basket.items",
+ "basket quotes support exactly one item",
+ ));
}
for item in &document.basket.items {
if item.listing.is_none() && item.listing_addr.is_none() {
- issues.push(BasketIssue {
- field: format!("basket.items.{}.listing", item.item_id),
- message: "item must include listing or listing_addr".to_owned(),
- });
+ issues.push(basket_issue(
+ "basket_item_listing_missing",
+ format!("basket.items.{}.listing", item.item_id),
+ "item must include listing or listing_addr",
+ ));
}
if item.bin_id.trim().is_empty() {
- issues.push(BasketIssue {
- field: format!("basket.items.{}.bin_id", item.item_id),
- message: "item must include bin_id".to_owned(),
- });
+ issues.push(basket_issue(
+ "basket_item_bin_missing",
+ format!("basket.items.{}.bin_id", item.item_id),
+ "item must include bin_id",
+ ));
}
if item.quantity == 0 {
- issues.push(BasketIssue {
- field: format!("basket.items.{}.quantity", item.item_id),
- message: "item quantity must be greater than 0".to_owned(),
- });
+ issues.push(basket_issue(
+ "basket_item_quantity_invalid",
+ format!("basket.items.{}.quantity", item.item_id),
+ "item quantity must be greater than 0",
+ ));
}
}
- issues
+ if issues.is_empty() {
+ issues.extend(basket_market_issues(config, document)?);
+ }
+ Ok(issues)
+}
+
+fn basket_market_issues(
+ config: &RuntimeConfig,
+ document: &BasketDocument,
+) -> Result<Vec<BasketIssue>, OperationAdapterError> {
+ if !config.local.replica_db_path.exists() {
+ return Ok(Vec::new());
+ }
+ let executor = SqliteExecutor::open(&config.local.replica_db_path).map_err(|error| {
+ OperationAdapterError::Runtime(format!(
+ "open local replica {}: {error}",
+ config.local.replica_db_path.display()
+ ))
+ })?;
+ let mut issues = Vec::new();
+ for item in &document.basket.items {
+ let Some(product) = basket_product_bin_state(config, &executor, item)? else {
+ continue;
+ };
+ let Some(primary_bin_id) = product.primary_bin_id.as_deref().and_then(non_empty_ref) else {
+ issues.push(basket_issue(
+ "listing_primary_bin_missing",
+ format!("basket.items.{}.bin_id", item.item_id),
+ "current local replica listing primary bin is required before quote creation",
+ ));
+ continue;
+ };
+ let Some(verified_primary_bin_id) = product
+ .verified_primary_bin_id
+ .as_deref()
+ .and_then(non_empty_ref)
+ else {
+ issues.push(basket_issue(
+ "listing_primary_bin_invalid",
+ format!("basket.items.{}.bin_id", item.item_id),
+ format!("current local replica primary bin `{primary_bin_id}` is not verified"),
+ ));
+ continue;
+ };
+ if verified_primary_bin_id != primary_bin_id {
+ issues.push(basket_issue(
+ "listing_primary_bin_invalid",
+ format!("basket.items.{}.bin_id", item.item_id),
+ format!(
+ "current local replica primary bin `{primary_bin_id}` does not match verified primary bin `{verified_primary_bin_id}`"
+ ),
+ ));
+ continue;
+ }
+ if item.bin_id != primary_bin_id {
+ issues.push(basket_issue(
+ "order_bin_unknown",
+ format!("basket.items.{}.bin_id", item.item_id),
+ format!(
+ "basket bin `{}` is not in the current local listing bin set; expected primary bin `{primary_bin_id}`",
+ item.bin_id
+ ),
+ ));
+ }
+ }
+ Ok(issues)
+}
+
+fn basket_product_bin_state(
+ config: &RuntimeConfig,
+ executor: &SqliteExecutor,
+ item: &BasketItem,
+) -> Result<Option<BasketProductBinState>, OperationAdapterError> {
+ if let Some(listing_addr) = item.listing_addr.as_deref().and_then(non_empty_ref) {
+ let product_rows = trade_product::find_many(
+ executor,
+ &ITradeProductFindMany {
+ filter: Some(trade_product_listing_addr_filter(listing_addr)),
+ },
+ )
+ .map_err(|error| {
+ OperationAdapterError::Runtime(format!("resolve listing product state: {error:?}"))
+ })?
+ .results;
+ let [product] = product_rows.as_slice() else {
+ return Ok(None);
+ };
+ return Ok(Some(BasketProductBinState {
+ primary_bin_id: product.primary_bin_id.clone(),
+ verified_primary_bin_id: product.verified_primary_bin_id.clone(),
+ }));
+ }
+
+ let Some(listing_lookup) = item.listing.as_deref().and_then(non_empty_ref) else {
+ return Ok(None);
+ };
+ let lookup_executor = SqliteExecutor::open(&config.local.replica_db_path).map_err(|error| {
+ OperationAdapterError::Runtime(format!(
+ "open local replica {}: {error}",
+ config.local.replica_db_path.display()
+ ))
+ })?;
+ let rows = ReplicaSql::new(lookup_executor)
+ .trade_product_lookup(listing_lookup)
+ .map_err(|error| {
+ OperationAdapterError::Runtime(format!("resolve listing product state: {error:?}"))
+ })?;
+ let [product] = rows.as_slice() else {
+ return Ok(None);
+ };
+ Ok(Some(BasketProductBinState {
+ primary_bin_id: product.primary_bin_id.clone(),
+ verified_primary_bin_id: product.verified_primary_bin_id.clone(),
+ }))
+}
+
+fn basket_issue(
+ code: impl Into<String>,
+ field: impl Into<String>,
+ message: impl Into<String>,
+) -> BasketIssue {
+ BasketIssue {
+ code: code.into(),
+ field: field.into(),
+ message: message.into(),
+ }
+}
+
+fn trade_product_listing_addr_filter(listing_addr: &str) -> ITradeProductFieldsFilter {
+ ITradeProductFieldsFilter {
+ id: None,
+ created_at: None,
+ updated_at: None,
+ key: None,
+ category: None,
+ title: None,
+ summary: None,
+ process: None,
+ lot: None,
+ profile: None,
+ year: None,
+ qty_amt: None,
+ qty_amt_exact: None,
+ qty_unit: None,
+ qty_label: None,
+ qty_avail: None,
+ price_amt: None,
+ price_amt_exact: None,
+ price_currency: None,
+ price_qty_amt: None,
+ price_qty_amt_exact: None,
+ price_qty_unit: None,
+ listing_addr: Some(listing_addr.to_owned()),
+ primary_bin_id: None,
+ verified_primary_bin_id: None,
+ notes: None,
+ }
+}
+
+fn non_empty_ref(value: &str) -> Option<&str> {
+ let value = value.trim();
+ if value.is_empty() { None } else { Some(value) }
}
-fn basket_actions(document: &BasketDocument) -> Vec<String> {
+fn basket_actions(document: &BasketDocument, issues: &[BasketIssue]) -> Vec<String> {
let basket_id = document.basket.basket_id.as_str();
if document.basket.items.is_empty() {
return vec![format!("radroots basket item add {basket_id}")];
}
- if basket_issues(document).is_empty() {
+ if issues.is_empty() {
vec![
format!("radroots basket validate {basket_id}"),
format!("radroots basket quote create {basket_id}"),
@@ -841,6 +1050,7 @@ fn quote_issues_from_order(order: &OrderNewView) -> Vec<BasketIssue> {
.issues
.iter()
.map(|issue| BasketIssue {
+ code: issue.code.clone(),
field: issue.field.clone(),
message: issue.message.clone(),
})
diff --git a/src/runtime/order.rs b/src/runtime/order.rs
@@ -234,6 +234,7 @@ struct ResolvedOrderEconomicsProduct {
price_qty_amt_exact: Option<String>,
price_qty_unit: String,
primary_bin_id: Option<String>,
+ verified_primary_bin_id: Option<String>,
notes: Option<String>,
}
@@ -247,6 +248,7 @@ impl ResolvedOrderEconomicsProduct {
price_qty_amt_exact: row.price_qty_amt_exact.clone(),
price_qty_unit: row.price_qty_unit.clone(),
primary_bin_id: row.primary_bin_id.clone(),
+ verified_primary_bin_id: row.verified_primary_bin_id.clone(),
notes: row.notes.clone(),
}
}
@@ -260,6 +262,7 @@ impl ResolvedOrderEconomicsProduct {
price_qty_amt_exact: row.price_qty_amt_exact,
price_qty_unit: row.price_qty_unit,
primary_bin_id: row.primary_bin_id,
+ verified_primary_bin_id: row.verified_primary_bin_id,
notes: row.notes,
}
}
@@ -9112,6 +9115,22 @@ fn order_economics_from_resolved_listing(
let Some(primary_bin_id) = product.primary_bin_id.as_deref().and_then(non_empty_ref) else {
return Ok(None);
};
+ let Some(verified_primary_bin_id) = product
+ .verified_primary_bin_id
+ .as_deref()
+ .and_then(non_empty_ref)
+ else {
+ return Err(RuntimeError::Config(format!(
+ "listing_primary_bin_invalid: listing `{}` primary bin `{primary_bin_id}` is not verified in the current local replica",
+ listing.listing_addr
+ )));
+ };
+ if verified_primary_bin_id != primary_bin_id {
+ return Err(RuntimeError::Config(format!(
+ "listing_primary_bin_invalid: listing `{}` primary bin `{primary_bin_id}` does not match verified primary bin `{verified_primary_bin_id}` in the current local replica",
+ listing.listing_addr
+ )));
+ }
if items.is_empty()
|| items
.iter()
@@ -10907,6 +10926,38 @@ fn order_submit_quantity_preflight_view(
)],
)));
};
+ let Some(verified_primary_bin_id) = product
+ .verified_primary_bin_id
+ .as_deref()
+ .and_then(non_empty_ref)
+ else {
+ return Ok(Some(order_submit_invalid_quantity_view(
+ config,
+ loaded,
+ args,
+ "order listing bin identity is not verified in the local replica",
+ vec![issue_with_code(
+ "listing_primary_bin_invalid",
+ "inventory.primary_bin_id",
+ format!("current local replica primary bin `{primary_bin_id}` is not verified"),
+ )],
+ )));
+ };
+ if verified_primary_bin_id != primary_bin_id {
+ return Ok(Some(order_submit_invalid_quantity_view(
+ config,
+ loaded,
+ args,
+ "order listing bin identity is invalid in the local replica",
+ vec![issue_with_code(
+ "listing_primary_bin_invalid",
+ "inventory.primary_bin_id",
+ format!(
+ "current local replica primary bin `{primary_bin_id}` does not match verified primary bin `{verified_primary_bin_id}`"
+ ),
+ )],
+ )));
+ }
let mut bin_issues = Vec::new();
for (index, item) in loaded.document.order.items.iter().enumerate() {
@@ -12670,6 +12721,7 @@ mod tests {
price_qty_amt_exact: Some("1".to_owned()),
price_qty_unit: "each".to_owned(),
primary_bin_id: Some("bin-1".to_owned()),
+ verified_primary_bin_id: Some("bin-1".to_owned()),
notes: Some(
serde_json::json!({
"listing_discounts": [{
@@ -12745,6 +12797,7 @@ mod tests {
price_qty_amt_exact: Some("1".to_owned()),
price_qty_unit: "each".to_owned(),
primary_bin_id: Some("bin-1".to_owned()),
+ verified_primary_bin_id: Some("bin-1".to_owned()),
notes: None,
}),
};
@@ -12786,6 +12839,7 @@ mod tests {
price_qty_amt_exact: Some("1".to_owned()),
price_qty_unit: "kg".to_owned(),
primary_bin_id: Some("bin-a".to_owned()),
+ verified_primary_bin_id: Some("bin-a".to_owned()),
notes: None,
}),
};
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -7229,6 +7229,102 @@ fn order_submit_rejects_unknown_local_listing_bin_before_publish() {
}
#[test]
+fn basket_quote_rejects_invalid_verified_primary_bin_before_order_write() {
+ let sandbox = RadrootsCliSandbox::new();
+ sandbox.json_success(&["--format", "json", "account", "create"]);
+ seed_orderable_listing(&sandbox, LISTING_ADDR);
+ update_orderable_listing_primary_bin_id(&sandbox, LISTING_ADDR, Some("missing-bin"));
+ sandbox.json_success(&[
+ "--format",
+ "json",
+ "basket",
+ "create",
+ "invalid_primary_bin",
+ ]);
+ let add = sandbox.json_success(&[
+ "--format",
+ "json",
+ "basket",
+ "item",
+ "add",
+ "invalid_primary_bin",
+ "--listing-addr",
+ LISTING_ADDR,
+ "--bin-id",
+ "bin-1",
+ "--quantity",
+ "2",
+ ]);
+ assert_eq!(add["result"]["ready_for_quote"], false);
+ assert_eq!(
+ add["result"]["issues"][0]["code"],
+ "listing_primary_bin_invalid"
+ );
+
+ let validate = sandbox.json_success(&[
+ "--format",
+ "json",
+ "basket",
+ "validate",
+ "invalid_primary_bin",
+ ]);
+ assert_eq!(validate["result"]["state"], "unconfigured");
+ assert_eq!(validate["result"]["ready_for_quote"], false);
+ assert_eq!(
+ validate["result"]["issues"][0]["code"],
+ "listing_primary_bin_invalid"
+ );
+
+ let quote = sandbox.json_success(&[
+ "--format",
+ "json",
+ "basket",
+ "quote",
+ "create",
+ "invalid_primary_bin",
+ ]);
+ assert_eq!(quote["result"]["state"], "unconfigured");
+ assert_eq!(quote["result"]["ready_for_quote"], false);
+ assert_eq!(
+ quote["result"]["issues"][0]["code"],
+ "listing_primary_bin_invalid"
+ );
+ assert!(!sandbox.root().join("data/apps/cli/orders/drafts").exists());
+}
+
+#[test]
+fn order_submit_rejects_stale_invalid_verified_primary_bin_before_relay_preflight() {
+ let sandbox = RadrootsCliSandbox::new();
+ let order_id = create_ready_order(&sandbox, "stale_invalid_bin");
+ update_orderable_listing_primary_bin_id(&sandbox, LISTING_ADDR, Some("missing-bin"));
+
+ let (output, value) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "order",
+ "submit",
+ &order_id,
+ ]);
+
+ assert!(!output.status.success());
+ assert_eq!(output.status.code(), Some(10));
+ assert_eq!(value["operation_id"], "order.submit");
+ assert_eq!(value["dry_run"], true);
+ assert_eq!(value["errors"][0]["code"], "validation_failed");
+ assert_eq!(
+ value["errors"][0]["detail"]["issues"][0]["code"],
+ "listing_primary_bin_invalid"
+ );
+ assert_eq!(
+ value["errors"][0]["detail"]["issues"][0]["field"],
+ "inventory.primary_bin_id"
+ );
+ assert_no_removed_command_reference(&value, &["order", "submit", "--dry-run"]);
+ assert_no_daemon_runtime_reference(&value, &["order", "submit", "--dry-run"]);
+}
+
+#[test]
fn order_submit_dry_run_rejects_over_available_quantity_before_relay_preflight() {
let sandbox = RadrootsCliSandbox::new();
sandbox.json_success(&["--format", "json", "account", "create"]);