lib

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

commit 1b0f81a85d5b5b6dbf766202bf5fd1170aa14a9a
parent 8b05915301888a21127f32e4155db91691e33df5
Author: triesap <tyson@radroots.org>
Date:   Fri, 10 Apr 2026 05:08:33 +0000

trade: cover overlay and helper gaps

Diffstat:
Mcrates/trade/src/listing/overlay.rs | 114++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/trade/src/listing/projection.rs | 224++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 329 insertions(+), 9 deletions(-)

diff --git a/crates/trade/src/listing/overlay.rs b/crates/trade/src/listing/overlay.rs @@ -508,7 +508,7 @@ mod tests { RadrootsTradeReviewPriority, RadrootsTradeReviewQueueEntry, RadrootsTradeReviewStatus, }; use crate::listing::{ - dvm::{TradeListingMessagePayload, TradeOrderResponse}, + dvm::{TradeListingCancel, TradeListingMessagePayload, TradeOrderResponse}, projection::RadrootsTradeOrderWorkflowMessage, }; use crate::listing::{ @@ -830,6 +830,118 @@ mod tests { } #[test] + fn overlay_helpers_and_store_accessors_cover_flags_and_errors() { + let review_entry = RadrootsTradeReviewQueueEntry { + queue: "queue".into(), + priority: RadrootsTradeReviewPriority::Normal, + status: RadrootsTradeReviewStatus::Resolved, + assigned_operator: None, + reason: None, + }; + assert!(!review_entry.requires_review()); + + let listing_overlay = RadrootsTradeListingBackofficeOverlay { + listing_addr: "listing-1".into(), + review_queue: Some(review_entry), + moderation_flags: vec![ + RadrootsTradeModerationFlag { + code: "resolved".into(), + severity: RadrootsTradeModerationSeverity::Notice, + status: RadrootsTradeModerationStatus::Resolved, + source: None, + reason: None, + }, + RadrootsTradeModerationFlag { + code: "open".into(), + severity: RadrootsTradeModerationSeverity::Warning, + status: RadrootsTradeModerationStatus::Open, + source: None, + reason: None, + }, + ], + }; + assert!(!listing_overlay.requires_review()); + assert_eq!(listing_overlay.open_moderation_flag_count(), 1); + assert!(listing_overlay.has_open_moderation_flags()); + + let order_overlay = RadrootsTradeOrderBackofficeOverlay { + order_id: "order-1".into(), + review_queue: Some(RadrootsTradeReviewQueueEntry { + queue: "queue".into(), + priority: RadrootsTradeReviewPriority::Low, + status: RadrootsTradeReviewStatus::Resolved, + assigned_operator: None, + reason: None, + }), + moderation_flags: vec![RadrootsTradeModerationFlag { + code: "resolved".into(), + severity: RadrootsTradeModerationSeverity::Notice, + status: RadrootsTradeModerationStatus::Resolved, + source: None, + reason: None, + }], + fulfillment_exceptions: vec![ + RadrootsTradeFulfillmentException { + code: "resolved".into(), + severity: RadrootsTradeFulfillmentExceptionSeverity::Notice, + status: RadrootsTradeFulfillmentExceptionStatus::Resolved, + source: None, + notes: None, + }, + RadrootsTradeFulfillmentException { + code: "open".into(), + severity: RadrootsTradeFulfillmentExceptionSeverity::Blocking, + status: RadrootsTradeFulfillmentExceptionStatus::Open, + source: None, + notes: None, + }, + ], + }; + assert!(!order_overlay.requires_review()); + assert_eq!(order_overlay.open_moderation_flag_count(), 0); + assert!(!order_overlay.has_open_moderation_flags()); + assert_eq!(order_overlay.open_fulfillment_exception_count(), 1); + assert!(order_overlay.has_open_fulfillment_exceptions()); + + let mut store = RadrootsTradeBackofficeOverlayStore::new(); + assert!(store.listing_overlays().is_empty()); + assert!(store.order_overlays().is_empty()); + store + .upsert_listing_overlay(listing_overlay) + .expect("listing overlay"); + store + .upsert_order_overlay(order_overlay) + .expect("order overlay"); + assert_eq!(store.listing_overlays().len(), 1); + assert_eq!(store.order_overlays().len(), 1); + assert!(store.listing_overlay("listing-1").is_some()); + assert!(store.order_overlay("order-1").is_some()); + + let missing_listing = RadrootsTradeBackofficeOverlayError::MissingListingAddr; + let missing_order = RadrootsTradeBackofficeOverlayError::MissingOrderId; + assert_eq!(missing_listing.to_string(), "missing listing address"); + assert_eq!(missing_order.to_string(), "missing order id"); + assert!(std::error::Error::source(&missing_listing).is_none()); + } + + #[test] + fn message_helper_bootstraps_missing_chain_for_non_request_payload() { + let message = message( + "seller-pubkey", + "30402:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAg", + Some("orphan-order"), + TradeListingMessagePayload::Cancel(TradeListingCancel { + reason: Some("operator-cancelled".into()), + }), + ); + + assert_eq!(message.order_id.as_deref(), Some("orphan-order")); + assert_eq!(message.counterparty_pubkey, "buyer-pubkey"); + assert_eq!(message.root_event_id.as_deref(), Some("orphan-order:root")); + assert_eq!(message.prev_event_id.as_deref(), Some("orphan-order:root")); + } + + #[test] fn listing_backoffice_views_merge_overlay_without_mutating_canonical_projection() { let mut index = RadrootsTradeReadIndex::new(); index diff --git a/crates/trade/src/listing/projection.rs b/crates/trade/src/listing/projection.rs @@ -1779,17 +1779,19 @@ mod tests { use std::cell::RefCell; use super::{ - RadrootsTradeListingMarketStatus, RadrootsTradeListingQuery, RadrootsTradeListingSort, - RadrootsTradeListingSortField, RadrootsTradeOrderQuery, RadrootsTradeOrderSort, - RadrootsTradeOrderSortField, RadrootsTradeOrderWorkflowMessage, - RadrootsTradeProjectionError, RadrootsTradeReadIndex, RadrootsTradeSortDirection, - radroots_trade_order_status_can_transition, radroots_trade_order_status_is_terminal, + RadrootsTradeListingMarketStatus, RadrootsTradeListingProjection, + RadrootsTradeListingQuery, RadrootsTradeListingSort, RadrootsTradeListingSortField, + RadrootsTradeOrderQuery, RadrootsTradeOrderSort, RadrootsTradeOrderSortField, + RadrootsTradeOrderWorkflowMessage, RadrootsTradeProjectionError, RadrootsTradeReadIndex, + RadrootsTradeSortDirection, radroots_trade_order_status_can_transition, + radroots_trade_order_status_is_terminal, }; use crate::listing::{ - codec::listing_tags_build, + codec::{TradeListingParseError, listing_tags_build}, dvm::{ - TradeListingCancel, TradeListingEnvelopeParseError, TradeListingMessagePayload, - TradeOrderResponse, trade_listing_envelope_event_build, + TradeListingAddressError, TradeListingCancel, TradeListingEnvelopeParseError, + TradeListingMessagePayload, TradeListingMessageType, TradeOrderResponse, + trade_listing_envelope_event_build, }, order::{ TradeAnswer, TradeDiscountDecision, TradeDiscountOffer, TradeDiscountRequest, @@ -2180,6 +2182,212 @@ mod tests { } #[test] + fn projection_defaults_and_helper_errors_cover_paths() { + let listing_sort = RadrootsTradeListingSort::default(); + assert_eq!( + listing_sort.field, + RadrootsTradeListingSortField::ListingAddr + ); + assert_eq!(listing_sort.direction, RadrootsTradeSortDirection::Asc); + + let order_sort = RadrootsTradeOrderSort::default(); + assert_eq!(order_sort.field, RadrootsTradeOrderSortField::OrderId); + assert_eq!(order_sort.direction, RadrootsTradeSortDirection::Asc); + + let mut listing = base_listing(); + listing.availability = Some(RadrootsListingAvailability::Status { + status: RadrootsListingStatus::Other { + value: "archived".into(), + }, + }); + let projection = + RadrootsTradeListingProjection::from_listing_contract("seller-pubkey", &listing) + .expect("listing projection"); + assert_eq!( + projection.market_status(), + RadrootsTradeListingMarketStatus::Other { + value: "archived".into(), + } + ); + + let mut index = RadrootsTradeReadIndex::new(); + assert!(index.listings().is_empty()); + assert!(index.orders().is_empty()); + index + .upsert_listing("seller-pubkey", &base_listing()) + .expect("listing"); + index + .apply_workflow_message(&message( + "buyer-pubkey", + "30402:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAg", + Some("order-1"), + TradeListingMessagePayload::OrderRequest(base_order()), + )) + .expect("order request"); + assert_eq!(index.listings().len(), 1); + assert_eq!(index.orders().len(), 1); + + let cases = [ + ( + RadrootsTradeProjectionError::InvalidListingKind { kind: 7 }, + "invalid listing event kind: 7", + false, + ), + ( + RadrootsTradeProjectionError::InvalidListingContract { + error: TradeListingParseError::InvalidTag("d".into()), + }, + "invalid listing contract event: invalid tag: d", + true, + ), + ( + RadrootsTradeProjectionError::MissingPrimaryBin("bin-9".into()), + "missing primary bin: bin-9", + false, + ), + ( + RadrootsTradeProjectionError::MissingOrderId, + "missing order id", + false, + ), + ( + RadrootsTradeProjectionError::OrderIdMismatch, + "order id mismatch", + false, + ), + ( + RadrootsTradeProjectionError::ListingAddrMismatch, + "listing address mismatch", + false, + ), + ( + RadrootsTradeProjectionError::MissingOrder("order-9".into()), + "missing order projection: order-9", + false, + ), + ( + RadrootsTradeProjectionError::InvalidTransition { + from: TradeOrderStatus::Draft, + to: TradeOrderStatus::Accepted, + }, + "invalid order transition: Draft -> Accepted", + false, + ), + ( + RadrootsTradeProjectionError::InvalidItemIndex(3), + "invalid order item index: 3", + false, + ), + ( + RadrootsTradeProjectionError::InvalidDiscountDecision, + "invalid discount decision payload", + false, + ), + ( + RadrootsTradeProjectionError::InvalidRevisionResponse, + "invalid order revision response payload", + false, + ), + ( + RadrootsTradeProjectionError::NonOrderWorkflowMessage( + TradeListingMessageType::ListingValidateRequest, + ), + "non-order workflow message: ListingValidateRequest", + false, + ), + ( + RadrootsTradeProjectionError::UnauthorizedActor, + "unauthorized actor", + false, + ), + ( + RadrootsTradeProjectionError::CounterpartyMismatch, + "counterparty pubkey mismatch", + false, + ), + ( + RadrootsTradeProjectionError::MissingListingSnapshot, + "missing listing snapshot", + false, + ), + ( + RadrootsTradeProjectionError::MissingTradeRootEventId, + "missing trade root event id", + false, + ), + ( + RadrootsTradeProjectionError::MissingTradePrevEventId, + "missing trade previous event id", + false, + ), + ( + RadrootsTradeProjectionError::TradeThreadRootMismatch, + "trade thread root mismatch", + false, + ), + ( + RadrootsTradeProjectionError::TradeThreadPrevMismatch, + "trade thread previous event mismatch", + false, + ), + ( + RadrootsTradeProjectionError::InvalidWorkflowEvent { + error: TradeListingEnvelopeParseError::InvalidListingAddr( + TradeListingAddressError::InvalidFormat, + ), + }, + "invalid listing address format", + true, + ), + ]; + for (error, expected, has_source) in cases { + assert_eq!(error.to_string(), expected); + assert_eq!(std::error::Error::source(&error).is_some(), has_source); + } + } + + #[test] + fn listing_projection_from_event_rejects_invalid_kind_and_invalid_contract() { + let mut invalid_kind = listing_event("seller-pubkey", &base_listing()); + invalid_kind.kind = 7; + assert!(matches!( + RadrootsTradeListingProjection::from_listing_event(&invalid_kind), + Err(RadrootsTradeProjectionError::InvalidListingKind { kind: 7 }) + )); + + let invalid_contract = RadrootsNostrEvent { + id: "bad-listing".into(), + author: "seller-pubkey".into(), + created_at: 1_700_000_000, + kind: KIND_LISTING, + tags: vec![], + content: "{}".into(), + sig: "sig".into(), + }; + assert!(matches!( + RadrootsTradeListingProjection::from_listing_event(&invalid_contract), + Err(RadrootsTradeProjectionError::InvalidListingContract { .. }) + )); + } + + #[test] + fn message_helper_bootstraps_missing_chain_for_non_request_payload() { + let message = message( + "seller-pubkey", + "30402:seller-pubkey:AAAAAAAAAAAAAAAAAAAAAg", + Some("orphan-order"), + TradeListingMessagePayload::Cancel(TradeListingCancel { + reason: Some("cancelled".into()), + }), + ); + + assert_eq!(message.order_id.as_deref(), Some("orphan-order")); + assert_eq!(message.counterparty_pubkey, "buyer-pubkey"); + assert_eq!(message.root_event_id.as_deref(), Some("orphan-order:root")); + assert_eq!(message.prev_event_id.as_deref(), Some("orphan-order:root")); + } + + #[test] fn listing_projection_builds_query_friendly_view() { let mut index = RadrootsTradeReadIndex::new(); let listing = base_listing();