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:
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;
}