lib

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

commit 508e1e7d2177d3bac9728067c0897a5510033f3a
parent 19f059234b4586912512d3d4abe33d04d6b9a9f8
Author: triesap <tyson@radroots.org>
Date:   Fri, 10 Apr 2026 04:28:49 +0000

events: cover trade validation paths

Diffstat:
Mcrates/events/src/trade.rs | 656+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 647 insertions(+), 9 deletions(-)

diff --git a/crates/events/src/trade.rs b/crates/events/src/trade.rs @@ -618,6 +618,90 @@ impl RadrootsTradeMessagePayload { #[cfg(test)] mod tests { use super::*; + use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscountValue, RadrootsCoreMoney, + RadrootsCorePercent, + }; + + fn sample_listing_addr() -> String { + "30402:pubkey:AAAAAAAAAAAAAAAAAAAAAg".into() + } + + fn sample_discount_value() -> RadrootsCoreDiscountValue { + RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::from_minor_units_u64( + 250, + RadrootsCoreCurrency::USD, + )) + } + + fn sample_percent_discount() -> RadrootsCoreDiscountValue { + RadrootsCoreDiscountValue::Percent(RadrootsCorePercent::new(RadrootsCoreDecimal::from( + 10u32, + ))) + } + + fn sample_order() -> RadrootsTradeOrder { + RadrootsTradeOrder { + order_id: "order-1".into(), + listing_addr: sample_listing_addr(), + buyer_pubkey: "buyer".into(), + seller_pubkey: "seller".into(), + items: vec![RadrootsTradeOrderItem { + bin_id: "bin-1".into(), + bin_count: 2, + }], + discounts: Some(vec![sample_discount_value()]), + } + } + + fn sample_order_revision() -> RadrootsTradeOrderRevision { + RadrootsTradeOrderRevision { + revision_id: "rev-1".into(), + changes: vec![ + RadrootsTradeOrderChange::BinCount { + item_index: 0, + bin_count: 3, + }, + RadrootsTradeOrderChange::ItemAdd { + item: RadrootsTradeOrderItem { + bin_id: "bin-2".into(), + bin_count: 1, + }, + }, + RadrootsTradeOrderChange::ItemRemove { item_index: 1 }, + ], + } + } + + fn sample_order_response(accepted: bool) -> RadrootsTradeOrderResponse { + RadrootsTradeOrderResponse { + accepted, + reason: (!accepted).then(|| "not today".into()), + } + } + + fn sample_order_revision_response(accepted: bool) -> RadrootsTradeOrderRevisionResponse { + RadrootsTradeOrderRevisionResponse { + accepted, + reason: (!accepted).then(|| "needs changes".into()), + } + } + + fn sample_validate_request() -> RadrootsTradeListingValidateRequest { + RadrootsTradeListingValidateRequest { + listing_event: Some(RadrootsNostrEventPtr { + id: "listing-event".into(), + relays: Some("wss://relay.example.com".into()), + }), + } + } + + fn sample_validate_result() -> RadrootsTradeListingValidateResult { + RadrootsTradeListingValidateResult { + valid: false, + errors: vec![RadrootsTradeListingValidationError::MissingDeliveryMethod], + } + } #[test] fn message_type_classifies_request_and_result_kinds() { @@ -636,23 +720,577 @@ mod tests { } #[test] + fn listing_parse_error_display_variants() { + assert_eq!( + RadrootsTradeListingParseError::MissingTag("price".into()).to_string(), + "missing required tag: price" + ); + assert_eq!( + RadrootsTradeListingParseError::InvalidTag("farm".into()).to_string(), + "invalid tag: farm" + ); + assert_eq!( + RadrootsTradeListingParseError::InvalidNumber("inventory".into()).to_string(), + "invalid number: inventory" + ); + assert_eq!( + RadrootsTradeListingParseError::InvalidUnit.to_string(), + "invalid unit" + ); + assert_eq!( + RadrootsTradeListingParseError::InvalidCurrency.to_string(), + "invalid currency" + ); + assert_eq!( + RadrootsTradeListingParseError::InvalidJson("bins".into()).to_string(), + "invalid json: bins" + ); + assert_eq!( + RadrootsTradeListingParseError::InvalidDiscount("offer".into()).to_string(), + "invalid discount data for offer" + ); + } + + #[test] + fn listing_validation_error_display_variants() { + assert_eq!( + (RadrootsTradeListingValidationError::InvalidKind { kind: KIND_PROFILE }).to_string(), + "invalid listing kind: 0" + ); + assert_eq!( + RadrootsTradeListingValidationError::MissingListingId.to_string(), + "missing listing id" + ); + assert_eq!( + RadrootsTradeListingValidationError::ListingEventNotFound { + listing_addr: "listing-1".into(), + } + .to_string(), + "listing event not found: listing-1" + ); + assert_eq!( + RadrootsTradeListingValidationError::ListingEventFetchFailed { + listing_addr: "listing-2".into(), + } + .to_string(), + "listing event fetch failed: listing-2" + ); + assert_eq!( + RadrootsTradeListingValidationError::ParseError { + error: RadrootsTradeListingParseError::InvalidJson("payload".into()), + } + .to_string(), + "invalid listing data: invalid json: payload" + ); + assert_eq!( + RadrootsTradeListingValidationError::InvalidSeller.to_string(), + "listing author does not match farm pubkey" + ); + assert_eq!( + RadrootsTradeListingValidationError::MissingFarmProfile.to_string(), + "missing farm profile" + ); + assert_eq!( + RadrootsTradeListingValidationError::MissingFarmRecord.to_string(), + "missing farm record" + ); + assert_eq!( + RadrootsTradeListingValidationError::MissingTitle.to_string(), + "missing listing title" + ); + assert_eq!( + RadrootsTradeListingValidationError::MissingDescription.to_string(), + "missing listing description" + ); + assert_eq!( + RadrootsTradeListingValidationError::MissingProductType.to_string(), + "missing listing product type" + ); + assert_eq!( + RadrootsTradeListingValidationError::MissingBins.to_string(), + "missing listing bins" + ); + assert_eq!( + RadrootsTradeListingValidationError::MissingPrimaryBin.to_string(), + "missing primary listing bin" + ); + assert_eq!( + RadrootsTradeListingValidationError::InvalidBin.to_string(), + "invalid listing bin" + ); + assert_eq!( + RadrootsTradeListingValidationError::MissingPrice.to_string(), + "missing listing price" + ); + assert_eq!( + RadrootsTradeListingValidationError::InvalidPrice.to_string(), + "invalid listing price" + ); + assert_eq!( + RadrootsTradeListingValidationError::MissingInventory.to_string(), + "missing listing inventory" + ); + assert_eq!( + RadrootsTradeListingValidationError::InvalidInventory.to_string(), + "invalid listing inventory" + ); + assert_eq!( + RadrootsTradeListingValidationError::MissingAvailability.to_string(), + "missing listing availability" + ); + assert_eq!( + RadrootsTradeListingValidationError::MissingLocation.to_string(), + "missing listing location" + ); + assert_eq!( + RadrootsTradeListingValidationError::MissingDeliveryMethod.to_string(), + "missing listing delivery method" + ); + } + + #[test] + fn message_type_maps_all_supported_kinds_and_helpers() { + let cases = [ + ( + RadrootsTradeMessageType::ListingValidateRequest, + KIND_TRADE_LISTING_VALIDATE_REQ, + true, + false, + false, + false, + false, + true, + ), + ( + RadrootsTradeMessageType::ListingValidateResult, + KIND_TRADE_LISTING_VALIDATE_RES, + true, + false, + false, + false, + false, + false, + ), + ( + RadrootsTradeMessageType::OrderRequest, + KIND_TRADE_ORDER_REQUEST, + false, + true, + true, + true, + false, + true, + ), + ( + RadrootsTradeMessageType::OrderResponse, + KIND_TRADE_ORDER_RESPONSE, + false, + true, + true, + false, + true, + false, + ), + ( + RadrootsTradeMessageType::OrderRevision, + KIND_TRADE_ORDER_REVISION, + false, + true, + true, + true, + true, + true, + ), + ( + RadrootsTradeMessageType::OrderRevisionAccept, + KIND_TRADE_ORDER_REVISION_RESPONSE, + false, + true, + true, + false, + true, + false, + ), + ( + RadrootsTradeMessageType::OrderRevisionDecline, + KIND_TRADE_ORDER_REVISION_RESPONSE, + false, + true, + true, + false, + true, + false, + ), + ( + RadrootsTradeMessageType::Question, + KIND_TRADE_QUESTION, + false, + true, + true, + false, + true, + true, + ), + ( + RadrootsTradeMessageType::Answer, + KIND_TRADE_ANSWER, + false, + true, + true, + false, + true, + false, + ), + ( + RadrootsTradeMessageType::DiscountRequest, + KIND_TRADE_DISCOUNT_REQUEST, + false, + true, + true, + true, + true, + true, + ), + ( + RadrootsTradeMessageType::DiscountOffer, + KIND_TRADE_DISCOUNT_OFFER, + false, + true, + true, + true, + true, + false, + ), + ( + RadrootsTradeMessageType::DiscountAccept, + KIND_TRADE_DISCOUNT_ACCEPT, + false, + true, + true, + false, + true, + true, + ), + ( + RadrootsTradeMessageType::DiscountDecline, + KIND_TRADE_DISCOUNT_DECLINE, + false, + true, + true, + false, + true, + true, + ), + ( + RadrootsTradeMessageType::Cancel, + KIND_TRADE_CANCEL, + false, + true, + true, + false, + true, + true, + ), + ( + RadrootsTradeMessageType::FulfillmentUpdate, + KIND_TRADE_FULFILLMENT_UPDATE, + false, + true, + true, + false, + true, + true, + ), + ( + RadrootsTradeMessageType::Receipt, + KIND_TRADE_RECEIPT, + false, + true, + true, + false, + true, + true, + ), + ]; + + for ( + message_type, + kind, + service, + public, + requires_order_id, + requires_listing_snapshot, + requires_trade_chain, + is_request, + ) in cases + { + assert_eq!(message_type.kind(), kind); + assert_eq!(message_type.is_service(), service); + assert_eq!(message_type.is_public(), public); + assert_eq!(message_type.requires_order_id(), requires_order_id); + assert_eq!( + message_type.requires_listing_snapshot(), + requires_listing_snapshot + ); + assert_eq!(message_type.requires_trade_chain(), requires_trade_chain); + assert_eq!(message_type.is_request(), is_request); + assert_eq!(message_type.is_result(), !is_request); + } + + assert_eq!( + RadrootsTradeMessageType::from_kind(KIND_TRADE_LISTING_VALIDATE_REQ), + Some(RadrootsTradeMessageType::ListingValidateRequest) + ); + assert_eq!( + RadrootsTradeMessageType::from_kind(KIND_TRADE_LISTING_VALIDATE_RES), + Some(RadrootsTradeMessageType::ListingValidateResult) + ); + assert_eq!( + RadrootsTradeMessageType::from_kind(KIND_TRADE_ORDER_REQUEST), + Some(RadrootsTradeMessageType::OrderRequest) + ); + assert_eq!( + RadrootsTradeMessageType::from_kind(KIND_TRADE_ORDER_RESPONSE), + Some(RadrootsTradeMessageType::OrderResponse) + ); + assert_eq!( + RadrootsTradeMessageType::from_kind(KIND_TRADE_ORDER_REVISION), + Some(RadrootsTradeMessageType::OrderRevision) + ); + assert_eq!( + RadrootsTradeMessageType::from_kind(KIND_TRADE_ORDER_REVISION_RESPONSE), + None + ); + assert_eq!( + RadrootsTradeMessageType::from_kind(KIND_TRADE_QUESTION), + Some(RadrootsTradeMessageType::Question) + ); + assert_eq!( + RadrootsTradeMessageType::from_kind(KIND_TRADE_ANSWER), + Some(RadrootsTradeMessageType::Answer) + ); + assert_eq!( + RadrootsTradeMessageType::from_kind(KIND_TRADE_DISCOUNT_REQUEST), + Some(RadrootsTradeMessageType::DiscountRequest) + ); + assert_eq!( + RadrootsTradeMessageType::from_kind(KIND_TRADE_DISCOUNT_OFFER), + Some(RadrootsTradeMessageType::DiscountOffer) + ); + assert_eq!( + RadrootsTradeMessageType::from_kind(KIND_TRADE_DISCOUNT_ACCEPT), + Some(RadrootsTradeMessageType::DiscountAccept) + ); + assert_eq!( + RadrootsTradeMessageType::from_kind(KIND_TRADE_DISCOUNT_DECLINE), + Some(RadrootsTradeMessageType::DiscountDecline) + ); + assert_eq!( + RadrootsTradeMessageType::from_kind(KIND_TRADE_CANCEL), + Some(RadrootsTradeMessageType::Cancel) + ); + assert_eq!( + RadrootsTradeMessageType::from_kind(KIND_TRADE_FULFILLMENT_UPDATE), + Some(RadrootsTradeMessageType::FulfillmentUpdate) + ); + assert_eq!( + RadrootsTradeMessageType::from_kind(KIND_TRADE_RECEIPT), + Some(RadrootsTradeMessageType::Receipt) + ); + assert_eq!(RadrootsTradeMessageType::from_kind(KIND_PROFILE), None); + } + + #[test] fn envelope_requires_order_id_for_order_scoped_messages() { let envelope = RadrootsTradeEnvelope::new( RadrootsTradeMessageType::OrderRequest, - "30402:pubkey:AAAAAAAAAAAAAAAAAAAAAg", + sample_listing_addr(), None, - RadrootsTradeMessagePayload::OrderRequest(RadrootsTradeOrder { - order_id: "order-1".into(), - listing_addr: "30402:pubkey:AAAAAAAAAAAAAAAAAAAAAg".into(), - buyer_pubkey: "buyer".into(), - seller_pubkey: "seller".into(), - items: vec![], - discounts: None, - }), + RadrootsTradeMessagePayload::OrderRequest(sample_order()), ); assert_eq!( envelope.validate().unwrap_err(), RadrootsTradeEnvelopeError::MissingOrderId ); } + + #[test] + fn envelope_validation_covers_success_and_error_paths() { + let service_envelope = RadrootsTradeEnvelope::new( + RadrootsTradeMessageType::ListingValidateRequest, + sample_listing_addr(), + None, + RadrootsTradeMessagePayload::ListingValidateRequest(sample_validate_request()), + ); + assert_eq!(service_envelope.validate(), Ok(())); + + let public_envelope = RadrootsTradeEnvelope::new( + RadrootsTradeMessageType::OrderRequest, + sample_listing_addr(), + Some("order-1".into()), + RadrootsTradeMessagePayload::OrderRequest(sample_order()), + ); + assert_eq!(public_envelope.validate(), Ok(())); + + let invalid_version = RadrootsTradeEnvelope { + version: RADROOTS_TRADE_ENVELOPE_VERSION + 1, + domain: RadrootsTradeDomain::TradeListing, + message_type: RadrootsTradeMessageType::OrderRequest, + order_id: Some("order-1".into()), + listing_addr: sample_listing_addr(), + payload: RadrootsTradeMessagePayload::OrderRequest(sample_order()), + }; + assert_eq!( + invalid_version.validate().unwrap_err(), + RadrootsTradeEnvelopeError::InvalidVersion { + expected: RADROOTS_TRADE_ENVELOPE_VERSION, + got: RADROOTS_TRADE_ENVELOPE_VERSION + 1, + } + ); + + let missing_listing_addr = RadrootsTradeEnvelope::new( + RadrootsTradeMessageType::ListingValidateRequest, + " ", + None, + RadrootsTradeMessagePayload::ListingValidateRequest(sample_validate_request()), + ); + assert_eq!( + missing_listing_addr.validate().unwrap_err(), + RadrootsTradeEnvelopeError::MissingListingAddr + ); + + let blank_order_id = RadrootsTradeEnvelope::new( + RadrootsTradeMessageType::OrderResponse, + sample_listing_addr(), + Some(" ".into()), + RadrootsTradeMessagePayload::OrderResponse(sample_order_response(true)), + ); + assert_eq!( + blank_order_id.validate().unwrap_err(), + RadrootsTradeEnvelopeError::MissingOrderId + ); + } + + #[test] + fn envelope_error_display_variants() { + assert_eq!( + (RadrootsTradeEnvelopeError::InvalidVersion { + expected: 1, + got: 2, + }) + .to_string(), + "invalid envelope version: expected 1, got 2" + ); + assert_eq!( + RadrootsTradeEnvelopeError::MissingOrderId.to_string(), + "missing order_id for order-scoped message" + ); + assert_eq!( + RadrootsTradeEnvelopeError::MissingListingAddr.to_string(), + "missing listing_addr" + ); + } + + #[test] + fn payload_message_type_covers_all_variants() { + let payloads = [ + ( + RadrootsTradeMessagePayload::ListingValidateRequest(sample_validate_request()), + RadrootsTradeMessageType::ListingValidateRequest, + ), + ( + RadrootsTradeMessagePayload::ListingValidateResult(sample_validate_result()), + RadrootsTradeMessageType::ListingValidateResult, + ), + ( + RadrootsTradeMessagePayload::OrderRequest(sample_order()), + RadrootsTradeMessageType::OrderRequest, + ), + ( + RadrootsTradeMessagePayload::OrderResponse(sample_order_response(false)), + RadrootsTradeMessageType::OrderResponse, + ), + ( + RadrootsTradeMessagePayload::OrderRevision(sample_order_revision()), + RadrootsTradeMessageType::OrderRevision, + ), + ( + RadrootsTradeMessagePayload::OrderRevisionAccept(sample_order_revision_response( + true, + )), + RadrootsTradeMessageType::OrderRevisionAccept, + ), + ( + RadrootsTradeMessagePayload::OrderRevisionDecline(sample_order_revision_response( + false, + )), + RadrootsTradeMessageType::OrderRevisionDecline, + ), + ( + RadrootsTradeMessagePayload::Question(RadrootsTradeQuestion { + question_id: "question-1".into(), + }), + RadrootsTradeMessageType::Question, + ), + ( + RadrootsTradeMessagePayload::Answer(RadrootsTradeAnswer { + question_id: "question-1".into(), + }), + RadrootsTradeMessageType::Answer, + ), + ( + RadrootsTradeMessagePayload::DiscountRequest(RadrootsTradeDiscountRequest { + discount_id: "discount-1".into(), + value: sample_discount_value(), + }), + RadrootsTradeMessageType::DiscountRequest, + ), + ( + RadrootsTradeMessagePayload::DiscountOffer(RadrootsTradeDiscountOffer { + discount_id: "discount-1".into(), + value: sample_percent_discount(), + }), + RadrootsTradeMessageType::DiscountOffer, + ), + ( + RadrootsTradeMessagePayload::DiscountAccept( + RadrootsTradeDiscountDecision::Accept { + value: sample_discount_value(), + }, + ), + RadrootsTradeMessageType::DiscountAccept, + ), + ( + RadrootsTradeMessagePayload::DiscountDecline( + RadrootsTradeDiscountDecision::Decline { + reason: Some("no thanks".into()), + }, + ), + RadrootsTradeMessageType::DiscountDecline, + ), + ( + RadrootsTradeMessagePayload::Cancel(RadrootsTradeListingCancel { + reason: Some("out of stock".into()), + }), + RadrootsTradeMessageType::Cancel, + ), + ( + RadrootsTradeMessagePayload::FulfillmentUpdate(RadrootsTradeFulfillmentUpdate { + status: RadrootsTradeFulfillmentStatus::Delivered, + }), + RadrootsTradeMessageType::FulfillmentUpdate, + ), + ( + RadrootsTradeMessagePayload::Receipt(RadrootsTradeReceipt { + acknowledged: true, + at: 42, + }), + RadrootsTradeMessageType::Receipt, + ), + ]; + + for (payload, message_type) in payloads { + assert_eq!(payload.message_type(), message_type); + } + } }