lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit ea16d393a4fbeca4c1b1cedcebe991e1e50244ec
parent ab015cb372c34340665b1e67ad87e6325ed87a16
Author: triesap <tyson@radroots.org>
Date:   Sun, 24 May 2026 18:14:17 +0000

local-events: harden app order work contract

- add typed supported and unsupported app order validation results
- require current no-payment order identity, item, and economics fields
- reject malformed support status, payment, currentness, item, and economics payloads
- validate with cargo test and cargo check for radroots_local_events

Diffstat:
Mcrates/local_events/src/lib.rs | 5++++-
Mcrates/local_events/src/order_work.rs | 410+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/local_events/tests/order_work.rs | 263+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
3 files changed, 611 insertions(+), 67 deletions(-)

diff --git a/crates/local_events/src/lib.rs b/crates/local_events/src/lib.rs @@ -15,7 +15,10 @@ pub use models::{ pub use order_work::{ BUYER_ORDER_REQUEST_ACTOR_SOURCE_RESOLVED_ACCOUNT, BUYER_ORDER_REQUEST_ACTOR_SOURCE_UNRESOLVED_APP, BUYER_ORDER_REQUEST_DOCUMENT_KIND, - BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, buyer_order_request_local_work_record_id, + BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, BuyerOrderRequestLocalWorkValidation, + BuyerOrderRequestSupportState, buyer_order_request_local_work_record_id, validate_buyer_order_request_local_work_payload, + validate_supported_buyer_order_request_local_work_payload, + validate_unsupported_buyer_order_request_local_work_payload, }; pub use store::LocalEventsStore; diff --git a/crates/local_events/src/order_work.rs b/crates/local_events/src/order_work.rs @@ -8,6 +8,28 @@ pub const BUYER_ORDER_REQUEST_DOCUMENT_KIND: &str = "order_draft_v1"; pub const BUYER_ORDER_REQUEST_ACTOR_SOURCE_RESOLVED_ACCOUNT: &str = "resolved_account"; pub const BUYER_ORDER_REQUEST_ACTOR_SOURCE_UNRESOLVED_APP: &str = "app_unresolved"; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BuyerOrderRequestSupportState { + Supported, + Unsupported, +} + +impl BuyerOrderRequestSupportState { + pub fn as_str(self) -> &'static str { + match self { + Self::Supported => "supported", + Self::Unsupported => "unsupported", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BuyerOrderRequestLocalWorkValidation { + pub order_id: String, + pub support_state: BuyerOrderRequestSupportState, + pub support_issues: Vec<String>, +} + pub fn buyer_order_request_local_work_record_id( order_id: &str, ) -> Result<String, LocalEventsError> { @@ -18,21 +40,302 @@ pub fn buyer_order_request_local_work_record_id( pub fn validate_buyer_order_request_local_work_payload( payload: &Value, -) -> Result<(), LocalEventsError> { +) -> Result<BuyerOrderRequestLocalWorkValidation, LocalEventsError> { validate_string_field( payload, &["record_kind"], BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, )?; + validate_string_field(payload, &["scope"], "app")?; validate_string_field( payload, &["document", "kind"], BUYER_ORDER_REQUEST_DOCUMENT_KIND, )?; - validate_required_string(payload, &["document", "order", "order_id"], "order_id")?; validate_bool_field(payload, &["currentness", "current"], true)?; + validate_string_field(payload, &["currentness", "source"], "app_sqlite_order")?; validate_bool_field(payload, &["no_payment", "payment_required"], false)?; validate_bool_field(payload, &["no_payment", "settlement_deferred"], true)?; + validate_string_field(payload, &["no_payment", "payment_state"], "not_applicable")?; + + let order_id = validate_required_string(payload, &["document", "order", "order_id"])?; + let currentness_order_id = validate_required_string(payload, &["currentness", "order_id"])?; + if currentness_order_id != order_id { + return Err(invalid_field( + "currentness.order_id", + "must match document.order.order_id", + )); + } + validate_required_string(payload, &["currentness", "record_id"])?; + validate_positive_i64(payload, &["currentness", "created_at_ms"])?; + validate_required_string(payload, &["currentness", "order_updated_at"])?; + + let support_state = validate_support_status(payload)?; + validate_exportability(payload, support_state)?; + validate_order_identity(payload, support_state)?; + validate_order_items(payload)?; + validate_order_economics(payload)?; + + Ok(BuyerOrderRequestLocalWorkValidation { + order_id: order_id.to_owned(), + support_state, + support_issues: support_issues(payload)?, + }) +} + +pub fn validate_supported_buyer_order_request_local_work_payload( + payload: &Value, +) -> Result<BuyerOrderRequestLocalWorkValidation, LocalEventsError> { + let validation = validate_buyer_order_request_local_work_payload(payload)?; + if validation.support_state != BuyerOrderRequestSupportState::Supported { + return Err(invalid_field( + "support_status.state", + "must be supported for exportable app order work", + )); + } + Ok(validation) +} + +pub fn validate_unsupported_buyer_order_request_local_work_payload( + payload: &Value, +) -> Result<BuyerOrderRequestLocalWorkValidation, LocalEventsError> { + let validation = validate_buyer_order_request_local_work_payload(payload)?; + if validation.support_state != BuyerOrderRequestSupportState::Unsupported { + return Err(invalid_field( + "support_status.state", + "must be unsupported for unsupported app order work", + )); + } + Ok(validation) +} + +fn validate_support_status( + payload: &Value, +) -> Result<BuyerOrderRequestSupportState, LocalEventsError> { + let state = validate_required_string(payload, &["support_status", "state"])?; + let issues = support_issues(payload)?; + match state { + "supported" => { + if !issues.is_empty() { + return Err(invalid_field( + "support_status.issues", + "must be empty when support_status.state is supported", + )); + } + Ok(BuyerOrderRequestSupportState::Supported) + } + "unsupported" => { + if issues.is_empty() { + return Err(invalid_field( + "support_status.issues", + "must contain at least one issue when support_status.state is unsupported", + )); + } + Ok(BuyerOrderRequestSupportState::Unsupported) + } + _ => Err(invalid_field( + "support_status.state", + "must be supported or unsupported", + )), + } +} + +fn validate_exportability( + payload: &Value, + support_state: BuyerOrderRequestSupportState, +) -> Result<(), LocalEventsError> { + let state = validate_required_string(payload, &["exportability", "state"])?; + let buyer_actor_source = + validate_required_string(payload, &["document", "buyer_actor", "source"])?; + match state { + "exportable" => { + validate_string_field( + payload, + &["document", "buyer_actor", "source"], + BUYER_ORDER_REQUEST_ACTOR_SOURCE_RESOLVED_ACCOUNT, + )?; + validate_buyer_pubkey(payload)?; + } + "identity_unresolved" => { + validate_required_string(payload, &["exportability", "reason"])?; + validate_string_field( + payload, + &["document", "buyer_actor", "source"], + BUYER_ORDER_REQUEST_ACTOR_SOURCE_UNRESOLVED_APP, + )?; + if support_state == BuyerOrderRequestSupportState::Supported { + return Err(invalid_field( + "exportability.state", + "supported app order work must be exportable", + )); + } + } + _ => { + return Err(invalid_field( + "exportability.state", + "must be exportable or identity_unresolved", + )); + } + } + if buyer_actor_source == BUYER_ORDER_REQUEST_ACTOR_SOURCE_RESOLVED_ACCOUNT { + validate_buyer_pubkey(payload)?; + } + Ok(()) +} + +fn validate_order_identity( + payload: &Value, + support_state: BuyerOrderRequestSupportState, +) -> Result<(), LocalEventsError> { + validate_required_string(payload, &["document", "order", "listing_addr"])?; + validate_required_string(payload, &["document", "order", "listing_event_id"])?; + validate_required_string(payload, &["document", "order", "seller_pubkey"])?; + if support_state == BuyerOrderRequestSupportState::Supported { + validate_buyer_pubkey(payload)?; + } + Ok(()) +} + +fn validate_buyer_pubkey(payload: &Value) -> Result<(), LocalEventsError> { + let order_buyer_pubkey = + validate_required_string(payload, &["document", "order", "buyer_pubkey"])?; + let actor_buyer_pubkey = + validate_required_string(payload, &["document", "buyer_actor", "pubkey"])?; + if order_buyer_pubkey != actor_buyer_pubkey { + return Err(invalid_field( + "document.buyer_actor.pubkey", + "must match document.order.buyer_pubkey", + )); + } + Ok(()) +} + +fn validate_order_items(payload: &Value) -> Result<(), LocalEventsError> { + let items = required_array(payload, &["document", "order", "items"])?; + if items.is_empty() { + return Err(invalid_field( + "document.order.items", + "must contain at least one item", + )); + } + for (index, item) in items.iter().enumerate() { + validate_required_string(item, &["bin_id"]).map_err(|_| { + invalid_field_at( + format!("document.order.items[{index}].bin_id"), + "is required", + ) + })?; + validate_positive_u64(item, &["bin_count"]).map_err(|_| { + invalid_field_at( + format!("document.order.items[{index}].bin_count"), + "must be positive", + ) + })?; + } + Ok(()) +} + +fn validate_order_economics(payload: &Value) -> Result<(), LocalEventsError> { + let economics = value_at(payload, &["document", "order", "economics"]).ok_or_else(|| { + invalid_field("document.order.economics", "is required for app order work") + })?; + if !economics.is_object() { + return Err(invalid_field( + "document.order.economics", + "must be an object", + )); + } + validate_string_field(economics, &["pricing_basis"], "listing_event")?; + let currency = validate_required_string(economics, &["currency"])?; + validate_currency("document.order.economics.currency", currency)?; + let economics_items = required_array(economics, &["items"])?; + let order_items = required_array(payload, &["document", "order", "items"])?; + if economics_items.is_empty() { + return Err(invalid_field( + "document.order.economics.items", + "must contain at least one item", + )); + } + if economics_items.len() != order_items.len() { + return Err(invalid_field( + "document.order.economics.items", + "must match document.order.items length", + )); + } + for (index, item) in economics_items.iter().enumerate() { + let order_item = &order_items[index]; + let economics_bin_id = validate_required_string(item, &["bin_id"]).map_err(|_| { + invalid_field_at( + format!("document.order.economics.items[{index}].bin_id"), + "is required", + ) + })?; + let order_bin_id = validate_required_string(order_item, &["bin_id"])?; + if economics_bin_id != order_bin_id { + return Err(invalid_field_at( + format!("document.order.economics.items[{index}].bin_id"), + "must match document.order.items bin_id", + )); + } + let economics_bin_count = validate_positive_u64(item, &["bin_count"]).map_err(|_| { + invalid_field_at( + format!("document.order.economics.items[{index}].bin_count"), + "must be positive", + ) + })?; + let order_bin_count = validate_positive_u64(order_item, &["bin_count"])?; + if economics_bin_count != order_bin_count { + return Err(invalid_field_at( + format!("document.order.economics.items[{index}].bin_count"), + "must match document.order.items bin_count", + )); + } + validate_required_string(item, &["quantity_amount"]).map_err(|_| { + invalid_field_at( + format!("document.order.economics.items[{index}].quantity_amount"), + "is required", + ) + })?; + validate_required_string(item, &["quantity_unit"]).map_err(|_| { + invalid_field_at( + format!("document.order.economics.items[{index}].quantity_unit"), + "is required", + ) + })?; + validate_required_string(item, &["unit_price_amount"]).map_err(|_| { + invalid_field_at( + format!("document.order.economics.items[{index}].unit_price_amount"), + "is required", + ) + })?; + let unit_price_currency = validate_required_string(item, &["unit_price_currency"])?; + if unit_price_currency != currency { + return Err(invalid_field_at( + format!("document.order.economics.items[{index}].unit_price_currency"), + "must match document.order.economics.currency", + )); + } + validate_money(item, &["line_subtotal"], currency)?; + } + validate_money(economics, &["subtotal"], currency)?; + validate_money(economics, &["discount_total"], currency)?; + validate_money(economics, &["adjustment_total"], currency)?; + validate_money(economics, &["total"], currency)?; + Ok(()) +} + +fn validate_money(payload: &Value, path: &[&str], currency: &str) -> Result<(), LocalEventsError> { + let Some(money) = value_at(payload, path) else { + return Err(missing_field(path)); + }; + validate_required_string(money, &["amount"])?; + let money_currency = validate_required_string(money, &["currency"])?; + if money_currency != currency { + return Err(invalid_field( + &format!("{}.currency", path.join(".")), + "must match currency", + )); + } Ok(()) } @@ -42,32 +345,26 @@ fn validate_string_field( expected: &str, ) -> Result<(), LocalEventsError> { let Some(value) = value_at(payload, path).and_then(Value::as_str) else { - return Err(LocalEventsError::InvalidRecord(format!( - "missing required local order field `{}`", - path.join(".") - ))); + return Err(missing_field(path)); }; if value != expected { - return Err(LocalEventsError::InvalidRecord(format!( - "local order field `{}` must be `{expected}`", - path.join(".") - ))); + return Err(invalid_field( + &path.join("."), + &format!("must be `{expected}`"), + )); } Ok(()) } -fn validate_required_string( - payload: &Value, +fn validate_required_string<'a>( + payload: &'a Value, path: &[&str], - field: &str, -) -> Result<(), LocalEventsError> { +) -> Result<&'a str, LocalEventsError> { let Some(value) = value_at(payload, path).and_then(Value::as_str) else { - return Err(LocalEventsError::InvalidRecord(format!( - "missing required local order field `{}`", - path.join(".") - ))); + return Err(missing_field(path)); }; - validate_non_empty(field, value) + validate_non_empty(&path.join("."), value)?; + Ok(value.trim()) } fn validate_bool_field( @@ -76,20 +373,67 @@ fn validate_bool_field( expected: bool, ) -> Result<(), LocalEventsError> { let Some(value) = value_at(payload, path).and_then(Value::as_bool) else { - return Err(LocalEventsError::InvalidRecord(format!( - "missing required local order field `{}`", - path.join(".") - ))); + return Err(missing_field(path)); }; if value != expected { - return Err(LocalEventsError::InvalidRecord(format!( - "local order field `{}` must be `{expected}`", - path.join(".") - ))); + return Err(invalid_field( + &path.join("."), + &format!("must be `{expected}`"), + )); } Ok(()) } +fn validate_positive_i64(payload: &Value, path: &[&str]) -> Result<(), LocalEventsError> { + match value_at(payload, path).and_then(Value::as_i64) { + Some(value) if value > 0 => Ok(()), + _ => Err(invalid_field(&path.join("."), "must be positive")), + } +} + +fn validate_positive_u64(payload: &Value, path: &[&str]) -> Result<u64, LocalEventsError> { + match value_at(payload, path).and_then(Value::as_u64) { + Some(value) if value > 0 => Ok(value), + _ => Err(invalid_field(&path.join("."), "must be positive")), + } +} + +fn validate_currency(field: &str, value: &str) -> Result<(), LocalEventsError> { + if value.len() != 3 || !value.bytes().all(|byte| byte.is_ascii_uppercase()) { + return Err(invalid_field( + field, + "must be an uppercase ISO currency code", + )); + } + Ok(()) +} + +fn required_array<'a>( + payload: &'a Value, + path: &[&str], +) -> Result<&'a Vec<Value>, LocalEventsError> { + let Some(value) = value_at(payload, path).and_then(Value::as_array) else { + return Err(missing_field(path)); + }; + Ok(value) +} + +fn support_issues(payload: &Value) -> Result<Vec<String>, LocalEventsError> { + let issues = required_array(payload, &["support_status", "issues"])?; + let mut parsed = Vec::with_capacity(issues.len()); + for (index, issue) in issues.iter().enumerate() { + let Some(issue) = issue.as_str() else { + return Err(invalid_field_at( + format!("support_status.issues[{index}]"), + "must be a string", + )); + }; + validate_non_empty("support_status.issues", issue)?; + parsed.push(issue.trim().to_owned()); + } + Ok(parsed) +} + fn value_at<'a>(payload: &'a Value, path: &[&str]) -> Option<&'a Value> { let mut current = payload; for part in path { @@ -97,3 +441,15 @@ fn value_at<'a>(payload: &'a Value, path: &[&str]) -> Option<&'a Value> { } Some(current) } + +fn missing_field(path: &[&str]) -> LocalEventsError { + invalid_field(&path.join("."), "is required") +} + +fn invalid_field(field: &str, requirement: &str) -> LocalEventsError { + LocalEventsError::InvalidRecord(format!("local order field `{field}` {requirement}")) +} + +fn invalid_field_at(field: String, requirement: &str) -> LocalEventsError { + LocalEventsError::InvalidRecord(format!("local order field `{field}` {requirement}")) +} diff --git a/crates/local_events/tests/order_work.rs b/crates/local_events/tests/order_work.rs @@ -1,9 +1,12 @@ use radroots_local_events::{ - BUYER_ORDER_REQUEST_ACTOR_SOURCE_RESOLVED_ACCOUNT, BUYER_ORDER_REQUEST_DOCUMENT_KIND, - BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, buyer_order_request_local_work_record_id, - validate_buyer_order_request_local_work_payload, + BUYER_ORDER_REQUEST_ACTOR_SOURCE_RESOLVED_ACCOUNT, + BUYER_ORDER_REQUEST_ACTOR_SOURCE_UNRESOLVED_APP, BUYER_ORDER_REQUEST_DOCUMENT_KIND, + BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, BuyerOrderRequestSupportState, + buyer_order_request_local_work_record_id, validate_buyer_order_request_local_work_payload, + validate_supported_buyer_order_request_local_work_payload, + validate_unsupported_buyer_order_request_local_work_payload, }; -use serde_json::json; +use serde_json::{Value, json}; #[test] fn buyer_order_request_record_id_is_deterministic_for_app_orders() { @@ -14,64 +17,246 @@ fn buyer_order_request_record_id_is_deterministic_for_app_orders() { } #[test] -fn buyer_order_request_payload_requires_current_no_payment_order_document() { - let payload = json!({ +fn buyer_order_request_payload_accepts_supported_exportable_work() { + let payload = supported_payload(); + + let validation = + validate_buyer_order_request_local_work_payload(&payload).expect("valid payload"); + let supported = validate_supported_buyer_order_request_local_work_payload(&payload) + .expect("supported payload"); + + assert_eq!(validation.order_id, "ord_1"); + assert_eq!( + validation.support_state, + BuyerOrderRequestSupportState::Supported + ); + assert!(validation.support_issues.is_empty()); + assert_eq!(supported, validation); +} + +#[test] +fn buyer_order_request_payload_accepts_explicit_unsupported_work() { + let mut payload = supported_payload(); + payload["exportability"] = json!({ + "state": "identity_unresolved", + "reason": "canonical_hex_pubkey_required" + }); + payload["support_status"] = json!({ + "state": "unsupported", + "issues": ["buyer_pubkey_required"] + }); + payload["document"]["order"]["buyer_pubkey"] = json!(""); + payload["document"]["buyer_actor"]["pubkey"] = json!(""); + payload["document"]["buyer_actor"]["source"] = + json!(BUYER_ORDER_REQUEST_ACTOR_SOURCE_UNRESOLVED_APP); + + let validation = + validate_buyer_order_request_local_work_payload(&payload).expect("valid payload"); + let unsupported = validate_unsupported_buyer_order_request_local_work_payload(&payload) + .expect("unsupported payload"); + let supported_error = validate_supported_buyer_order_request_local_work_payload(&payload) + .expect_err("unsupported payload should not validate as supported"); + + assert_eq!( + validation.support_state, + BuyerOrderRequestSupportState::Unsupported + ); + assert_eq!(validation.support_issues, vec!["buyer_pubkey_required"]); + assert_eq!(unsupported, validation); + assert!(supported_error.to_string().contains("support_status.state")); +} + +#[test] +fn buyer_order_request_payload_rejects_payment_required_documents() { + let mut payload = supported_payload(); + payload["no_payment"]["payment_required"] = json!(true); + + assert_invalid(payload, "payment_required"); +} + +#[test] +fn buyer_order_request_payload_rejects_missing_identity() { + for (path, expected) in [ + (vec!["document", "order", "listing_addr"], "listing_addr"), + ( + vec!["document", "order", "listing_event_id"], + "listing_event_id", + ), + (vec!["document", "order", "seller_pubkey"], "seller_pubkey"), + (vec!["document", "order", "buyer_pubkey"], "buyer_pubkey"), + ] { + let mut payload = supported_payload(); + set_path(&mut payload, &path, json!("")); + + assert_invalid(payload, expected); + } +} + +#[test] +fn buyer_order_request_payload_rejects_missing_items() { + let mut payload = supported_payload(); + payload["document"]["order"]["items"] = json!([]); + + assert_invalid(payload, "items"); +} + +#[test] +fn buyer_order_request_payload_rejects_invalid_item_identity() { + let mut missing_bin = supported_payload(); + missing_bin["document"]["order"]["items"][0]["bin_id"] = json!(""); + assert_invalid(missing_bin, "items[0].bin_id"); + + let mut zero_count = supported_payload(); + zero_count["document"]["order"]["items"][0]["bin_count"] = json!(0); + assert_invalid(zero_count, "items[0].bin_count"); +} + +#[test] +fn buyer_order_request_payload_rejects_invalid_economics() { + let mut missing_economics = supported_payload(); + missing_economics["document"]["order"]["economics"] = Value::Null; + assert_invalid(missing_economics, "economics"); + + let mut mismatched_currency = supported_payload(); + mismatched_currency["document"]["order"]["economics"]["items"][0]["unit_price_currency"] = + json!("CAD"); + assert_invalid(mismatched_currency, "unit_price_currency"); + + let mut mismatched_items = supported_payload(); + mismatched_items["document"]["order"]["economics"]["items"] = json!([]); + assert_invalid(mismatched_items, "economics.items"); + + let mut mismatched_bin = supported_payload(); + mismatched_bin["document"]["order"]["economics"]["items"][0]["bin_id"] = json!("other-bin"); + assert_invalid(mismatched_bin, "economics.items[0].bin_id"); +} + +#[test] +fn buyer_order_request_payload_rejects_stale_or_conflicting_currentness() { + let mut stale = supported_payload(); + stale["currentness"]["current"] = json!(false); + assert_invalid(stale, "currentness.current"); + + let mut wrong_order = supported_payload(); + wrong_order["currentness"]["order_id"] = json!("ord_other"); + assert_invalid(wrong_order, "currentness.order_id"); +} + +#[test] +fn buyer_order_request_payload_rejects_malformed_support_status() { + let mut supported_with_issue = supported_payload(); + supported_with_issue["support_status"]["issues"] = json!(["unit_price_required"]); + assert_invalid(supported_with_issue, "support_status.issues"); + + let mut unsupported_without_issue = supported_payload(); + unsupported_without_issue["support_status"] = json!({ + "state": "unsupported", + "issues": [] + }); + assert_invalid(unsupported_without_issue, "support_status.issues"); +} + +fn supported_payload() -> Value { + json!({ "record_kind": BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, "scope": "app", + "exportability": { + "state": "exportable" + }, + "support_status": { + "state": "supported", + "issues": [] + }, "currentness": { - "current": true + "current": true, + "source": "app_sqlite_order", + "record_id": "app:local_work:order_request:ord_1", + "order_id": "ord_1", + "order_updated_at": "2026-05-24T12:00:00Z", + "created_at_ms": 1777777777000_i64 }, "no_payment": { "payment_required": false, - "settlement_deferred": true + "settlement_deferred": true, + "payment_state": "not_applicable" }, "document": { "version": 1, "kind": BUYER_ORDER_REQUEST_DOCUMENT_KIND, "order": { "order_id": "ord_1", - "listing_addr": "30402:seller:listing", - "buyer_pubkey": "buyer", - "seller_pubkey": "seller", + "listing_addr": "30402:seller_pubkey:listing_key", + "listing_event_id": "event-listing-1", + "buyer_pubkey": "buyer_pubkey", + "seller_pubkey": "seller_pubkey", "items": [ { - "bin_id": "bin-1", - "bin_count": 1 + "bin_id": "dozen-eggs", + "bin_count": 2 } - ] + ], + "economics": { + "quote_id": "app-order:ord_1", + "quote_version": 1, + "pricing_basis": "listing_event", + "currency": "USD", + "items": [ + { + "bin_id": "dozen-eggs", + "bin_count": 2, + "quantity_amount": "1", + "quantity_unit": "dozen", + "unit_price_amount": "8.00", + "unit_price_currency": "USD", + "line_subtotal": { + "amount": "16.00", + "currency": "USD" + } + } + ], + "discounts": [], + "adjustments": [], + "subtotal": { + "amount": "16.00", + "currency": "USD" + }, + "discount_total": { + "amount": "0", + "currency": "USD" + }, + "adjustment_total": { + "amount": "0", + "currency": "USD" + }, + "total": { + "amount": "16.00", + "currency": "USD" + } + } }, "buyer_actor": { "account_id": "buyer-account", - "pubkey": "buyer", + "pubkey": "buyer_pubkey", "source": BUYER_ORDER_REQUEST_ACTOR_SOURCE_RESOLVED_ACCOUNT - } + }, + "listing_lookup": "30402:seller_pubkey:listing_key" } - }); - - validate_buyer_order_request_local_work_payload(&payload).expect("valid payload"); + }) } -#[test] -fn buyer_order_request_payload_rejects_payment_required_documents() { - let payload = json!({ - "record_kind": BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, - "currentness": { - "current": true - }, - "no_payment": { - "payment_required": true, - "settlement_deferred": true - }, - "document": { - "kind": BUYER_ORDER_REQUEST_DOCUMENT_KIND, - "order": { - "order_id": "ord_1" - } - } - }); - +fn assert_invalid(payload: Value, expected: &str) { let error = validate_buyer_order_request_local_work_payload(&payload).expect_err("invalid payload"); + assert!( + error.to_string().contains(expected), + "expected error to contain {expected}, got {error}" + ); +} - assert!(error.to_string().contains("payment_required")); +fn set_path(payload: &mut Value, path: &[&str], value: Value) { + let mut current = payload; + for segment in &path[..path.len() - 1] { + current = current.get_mut(*segment).expect("path segment"); + } + current[path[path.len() - 1]] = value; }