lib

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

commit abd648752ac9fc831ef15857bab1fbea7307e4ba
parent 2f2023f54cf2e7b0e0fb98b8a0e380a68c7f26d3
Author: triesap <tyson@radroots.org>
Date:   Wed, 29 Apr 2026 17:15:46 +0000

trade: validate accepted commitments

- add reducer issue for commitment mismatches
- normalize request and commitment bin counts
- reject wrong bin and count acceptances
- cover normalized commitment equality tests

Diffstat:
Mcrates/trade/src/order.rs | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 146 insertions(+), 0 deletions(-)

diff --git a/crates/trade/src/order.rs b/crates/trade/src/order.rs @@ -80,6 +80,7 @@ pub enum RadrootsActiveOrderReducerIssue { DecisionRootMismatch { event_id: String }, DecisionPreviousMismatch { event_id: String }, DecisionMissingInventoryCommitments { event_id: String }, + DecisionInventoryCommitmentMismatch { event_id: String }, DecisionMissingReason { event_id: String }, ConflictingDecisions { event_ids: Vec<String> }, } @@ -462,6 +463,19 @@ fn validate_active_decision_record( }); valid = false; } + if let RadrootsTradeOrderDecision::Accepted { + inventory_commitments, + } = &decision.payload.decision + && decision.payload.validate().is_ok() + && !inventory_commitments_match_request(&request.payload.items, inventory_commitments) + { + issues.push( + RadrootsActiveOrderReducerIssue::DecisionInventoryCommitmentMismatch { + event_id: decision.event_id.clone(), + }, + ); + valid = false; + } valid } @@ -613,6 +627,62 @@ fn canonicalize_inventory_commitments( Ok(()) } +#[derive(Debug, PartialEq, Eq)] +struct NormalizedInventoryCount { + bin_id: String, + bin_count: u64, +} + +fn inventory_commitments_match_request( + request_items: &[RadrootsTradeOrderItem], + inventory_commitments: &[RadrootsTradeInventoryCommitment], +) -> bool { + normalized_request_item_counts(request_items) + == normalized_inventory_commitment_counts(inventory_commitments) +} + +fn normalized_request_item_counts( + items: &[RadrootsTradeOrderItem], +) -> Option<Vec<NormalizedInventoryCount>> { + let mut counts = Vec::new(); + for item in items { + push_normalized_inventory_count(&mut counts, &item.bin_id, item.bin_count)?; + } + counts.sort_by(|left, right| left.bin_id.cmp(&right.bin_id)); + Some(counts) +} + +fn normalized_inventory_commitment_counts( + commitments: &[RadrootsTradeInventoryCommitment], +) -> Option<Vec<NormalizedInventoryCount>> { + let mut counts = Vec::new(); + for commitment in commitments { + push_normalized_inventory_count(&mut counts, &commitment.bin_id, commitment.bin_count)?; + } + counts.sort_by(|left, right| left.bin_id.cmp(&right.bin_id)); + Some(counts) +} + +fn push_normalized_inventory_count( + counts: &mut Vec<NormalizedInventoryCount>, + bin_id: &str, + bin_count: u32, +) -> Option<()> { + let bin_id = bin_id.trim(); + if bin_id.is_empty() || bin_count == 0 { + return None; + } + if let Some(existing) = counts.iter_mut().find(|count| count.bin_id == bin_id) { + existing.bin_count += u64::from(bin_count); + } else { + counts.push(NormalizedInventoryCount { + bin_id: bin_id.to_string(), + bin_count: u64::from(bin_count), + }); + } + Some(()) +} + fn normalized_required_string( value: String, field: &'static str, @@ -939,6 +1009,82 @@ mod tests { } #[test] + fn reduce_active_order_events_rejects_commitment_count_mismatch() { + let decision = RadrootsActiveOrderDecisionRecord { + payload: decision_payload(RadrootsTradeOrderDecision::Accepted { + inventory_commitments: vec![RadrootsTradeInventoryCommitment { + bin_id: "bin-1".to_string(), + bin_count: 1, + }], + }), + ..accepted_decision_record("decision-1") + }; + + let projection = reduce_active_order_events("order-1", [request_record()], [decision]); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); + assert!(projection.issues.iter().any(|issue| matches!( + issue, + RadrootsActiveOrderReducerIssue::DecisionInventoryCommitmentMismatch { event_id } + if event_id == "decision-1" + ))); + } + + #[test] + fn reduce_active_order_events_rejects_commitment_bin_mismatch() { + let decision = RadrootsActiveOrderDecisionRecord { + payload: decision_payload(RadrootsTradeOrderDecision::Accepted { + inventory_commitments: vec![RadrootsTradeInventoryCommitment { + bin_id: "bin-2".to_string(), + bin_count: 2, + }], + }), + ..accepted_decision_record("decision-1") + }; + + let projection = reduce_active_order_events("order-1", [request_record()], [decision]); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Invalid); + assert_eq!( + projection.issues, + vec![ + RadrootsActiveOrderReducerIssue::DecisionInventoryCommitmentMismatch { + event_id: "decision-1".to_string() + } + ] + ); + } + + #[test] + fn reduce_active_order_events_matches_normalized_duplicate_bins() { + let mut request = request_record(); + request.payload.items = vec![ + RadrootsTradeOrderItem { + bin_id: " bin-1 ".to_string(), + bin_count: 1, + }, + RadrootsTradeOrderItem { + bin_id: "bin-1".to_string(), + bin_count: 1, + }, + ]; + let decision = RadrootsActiveOrderDecisionRecord { + payload: decision_payload(RadrootsTradeOrderDecision::Accepted { + inventory_commitments: vec![RadrootsTradeInventoryCommitment { + bin_id: "bin-1".to_string(), + bin_count: 2, + }], + }), + ..accepted_decision_record("decision-1") + }; + + let projection = reduce_active_order_events("order-1", [request], [decision]); + + assert_eq!(projection.status, RadrootsActiveOrderStatus::Accepted); + assert!(projection.issues.is_empty()); + } + + #[test] fn reduce_active_order_events_rejects_missing_decline_reason() { let decision = RadrootsActiveOrderDecisionRecord { payload: decision_payload(RadrootsTradeOrderDecision::Declined {