rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

commit a57b273014161eed1b82747e08bed5f0a97bec66
parent 7af45e4af4bf0036738e64a40850ac6e026e1faa
Author: triesap <tyson@radroots.org>
Date:   Tue,  3 Mar 2026 20:05:01 +0000

tests: close dvm handler coverage gaps

Diffstat:
Msrc/features/trade_listing/handlers/dvm.rs | 2525++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 2498 insertions(+), 27 deletions(-)

diff --git a/src/features/trade_listing/handlers/dvm.rs b/src/features/trade_listing/handlers/dvm.rs @@ -5,10 +5,10 @@ use std::{sync::Arc, time::Duration}; use radroots_events::kinds::KIND_FARM; use radroots_events::listing::RadrootsListingFarmRef; use radroots_nostr::prelude::{ - RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrFilter, RadrootsNostrKeys, - RadrootsNostrKind, RadrootsNostrTag, radroots_event_from_nostr, radroots_nostr_build_event, - radroots_nostr_build_event_job_feedback, radroots_nostr_fetch_event_by_id, - radroots_nostr_parse_pubkey, radroots_nostr_send_event, + RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrFilter, + RadrootsNostrKind, RadrootsNostrKeys, RadrootsNostrTag, radroots_event_from_nostr, + radroots_nostr_build_event, radroots_nostr_build_event_job_feedback, + radroots_nostr_fetch_event_by_id, radroots_nostr_parse_pubkey, radroots_nostr_send_event, }; use radroots_trade::listing::{ dvm::{ @@ -61,6 +61,126 @@ pub enum TradeListingDvmError { ListingNotValidated, } +#[cfg(test)] +#[derive(Default)] +struct DvmTestHooks { + fetch_event_by_id_results: + std::collections::VecDeque<Result<RadrootsNostrEvent, TradeListingDvmError>>, + fetch_events_results: + std::collections::VecDeque<Result<Vec<RadrootsNostrEvent>, TradeListingDvmError>>, + send_event_results: std::collections::VecDeque<Result<(), TradeListingDvmError>>, + validate_listing_results: + std::collections::VecDeque<Result<RadrootsListingFarmRef, TradeListingValidationError>>, + farm_validation_results: + std::collections::VecDeque<Result<Vec<TradeListingValidationError>, TradeListingDvmError>>, +} + +#[cfg(test)] +static DVM_TEST_HOOKS: std::sync::OnceLock<std::sync::Mutex<DvmTestHooks>> = + std::sync::OnceLock::new(); + +#[cfg(test)] +fn dvm_test_hooks() -> &'static std::sync::Mutex<DvmTestHooks> { + DVM_TEST_HOOKS.get_or_init(|| std::sync::Mutex::new(DvmTestHooks::default())) +} + +#[cfg(test)] +fn pop_fetch_event_by_id_hook() -> Option<Result<RadrootsNostrEvent, TradeListingDvmError>> { + dvm_test_hooks() + .lock() + .expect("dvm test hooks lock") + .fetch_event_by_id_results + .pop_front() +} + +#[cfg(test)] +fn pop_fetch_events_hook() -> Option<Result<Vec<RadrootsNostrEvent>, TradeListingDvmError>> { + dvm_test_hooks() + .lock() + .expect("dvm test hooks lock") + .fetch_events_results + .pop_front() +} + +#[cfg(test)] +fn pop_send_event_hook() -> Option<Result<(), TradeListingDvmError>> { + dvm_test_hooks() + .lock() + .expect("dvm test hooks lock") + .send_event_results + .pop_front() +} + +#[cfg(test)] +fn pop_validate_listing_hook() -> Option<Result<RadrootsListingFarmRef, TradeListingValidationError>> { + dvm_test_hooks() + .lock() + .expect("dvm test hooks lock") + .validate_listing_results + .pop_front() +} + +#[cfg(test)] +fn pop_farm_validation_hook() -> Option<Result<Vec<TradeListingValidationError>, TradeListingDvmError>> { + dvm_test_hooks() + .lock() + .expect("dvm test hooks lock") + .farm_validation_results + .pop_front() +} + +async fn fetch_event_by_id_io( + client: &RadrootsNostrClient, + id: &str, +) -> Result<RadrootsNostrEvent, TradeListingDvmError> { + #[cfg(test)] + if let Some(result) = pop_fetch_event_by_id_hook() { + return Ok(result?); + } + let event = radroots_nostr_fetch_event_by_id(client, id).await?; + Ok(event) +} + +async fn fetch_events_io( + client: &RadrootsNostrClient, + filter: RadrootsNostrFilter, + timeout: Duration, +) -> Result<Vec<RadrootsNostrEvent>, TradeListingDvmError> { + #[cfg(test)] + if let Some(result) = pop_fetch_events_hook() { + return Ok(result?); + } + let events = client.fetch_events(filter, timeout).await?; + Ok(events) +} + +async fn send_event_io( + client: &RadrootsNostrClient, + builder: RadrootsNostrEventBuilder, +) -> Result<(), TradeListingDvmError> { + #[cfg(test)] + if let Some(result) = pop_send_event_hook() { + result?; + return Ok(()); + } + + let _ = radroots_nostr_send_event(client, builder).await?; + Ok(()) +} + +fn validate_listing_event_io( + event: &RadrootsNostrEvent, +) -> Result<RadrootsListingFarmRef, TradeListingValidationError> { + #[cfg(test)] + if let Some(result) = pop_validate_listing_hook() { + return Ok(result?); + } + let rr_event = radroots_event_from_nostr(event); + let listing = validate_listing_event(&rr_event)?; + let farm = listing.listing.farm; + Ok(farm) +} + pub async fn handle_event( event: RadrootsNostrEvent, tags: Vec<RadrootsNostrTag>, @@ -279,15 +399,15 @@ async fn handle_listing_validate_request( state: &Arc<tokio::sync::Mutex<TradeListingState>>, ) -> Result<(), TradeListingDvmError> { let listing_event = if let Some(ptr) = payload.listing_event { - match radroots_nostr_fetch_event_by_id(client, &ptr.id).await { + match fetch_event_by_id_io(client, &ptr.id).await { Ok(evt) => Some(evt), Err(err) => { let error = match err { - radroots_nostr::error::RadrootsNostrError::EventNotFound(_) => { - TradeListingValidationError::ListingEventNotFound { - listing_addr: listing_addr.to_string(), - } - } + TradeListingDvmError::Nostr( + radroots_nostr::error::RadrootsNostrError::EventNotFound(_), + ) => TradeListingValidationError::ListingEventNotFound { + listing_addr: listing_addr.to_string(), + }, _ => TradeListingValidationError::ListingEventFetchFailed { listing_addr: listing_addr.to_string(), }, @@ -310,10 +430,9 @@ async fn handle_listing_validate_request( }; let errors = if let Some(event) = listing_event { - let rr_event = radroots_event_from_nostr(&event); - match validate_listing_event(&rr_event) { - Ok(listing) => { - let errors = validate_farm_dependencies(client, &listing.listing.farm).await?; + match validate_listing_event_io(&event) { + Ok(farm) => { + let errors = validate_farm_dependencies(client, &farm).await?; if errors.is_empty() { let mut state = state.lock().await; state.mark_listing_validated(listing_addr); @@ -902,7 +1021,7 @@ async fn send_envelope<T: serde::Serialize + Clone>( envelope_event.content, envelope_event.tags, )?; - radroots_nostr_send_event(client, builder).await?; + send_event_io(client, builder).await?; Ok(()) } @@ -918,7 +1037,7 @@ async fn fetch_listing_by_addr( .kind(RadrootsNostrKind::Custom(addr.kind)) .author(author) .identifier(addr.listing_id); - let events = client.fetch_events(filter, Duration::from_secs(10)).await?; + let events = fetch_events_io(client, filter, Duration::from_secs(10)).await?; let mut latest: Option<RadrootsNostrEvent> = None; for ev in events { if ev.kind != RadrootsNostrKind::Custom(addr.kind) { @@ -937,7 +1056,7 @@ async fn fetch_latest_event_by_kind( filter: RadrootsNostrFilter, kind: RadrootsNostrKind, ) -> Result<Option<RadrootsNostrEvent>, TradeListingDvmError> { - let events = client.fetch_events(filter, Duration::from_secs(10)).await?; + let events = fetch_events_io(client, filter, Duration::from_secs(10)).await?; let mut latest: Option<RadrootsNostrEvent> = None; for ev in events { if ev.kind != kind { @@ -955,6 +1074,11 @@ async fn validate_farm_dependencies( client: &RadrootsNostrClient, farm: &RadrootsListingFarmRef, ) -> Result<Vec<TradeListingValidationError>, TradeListingDvmError> { + #[cfg(test)] + if let Some(result) = pop_farm_validation_hook() { + return result; + } + let mut errors = Vec::new(); let farm_pubkey = farm.pubkey.trim(); let farm_d_tag = farm.d_tag.trim(); @@ -1093,24 +1217,2371 @@ pub async fn handle_error( ) -> Result<(), TradeListingDvmError> { let builder = radroots_nostr_build_event_job_feedback(event, "error", Some(error.to_string()), None)?; - let _ = radroots_nostr_send_event(client, builder).await?; - Ok(()) + send_event_io(client, builder).await } #[cfg(test)] mod tests { - use super::ensure_transition; - use radroots_trade::listing::order::TradeOrderStatus; + use super::{ + DvmTestHooks, TradeListingDvmError, dvm_test_hooks, ensure_transition, fetch_events_io, + fetch_event_by_id_io, fetch_latest_event_by_kind, fetch_listing_by_addr, handle_answer, + handle_cancel, handle_discount_decision, handle_discount_offer, handle_discount_request, + handle_error, handle_event, + handle_fulfillment_update, handle_listing_validate_request, handle_order_request, + handle_order_response, handle_order_revision, handle_order_revision_response, + handle_question, handle_receipt, parse_payload, send_envelope, send_event_io, tag_has_value, + tag_value, validate_farm_dependencies, validate_listing_event_io, + }; + use crate::features::trade_listing::state::{TradeListingState, TradeOrderState}; + use radroots_core::{RadrootsCoreCurrency, RadrootsCoreDiscountValue, RadrootsCoreMoney}; + use radroots_events::RadrootsNostrEventPtr; + use radroots_events::listing::RadrootsListingFarmRef; + use radroots_nostr::error::RadrootsNostrError; + use radroots_nostr::prelude::{ + RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrFilter, + RadrootsNostrKind, RadrootsNostrKeys, RadrootsNostrTag, RadrootsNostrTagKind, + }; + use radroots_trade::listing::dvm::{ + TradeListingAddress, TradeListingCancel, TradeListingEnvelope, TradeListingMessageType, + TradeListingValidateRequest, TradeOrderResponse, TradeOrderRevisionResponse, + }; + use radroots_trade::listing::kinds::{ + KIND_TRADE_LISTING_ANSWER_RES, KIND_TRADE_LISTING_CANCEL_REQ, + KIND_TRADE_LISTING_DISCOUNT_ACCEPT_REQ, KIND_TRADE_LISTING_DISCOUNT_DECLINE_REQ, + KIND_TRADE_LISTING_DISCOUNT_OFFER_RES, KIND_TRADE_LISTING_DISCOUNT_REQ, + KIND_TRADE_LISTING_FULFILLMENT_UPDATE_REQ, KIND_TRADE_LISTING_ORDER_REQ, + KIND_TRADE_LISTING_ORDER_RES, KIND_TRADE_LISTING_ORDER_REVISION_REQ, + KIND_TRADE_LISTING_ORDER_REVISION_RES, KIND_TRADE_LISTING_QUESTION_REQ, + KIND_TRADE_LISTING_RECEIPT_REQ, KIND_TRADE_LISTING_VALIDATE_REQ, + KIND_TRADE_LISTING_VALIDATE_RES, + }; + use radroots_trade::listing::order::{ + TradeAnswer, TradeDiscountDecision, TradeDiscountOffer, TradeDiscountRequest, + TradeFulfillmentStatus, TradeFulfillmentUpdate, TradeOrder, TradeOrderStatus, + TradeOrderRevision, TradeQuestion, TradeReceipt, + }; + use radroots_trade::listing::validation::TradeListingValidationError; + use serde_json::json; + use std::collections::HashSet; + use std::sync::{Arc, Mutex, MutexGuard}; + use tokio::sync::Mutex as AsyncMutex; + + static TEST_LOCK: Mutex<()> = Mutex::new(()); + + fn test_guard() -> MutexGuard<'static, ()> { + let guard = TEST_LOCK.lock().unwrap_or_else(|err| err.into_inner()); + *dvm_test_hooks() + .lock() + .unwrap_or_else(|err| err.into_inner()) = DvmTestHooks::default(); + guard + } + + fn push_send_ok() { + dvm_test_hooks() + .lock() + .expect("hooks") + .send_event_results + .push_back(Ok(())); + } + + fn push_fetch_events_ok(events: Vec<RadrootsNostrEvent>) { + dvm_test_hooks() + .lock() + .expect("hooks") + .fetch_events_results + .push_back(Ok(events)); + } + + fn push_fetch_event_by_id_error_not_found() { + dvm_test_hooks() + .lock() + .expect("hooks") + .fetch_event_by_id_results + .push_back(Err(TradeListingDvmError::Nostr( + RadrootsNostrError::EventNotFound("missing".to_string()), + ))); + } + + fn push_validate_listing_ok(farm: RadrootsListingFarmRef) { + dvm_test_hooks() + .lock() + .expect("hooks") + .validate_listing_results + .push_back(Ok(farm)); + } + + fn push_farm_validation_result(result: Result<Vec<TradeListingValidationError>, TradeListingDvmError>) { + dvm_test_hooks() + .lock() + .expect("hooks") + .farm_validation_results + .push_back(result); + } + + fn make_keys() -> (RadrootsNostrKeys, RadrootsNostrKeys, RadrootsNostrKeys) { + ( + RadrootsNostrKeys::generate(), + RadrootsNostrKeys::generate(), + RadrootsNostrKeys::generate(), + ) + } + + fn listing_addr_for_seller(seller: &RadrootsNostrKeys) -> String { + format!("30402:{}:AAAAAAAAAAAAAAAAAAAAAA", seller.public_key().to_hex()) + } + + fn make_client(keys: &RadrootsNostrKeys) -> RadrootsNostrClient { + RadrootsNostrClient::new(keys.clone()) + } + + fn make_order( + order_id: &str, + listing_addr: &str, + buyer: &str, + seller: &str, + status: TradeOrderStatus, + ) -> TradeOrder { + TradeOrder { + order_id: order_id.to_string(), + listing_addr: listing_addr.to_string(), + buyer_pubkey: buyer.to_string(), + seller_pubkey: seller.to_string(), + items: Vec::new(), + discounts: None, + notes: None, + status, + } + } + + fn make_order_state( + order_id: &str, + listing_addr: &str, + buyer: &str, + seller: &str, + status: TradeOrderStatus, + ) -> TradeOrderState { + TradeOrderState { + order_id: order_id.to_string(), + listing_addr: listing_addr.to_string(), + buyer_pubkey: buyer.to_string(), + seller_pubkey: seller.to_string(), + status, + seen_event_ids: HashSet::new(), + } + } + + async fn state_with_order( + listing_addr: &str, + order_id: &str, + buyer: &str, + seller: &str, + status: TradeOrderStatus, + ) -> Arc<AsyncMutex<TradeListingState>> { + let state = Arc::new(AsyncMutex::new(TradeListingState::default())); + let mut locked = state.lock().await; + locked.mark_listing_validated(listing_addr); + locked.insert_order(make_order_state(order_id, listing_addr, buyer, seller, status)); + drop(locked); + state + } + + async fn set_order_status( + state: &Arc<AsyncMutex<TradeListingState>>, + order_id: &str, + status: TradeOrderStatus, + ) { + let mut locked = state.lock().await; + let order = locked.get_order_mut(order_id).expect("order"); + order.status = status; + order.seen_event_ids.clear(); + } + + async fn mark_event_seen( + state: &Arc<AsyncMutex<TradeListingState>>, + order_id: &str, + event_id: String, + ) { + let mut locked = state.lock().await; + let order = locked.get_order_mut(order_id).expect("order"); + order.seen_event_ids.insert(event_id); + } + + fn make_custom_tags(recipient: &str, listing_addr: &str, order_id: Option<&str>) -> Vec<RadrootsNostrTag> { + let mut tags = vec![ + RadrootsNostrTag::custom(RadrootsNostrTagKind::custom("p"), vec![recipient.to_string()]), + RadrootsNostrTag::custom(RadrootsNostrTagKind::custom("a"), vec![listing_addr.to_string()]), + ]; + if let Some(order_id) = order_id { + tags.push(RadrootsNostrTag::custom( + RadrootsNostrTagKind::custom("d"), + vec![order_id.to_string()], + )); + } + tags + } + + fn make_event( + sender: &RadrootsNostrKeys, + kind: RadrootsNostrKind, + content: String, + tags: Vec<RadrootsNostrTag>, + ) -> RadrootsNostrEvent { + RadrootsNostrEventBuilder::new(kind, content) + .tags(tags) + .sign_with_keys(sender) + .expect("event") + } + + fn make_envelope_content( + message_type: TradeListingMessageType, + listing_addr: &str, + order_id: Option<&str>, + payload: serde_json::Value, + ) -> String { + serde_json::to_string(&TradeListingEnvelope::new( + message_type, + listing_addr.to_string(), + order_id.map(|v| v.to_string()), + payload, + )) + .expect("envelope") + } + + fn sample_discount_value() -> RadrootsCoreDiscountValue { + RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::from_minor_units_u32( + 100, + RadrootsCoreCurrency::USD, + )) + } #[test] - fn transition_rejects_accept_after_decline() { - let err = ensure_transition(TradeOrderStatus::Declined, TradeOrderStatus::Accepted); - assert!(err.is_err()); + fn transition_matrix_and_tag_helpers_are_covered() { + let _guard = test_guard(); + + assert!(ensure_transition(TradeOrderStatus::Requested, TradeOrderStatus::Revised).is_ok()); + assert!(ensure_transition(TradeOrderStatus::Declined, TradeOrderStatus::Accepted).is_err()); + assert!(ensure_transition(TradeOrderStatus::Fulfilled, TradeOrderStatus::Completed).is_ok()); + assert!(ensure_transition(TradeOrderStatus::Completed, TradeOrderStatus::Requested).is_err()); + assert!(ensure_transition(TradeOrderStatus::Draft, TradeOrderStatus::Draft).is_ok()); + + let tags = vec![ + vec!["p".to_string(), "pk".to_string()], + vec!["a".to_string(), "addr".to_string()], + ]; + assert_eq!(tag_value(&tags, "a"), Some("addr".to_string())); + assert_eq!(tag_value(&tags, "x"), None); + assert!(tag_has_value(&tags, "p", "pk")); + assert!(!tag_has_value(&tags, "p", "miss")); + + let parsed: Result<TradeOrderResponse, _> = parse_payload(json!({"accepted":true,"reason":null})); + assert!(parsed.is_ok()); + let invalid: Result<TradeOrderResponse, _> = parse_payload(json!({"accepted":"true"})); + assert!(invalid.is_err()); } #[test] - fn transition_allows_revision_after_request() { - let ok = ensure_transition(TradeOrderStatus::Requested, TradeOrderStatus::Revised); - assert!(ok.is_ok()); + fn transition_matrix_covers_all_from_arms() { + let _guard = test_guard(); + + assert!(ensure_transition(TradeOrderStatus::Draft, TradeOrderStatus::Requested).is_ok()); + assert!(ensure_transition(TradeOrderStatus::Draft, TradeOrderStatus::Accepted).is_err()); + + assert!(ensure_transition(TradeOrderStatus::Validated, TradeOrderStatus::Requested).is_ok()); + assert!(ensure_transition(TradeOrderStatus::Validated, TradeOrderStatus::Accepted).is_err()); + + assert!(ensure_transition(TradeOrderStatus::Requested, TradeOrderStatus::Accepted).is_ok()); + assert!(ensure_transition(TradeOrderStatus::Requested, TradeOrderStatus::Fulfilled).is_err()); + + assert!(ensure_transition(TradeOrderStatus::Questioned, TradeOrderStatus::Requested).is_ok()); + assert!(ensure_transition(TradeOrderStatus::Questioned, TradeOrderStatus::Accepted).is_err()); + + assert!(ensure_transition(TradeOrderStatus::Revised, TradeOrderStatus::Declined).is_ok()); + assert!(ensure_transition(TradeOrderStatus::Revised, TradeOrderStatus::Fulfilled).is_err()); + + assert!(ensure_transition(TradeOrderStatus::Accepted, TradeOrderStatus::Fulfilled).is_ok()); + assert!(ensure_transition(TradeOrderStatus::Accepted, TradeOrderStatus::Requested).is_err()); + + assert!(ensure_transition(TradeOrderStatus::Declined, TradeOrderStatus::Accepted).is_err()); + assert!(ensure_transition(TradeOrderStatus::Cancelled, TradeOrderStatus::Requested).is_err()); + + assert!(ensure_transition(TradeOrderStatus::Fulfilled, TradeOrderStatus::Completed).is_ok()); + assert!(ensure_transition(TradeOrderStatus::Fulfilled, TradeOrderStatus::Accepted).is_err()); + + assert!(ensure_transition(TradeOrderStatus::Completed, TradeOrderStatus::Cancelled).is_err()); + } + + #[tokio::test] + async fn io_hooks_cover_fetch_send_and_validate_wrappers() { + let _guard = test_guard(); + let (rhi_keys, _, _) = make_keys(); + let client = make_client(&rhi_keys); + let listing_addr = listing_addr_for_seller(&rhi_keys); + let event = make_event( + &rhi_keys, + RadrootsNostrKind::Metadata, + "meta".to_string(), + vec![RadrootsNostrTag::custom( + RadrootsNostrTagKind::custom("t"), + vec!["radroots:type:farm".to_string()], + )], + ); + push_fetch_events_ok(vec![event.clone()]); + let fetched = fetch_events_io(&client, RadrootsNostrFilter::new(), std::time::Duration::from_secs(1)) + .await + .expect("fetch hook"); + assert_eq!(fetched.len(), 1); + + dvm_test_hooks() + .lock() + .expect("hooks") + .fetch_event_by_id_results + .push_back(Ok(event.clone())); + let by_id = super::fetch_event_by_id_io(&client, "id").await.expect("by id"); + assert_eq!(by_id.id, event.id); + + push_send_ok(); + let builder = radroots_nostr::prelude::radroots_nostr_build_event( + KIND_TRADE_LISTING_VALIDATE_RES as u32, + "x", + vec![vec!["p".to_string(), rhi_keys.public_key().to_hex()]], + ) + .expect("builder"); + assert!(send_event_io(&client, builder).await.is_ok()); + + let farm = RadrootsListingFarmRef { + pubkey: rhi_keys.public_key().to_hex(), + d_tag: "farmtag".to_string(), + }; + push_validate_listing_ok(farm.clone()); + let validated = validate_listing_event_io(&event).expect("validate hook"); + assert_eq!(validated.pubkey, farm.pubkey); + assert_eq!(listing_addr.contains(':',), true); + } + + #[tokio::test] + async fn farm_dependency_validation_paths_are_covered() { + let _guard = test_guard(); + let (rhi_keys, seller_keys, _) = make_keys(); + let client = make_client(&rhi_keys); + + let invalid_farm = RadrootsListingFarmRef { + pubkey: "bad".to_string(), + d_tag: "farmtag".to_string(), + }; + let errors = validate_farm_dependencies(&client, &invalid_farm) + .await + .expect("invalid farm result"); + assert!(errors.contains(&TradeListingValidationError::MissingFarmProfile)); + assert!(errors.contains(&TradeListingValidationError::MissingFarmRecord)); + + let farm = RadrootsListingFarmRef { + pubkey: seller_keys.public_key().to_hex(), + d_tag: "farmtag".to_string(), + }; + push_fetch_events_ok(Vec::new()); + push_fetch_events_ok(Vec::new()); + let missing = validate_farm_dependencies(&client, &farm) + .await + .expect("missing deps"); + assert!(missing.contains(&TradeListingValidationError::MissingFarmProfile)); + assert!(missing.contains(&TradeListingValidationError::MissingFarmRecord)); + + let profile_event = make_event( + &seller_keys, + RadrootsNostrKind::Metadata, + "profile".to_string(), + vec![RadrootsNostrTag::custom( + RadrootsNostrTagKind::custom("t"), + vec!["radroots:type:farm".to_string()], + )], + ); + let record_event = make_event( + &seller_keys, + RadrootsNostrKind::Custom(radroots_events::kinds::KIND_FARM as u16), + "record".to_string(), + Vec::new(), + ); + push_fetch_events_ok(vec![profile_event]); + push_fetch_events_ok(vec![record_event]); + let ok = validate_farm_dependencies(&client, &farm).await.expect("ok deps"); + assert!(ok.is_empty()); + } + + #[tokio::test] + async fn handle_listing_validate_request_paths_are_covered() { + let _guard = test_guard(); + let (rhi_keys, seller_keys, _) = make_keys(); + let client = make_client(&rhi_keys); + let listing_addr = listing_addr_for_seller(&seller_keys); + let state = Arc::new(AsyncMutex::new(TradeListingState::default())); + let event = make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_VALIDATE_REQ), + "content".to_string(), + Vec::new(), + ); + + push_fetch_event_by_id_error_not_found(); + push_send_ok(); + let payload = TradeListingValidateRequest { + listing_event: Some(RadrootsNostrEventPtr { + id: "missing".to_string(), + relays: None, + }), + }; + assert!(handle_listing_validate_request(&event, payload, &listing_addr, &client, &state) + .await + .is_ok()); + + push_fetch_events_ok(Vec::new()); + push_send_ok(); + let payload = TradeListingValidateRequest { listing_event: None }; + assert!(handle_listing_validate_request(&event, payload, &listing_addr, &client, &state) + .await + .is_ok()); + + dvm_test_hooks() + .lock() + .expect("hooks") + .fetch_event_by_id_results + .push_back(Ok(event.clone())); + push_validate_listing_ok(RadrootsListingFarmRef { + pubkey: seller_keys.public_key().to_hex(), + d_tag: "farmtag".to_string(), + }); + push_farm_validation_result(Ok(Vec::new())); + push_send_ok(); + let payload = TradeListingValidateRequest { + listing_event: Some(RadrootsNostrEventPtr { + id: event.id.to_hex(), + relays: None, + }), + }; + assert!(handle_listing_validate_request(&event, payload, &listing_addr, &client, &state) + .await + .is_ok()); + assert!(state.lock().await.is_listing_validated(&listing_addr)); + } + + #[tokio::test] + async fn handler_paths_cover_state_transitions() { + let _guard = test_guard(); + let (rhi_keys, seller_keys, buyer_keys) = make_keys(); + let client = make_client(&rhi_keys); + let listing_addr = listing_addr_for_seller(&seller_keys); + let listing_addr_parsed = TradeListingAddress::parse(&listing_addr).expect("addr"); + let order_id = "order-1"; + let seller_pub = seller_keys.public_key().to_hex(); + let buyer_pub = buyer_keys.public_key().to_hex(); + let state = Arc::new(AsyncMutex::new(TradeListingState::default())); + state.lock().await.mark_listing_validated(&listing_addr); + + let order_event = make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ORDER_REQ), + "order".to_string(), + Vec::new(), + ); + push_send_ok(); + let order_payload = make_order( + order_id, + &listing_addr, + &buyer_pub, + &seller_pub, + TradeOrderStatus::Requested, + ); + assert!(handle_order_request( + &order_event, + order_payload, + &listing_addr_parsed, + Some(order_id), + &client, + &state + ) + .await + .is_ok()); + + let response_event = make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ORDER_RES), + "resp".to_string(), + Vec::new(), + ); + push_send_ok(); + assert!(handle_order_response( + &response_event, + TradeOrderResponse { + accepted: true, + reason: None, + }, + &listing_addr_parsed, + Some(order_id), + &client, + &state + ) + .await + .is_ok()); + + state.lock().await.get_order_mut(order_id).expect("order").status = TradeOrderStatus::Requested; + let revision_event = make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ORDER_REVISION_REQ), + "rev".to_string(), + Vec::new(), + ); + push_send_ok(); + assert!(handle_order_revision( + &revision_event, + TradeOrderRevision { + revision_id: "r1".to_string(), + order_id: order_id.to_string(), + changes: Vec::new(), + reason: None, + }, + &listing_addr_parsed, + Some(order_id), + &client, + &state + ) + .await + .is_ok()); + + let revision_response_event = make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ORDER_REVISION_RES), + "revresp".to_string(), + Vec::new(), + ); + push_send_ok(); + assert!(handle_order_revision_response( + &revision_response_event, + TradeListingMessageType::OrderRevisionAccept, + TradeOrderRevisionResponse { + accepted: true, + reason: None, + }, + &listing_addr_parsed, + Some(order_id), + &client, + &state + ) + .await + .is_ok()); + + state.lock().await.get_order_mut(order_id).expect("order").status = TradeOrderStatus::Requested; + let question_event = make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_QUESTION_REQ), + "q".to_string(), + Vec::new(), + ); + push_send_ok(); + assert!(handle_question( + &question_event, + TradeQuestion { + question_id: "q1".to_string(), + order_id: Some(order_id.to_string()), + listing_addr: Some(listing_addr.clone()), + question_text: "what".to_string(), + }, + &listing_addr_parsed, + Some(order_id), + &client, + &state + ) + .await + .is_ok()); + + let answer_event = make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ANSWER_RES), + "a".to_string(), + Vec::new(), + ); + push_send_ok(); + assert!(handle_answer( + &answer_event, + TradeAnswer { + question_id: "q1".to_string(), + order_id: Some(order_id.to_string()), + listing_addr: Some(listing_addr.clone()), + answer_text: "ans".to_string(), + }, + &listing_addr_parsed, + Some(order_id), + &client, + &state + ) + .await + .is_ok()); + + let discount_request_event = make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_DISCOUNT_REQ), + "dr".to_string(), + Vec::new(), + ); + push_send_ok(); + assert!(handle_discount_request( + &discount_request_event, + TradeDiscountRequest { + discount_id: "d1".to_string(), + order_id: order_id.to_string(), + value: sample_discount_value(), + conditions: None, + }, + &listing_addr_parsed, + Some(order_id), + &client, + &state + ) + .await + .is_ok()); + + let discount_offer_event = make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_DISCOUNT_OFFER_RES), + "do".to_string(), + Vec::new(), + ); + push_send_ok(); + assert!(handle_discount_offer( + &discount_offer_event, + TradeDiscountOffer { + discount_id: "d1".to_string(), + order_id: order_id.to_string(), + value: sample_discount_value(), + conditions: None, + }, + &listing_addr_parsed, + Some(order_id), + &client, + &state + ) + .await + .is_ok()); + + let discount_accept_event = make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_DISCOUNT_ACCEPT_REQ), + "da".to_string(), + Vec::new(), + ); + push_send_ok(); + assert!(handle_discount_decision( + &discount_accept_event, + TradeListingMessageType::DiscountAccept, + TradeDiscountDecision::Accept { + value: sample_discount_value(), + }, + &listing_addr_parsed, + Some(order_id), + &client, + &state + ) + .await + .is_ok()); + + state.lock().await.get_order_mut(order_id).expect("order").status = TradeOrderStatus::Requested; + let cancel_event = make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_CANCEL_REQ), + "cancel".to_string(), + Vec::new(), + ); + push_send_ok(); + assert!(handle_cancel( + &cancel_event, + TradeListingCancel { reason: None }, + &listing_addr_parsed, + Some(order_id), + &client, + &state + ) + .await + .is_ok()); + + state.lock().await.get_order_mut(order_id).expect("order").status = TradeOrderStatus::Accepted; + let fulfill_event = make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_FULFILLMENT_UPDATE_REQ), + "fulfill".to_string(), + Vec::new(), + ); + push_send_ok(); + assert!(handle_fulfillment_update( + &fulfill_event, + TradeFulfillmentUpdate { + status: TradeFulfillmentStatus::Shipped, + tracking: None, + eta: None, + notes: None, + }, + &listing_addr_parsed, + Some(order_id), + &client, + &state + ) + .await + .is_ok()); + + let receipt_event = make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_RECEIPT_REQ), + "receipt".to_string(), + Vec::new(), + ); + push_send_ok(); + assert!(handle_receipt( + &receipt_event, + TradeReceipt { + acknowledged: true, + at: 1, + note: None, + }, + &listing_addr_parsed, + Some(order_id), + &client, + &state + ) + .await + .is_ok()); + } + + #[tokio::test] + async fn handle_event_covers_guard_and_dispatch_paths() { + let _guard = test_guard(); + let (rhi_keys, seller_keys, buyer_keys) = make_keys(); + let client = make_client(&rhi_keys); + let rhi_pub = rhi_keys.public_key().to_hex(); + let listing_addr = listing_addr_for_seller(&seller_keys); + let order_id = "order-1"; + let tags = make_custom_tags(&rhi_pub, &listing_addr, Some(order_id)); + let state = state_with_order( + &listing_addr, + order_id, + &buyer_keys.public_key().to_hex(), + &seller_keys.public_key().to_hex(), + TradeOrderStatus::Requested, + ) + .await; + + let unsupported = make_event( + &buyer_keys, + RadrootsNostrKind::TextNote, + "x".to_string(), + tags.clone(), + ); + assert!(matches!( + handle_event( + unsupported, + tags.clone(), + rhi_keys.clone(), + client.clone(), + state.clone() + ) + .await, + Err(TradeListingDvmError::UnsupportedKind) + )); + + let missing_recipient = make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ORDER_REQ), + make_envelope_content( + TradeListingMessageType::OrderRequest, + &listing_addr, + Some(order_id), + json!({}), + ), + Vec::new(), + ); + assert!(matches!( + handle_event( + missing_recipient, + Vec::new(), + rhi_keys.clone(), + client.clone(), + state.clone() + ) + .await, + Err(TradeListingDvmError::MissingRecipient) + )); + + let unsupported_custom = make_event( + &buyer_keys, + RadrootsNostrKind::Custom(1), + make_envelope_content( + TradeListingMessageType::OrderRequest, + &listing_addr, + Some(order_id), + json!({}), + ), + tags.clone(), + ); + assert!(matches!( + handle_event( + unsupported_custom, + tags.clone(), + rhi_keys.clone(), + client.clone(), + state.clone() + ) + .await, + Err(TradeListingDvmError::UnsupportedKind) + )); + + let self_event = make_event( + &rhi_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ORDER_REQ), + make_envelope_content( + TradeListingMessageType::OrderRequest, + &listing_addr, + Some(order_id), + json!({}), + ), + tags.clone(), + ); + assert!(handle_event( + self_event, + tags.clone(), + rhi_keys.clone(), + client.clone(), + state.clone(), + ) + .await + .is_ok()); + + let kind_mismatch = make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ORDER_REQ), + make_envelope_content( + TradeListingMessageType::Question, + &listing_addr, + Some(order_id), + json!({}), + ), + tags.clone(), + ); + assert!(matches!( + handle_event( + kind_mismatch, + tags.clone(), + rhi_keys.clone(), + client.clone(), + state.clone(), + ) + .await, + Err(TradeListingDvmError::TagMismatch("kind")) + )); + + let a_mismatch_event = make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ORDER_REQ), + make_envelope_content( + TradeListingMessageType::OrderRequest, + "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAA", + Some(order_id), + json!({}), + ), + tags.clone(), + ); + assert!(matches!( + handle_event( + a_mismatch_event, + tags.clone(), + rhi_keys.clone(), + client.clone(), + state.clone(), + ) + .await, + Err(TradeListingDvmError::TagMismatch("a")) + )); + + let d_mismatch_tags = make_custom_tags(&rhi_pub, &listing_addr, Some("other-order")); + let d_mismatch_event = make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ORDER_REQ), + make_envelope_content( + TradeListingMessageType::OrderRequest, + &listing_addr, + Some(order_id), + json!({}), + ), + d_mismatch_tags.clone(), + ); + assert!(matches!( + handle_event( + d_mismatch_event, + d_mismatch_tags, + rhi_keys.clone(), + client.clone(), + state.clone(), + ) + .await, + Err(TradeListingDvmError::TagMismatch("d")) + )); + + let bad_addr = format!("30403:{}:AAAAAAAAAAAAAAAAAAAAAA", seller_keys.public_key().to_hex()); + let bad_addr_tags = make_custom_tags(&rhi_pub, &bad_addr, Some(order_id)); + let bad_addr_event = make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ORDER_REQ), + make_envelope_content( + TradeListingMessageType::OrderRequest, + &bad_addr, + Some(order_id), + json!({}), + ), + bad_addr_tags.clone(), + ); + assert!(matches!( + handle_event( + bad_addr_event, + bad_addr_tags, + rhi_keys.clone(), + client.clone(), + state.clone(), + ) + .await, + Err(TradeListingDvmError::InvalidListingAddr) + )); + + let cases = vec![ + (TradeListingMessageType::ListingValidateRequest, KIND_TRADE_LISTING_VALIDATE_REQ), + (TradeListingMessageType::OrderRequest, KIND_TRADE_LISTING_ORDER_REQ), + (TradeListingMessageType::OrderResponse, KIND_TRADE_LISTING_ORDER_RES), + (TradeListingMessageType::OrderRevision, KIND_TRADE_LISTING_ORDER_REVISION_REQ), + ( + TradeListingMessageType::OrderRevisionAccept, + KIND_TRADE_LISTING_ORDER_REVISION_RES, + ), + ( + TradeListingMessageType::OrderRevisionDecline, + KIND_TRADE_LISTING_ORDER_REVISION_RES, + ), + (TradeListingMessageType::Question, KIND_TRADE_LISTING_QUESTION_REQ), + (TradeListingMessageType::Answer, KIND_TRADE_LISTING_ANSWER_RES), + ( + TradeListingMessageType::DiscountRequest, + KIND_TRADE_LISTING_DISCOUNT_REQ, + ), + ( + TradeListingMessageType::DiscountOffer, + KIND_TRADE_LISTING_DISCOUNT_OFFER_RES, + ), + ( + TradeListingMessageType::DiscountAccept, + KIND_TRADE_LISTING_DISCOUNT_ACCEPT_REQ, + ), + ( + TradeListingMessageType::DiscountDecline, + KIND_TRADE_LISTING_DISCOUNT_DECLINE_REQ, + ), + (TradeListingMessageType::Cancel, KIND_TRADE_LISTING_CANCEL_REQ), + ( + TradeListingMessageType::FulfillmentUpdate, + KIND_TRADE_LISTING_FULFILLMENT_UPDATE_REQ, + ), + (TradeListingMessageType::Receipt, KIND_TRADE_LISTING_RECEIPT_REQ), + ( + TradeListingMessageType::ListingValidateResult, + KIND_TRADE_LISTING_VALIDATE_RES, + ), + ]; + + for (message_type, kind) in cases { + if message_type == TradeListingMessageType::ListingValidateRequest { + push_fetch_events_ok(Vec::new()); + push_send_ok(); + } + if message_type == TradeListingMessageType::Cancel { + state.lock().await.get_order_mut(order_id).expect("order").status = TradeOrderStatus::Requested; + } + let payload = if message_type == TradeListingMessageType::ListingValidateResult { + json!({"valid": true, "errors": []}) + } else if message_type == TradeListingMessageType::ListingValidateRequest { + json!({"listing_event": null}) + } else { + json!({}) + }; + let content = make_envelope_content( + message_type, + &listing_addr, + if message_type.requires_order_id() { + Some(order_id) + } else { + None + }, + payload, + ); + let event = make_event( + &buyer_keys, + RadrootsNostrKind::Custom(kind), + content, + tags.clone(), + ); + let _ = handle_event( + event, + tags.clone(), + rhi_keys.clone(), + client.clone(), + state.clone(), + ) + .await; + } + } + + #[tokio::test] + async fn fetch_latest_send_envelope_and_handle_error_paths() { + let _guard = test_guard(); + let (rhi_keys, seller_keys, _) = make_keys(); + let client = make_client(&rhi_keys); + let older = make_event( + &seller_keys, + RadrootsNostrKind::Metadata, + "old".to_string(), + Vec::new(), + ); + let newer = make_event( + &seller_keys, + RadrootsNostrKind::Metadata, + "new".to_string(), + Vec::new(), + ); + push_fetch_events_ok(vec![older, newer.clone()]); + let latest = fetch_latest_event_by_kind( + &client, + RadrootsNostrFilter::new(), + RadrootsNostrKind::Metadata, + ) + .await + .expect("latest"); + assert!(latest.is_some()); + + push_send_ok(); + assert!(send_envelope( + &client, + seller_keys.public_key().to_hex(), + TradeListingMessageType::ListingValidateResult, + &listing_addr_for_seller(&seller_keys), + None, + &json!({"valid":true,"errors":[]}), + ) + .await + .is_ok()); + + let event = make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ORDER_REQ), + "x".to_string(), + Vec::new(), + ); + push_send_ok(); + assert!(handle_error(TradeListingDvmError::UnsupportedKind, &event, &client) + .await + .is_ok()); + } + + #[tokio::test] + async fn fetch_and_validation_guard_branches_are_covered() { + let _guard = test_guard(); + let (rhi_keys, seller_keys, _) = make_keys(); + let client = make_client(&rhi_keys); + let listing_addr = listing_addr_for_seller(&seller_keys); + let listing_kind = TradeListingAddress::parse(&listing_addr) + .expect("listing address") + .kind; + + let wrong_kind = make_event( + &seller_keys, + RadrootsNostrKind::Metadata, + "metadata".to_string(), + Vec::new(), + ); + let listing_event = make_event( + &seller_keys, + RadrootsNostrKind::Custom(listing_kind), + "listing".to_string(), + Vec::new(), + ); + push_fetch_events_ok(vec![wrong_kind.clone(), listing_event.clone(), listing_event.clone()]); + let fetched_listing = fetch_listing_by_addr(&client, &listing_addr).await.expect("listing fetch"); + assert!(fetched_listing.is_some()); + + let wrong_custom = make_event( + &seller_keys, + RadrootsNostrKind::Custom(9999), + "other".to_string(), + Vec::new(), + ); + let metadata_event = make_event( + &seller_keys, + RadrootsNostrKind::Metadata, + "profile".to_string(), + Vec::new(), + ); + push_fetch_events_ok(vec![ + wrong_custom, + metadata_event.clone(), + metadata_event.clone(), + ]); + let fetched_latest = fetch_latest_event_by_kind( + &client, + RadrootsNostrFilter::new(), + RadrootsNostrKind::Metadata, + ) + .await + .expect("latest metadata"); + assert!(fetched_latest.is_some()); + + let farm = RadrootsListingFarmRef { + pubkey: seller_keys.public_key().to_hex(), + d_tag: "farm".to_string(), + }; + dvm_test_hooks() + .lock() + .expect("hooks") + .fetch_events_results + .push_back(Err(TradeListingDvmError::InvalidOrder)); + dvm_test_hooks() + .lock() + .expect("hooks") + .fetch_events_results + .push_back(Err(TradeListingDvmError::InvalidOrder)); + let errors = validate_farm_dependencies(&client, &farm) + .await + .expect("farm validation"); + assert!(errors.contains(&TradeListingValidationError::MissingFarmProfile)); + assert!(errors.contains(&TradeListingValidationError::MissingFarmRecord)); + + let empty_farm_tag = RadrootsListingFarmRef { + pubkey: seller_keys.public_key().to_hex(), + d_tag: String::new(), + }; + push_fetch_events_ok(Vec::new()); + let empty_tag_errors = validate_farm_dependencies(&client, &empty_farm_tag) + .await + .expect("empty farm tag"); + assert!(empty_tag_errors.contains(&TradeListingValidationError::MissingFarmRecord)); + } + + #[tokio::test] + async fn io_wrapper_default_paths_cover_fallback_branches() { + let _guard = test_guard(); + let (rhi_keys, seller_keys, _) = make_keys(); + let client = make_client(&rhi_keys); + assert!(fetch_event_by_id_io(&client, "invalid-id").await.is_err()); + assert!(fetch_events_io(&client, RadrootsNostrFilter::new(), std::time::Duration::from_millis(1)) + .await + .is_err()); + let builder = radroots_nostr::prelude::radroots_nostr_build_event( + KIND_TRADE_LISTING_ORDER_REQ as u32, + "x", + vec![vec!["a".to_string(), listing_addr_for_seller(&seller_keys)]], + ) + .expect("builder"); + assert!(send_event_io(&client, builder).await.is_err()); + let event = make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ORDER_REQ), + "{}".to_string(), + Vec::new(), + ); + assert!(validate_listing_event_io(&event).is_err()); + } + + #[tokio::test] + async fn handle_event_valid_dispatch_matrix_covers_arm_calls() { + let _guard = test_guard(); + let (rhi_keys, seller_keys, buyer_keys) = make_keys(); + let client = make_client(&rhi_keys); + let listing_addr = listing_addr_for_seller(&seller_keys); + let rhi_pub = rhi_keys.public_key().to_hex(); + let order_id = "order-1"; + let buyer_pub = buyer_keys.public_key().to_hex(); + let seller_pub = seller_keys.public_key().to_hex(); + let state = state_with_order( + &listing_addr, + order_id, + &buyer_pub, + &seller_pub, + TradeOrderStatus::Requested, + ) + .await; + + push_fetch_events_ok(Vec::new()); + push_send_ok(); + let validate_event = make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_VALIDATE_REQ), + make_envelope_content( + TradeListingMessageType::ListingValidateRequest, + &listing_addr, + None, + json!({"listing_event": null}), + ), + make_custom_tags(&rhi_pub, &listing_addr, None), + ); + let _ = handle_event( + validate_event, + make_custom_tags(&rhi_pub, &listing_addr, None), + rhi_keys.clone(), + client.clone(), + state.clone(), + ) + .await; + + let cases: Vec<(TradeListingMessageType, u16, serde_json::Value, TradeOrderStatus)> = vec![ + ( + TradeListingMessageType::OrderRequest, + KIND_TRADE_LISTING_ORDER_REQ, + serde_json::to_value(make_order( + order_id, + &listing_addr, + &buyer_pub, + &seller_pub, + TradeOrderStatus::Requested, + )) + .expect("order request"), + TradeOrderStatus::Requested, + ), + ( + TradeListingMessageType::OrderResponse, + KIND_TRADE_LISTING_ORDER_RES, + serde_json::to_value(TradeOrderResponse { + accepted: true, + reason: None, + }) + .expect("order response"), + TradeOrderStatus::Requested, + ), + ( + TradeListingMessageType::OrderRevision, + KIND_TRADE_LISTING_ORDER_REVISION_REQ, + serde_json::to_value(TradeOrderRevision { + revision_id: "r2".to_string(), + order_id: order_id.to_string(), + changes: Vec::new(), + reason: None, + }) + .expect("order revision"), + TradeOrderStatus::Requested, + ), + ( + TradeListingMessageType::OrderRevisionAccept, + KIND_TRADE_LISTING_ORDER_REVISION_RES, + serde_json::to_value(TradeOrderRevisionResponse { + accepted: true, + reason: None, + }) + .expect("order revision accept"), + TradeOrderStatus::Revised, + ), + ( + TradeListingMessageType::OrderRevisionDecline, + KIND_TRADE_LISTING_ORDER_REVISION_RES, + serde_json::to_value(TradeOrderRevisionResponse { + accepted: false, + reason: None, + }) + .expect("order revision decline"), + TradeOrderStatus::Revised, + ), + ( + TradeListingMessageType::Question, + KIND_TRADE_LISTING_QUESTION_REQ, + serde_json::to_value(TradeQuestion { + question_id: "qx".to_string(), + order_id: Some(order_id.to_string()), + listing_addr: Some(listing_addr.clone()), + question_text: "question".to_string(), + }) + .expect("question"), + TradeOrderStatus::Requested, + ), + ( + TradeListingMessageType::Answer, + KIND_TRADE_LISTING_ANSWER_RES, + serde_json::to_value(TradeAnswer { + question_id: "qx".to_string(), + order_id: Some(order_id.to_string()), + listing_addr: Some(listing_addr.clone()), + answer_text: "answer".to_string(), + }) + .expect("answer"), + TradeOrderStatus::Questioned, + ), + ( + TradeListingMessageType::DiscountRequest, + KIND_TRADE_LISTING_DISCOUNT_REQ, + serde_json::to_value(TradeDiscountRequest { + discount_id: "d2".to_string(), + order_id: order_id.to_string(), + value: sample_discount_value(), + conditions: None, + }) + .expect("discount request"), + TradeOrderStatus::Requested, + ), + ( + TradeListingMessageType::DiscountOffer, + KIND_TRADE_LISTING_DISCOUNT_OFFER_RES, + serde_json::to_value(TradeDiscountOffer { + discount_id: "d2".to_string(), + order_id: order_id.to_string(), + value: sample_discount_value(), + conditions: None, + }) + .expect("discount offer"), + TradeOrderStatus::Requested, + ), + ( + TradeListingMessageType::DiscountAccept, + KIND_TRADE_LISTING_DISCOUNT_ACCEPT_REQ, + serde_json::to_value(TradeDiscountDecision::Accept { + value: sample_discount_value(), + }) + .expect("discount accept"), + TradeOrderStatus::Revised, + ), + ( + TradeListingMessageType::DiscountDecline, + KIND_TRADE_LISTING_DISCOUNT_DECLINE_REQ, + serde_json::to_value(TradeDiscountDecision::Decline { reason: None }) + .expect("discount decline"), + TradeOrderStatus::Revised, + ), + ( + TradeListingMessageType::Cancel, + KIND_TRADE_LISTING_CANCEL_REQ, + serde_json::to_value(TradeListingCancel { reason: None }).expect("cancel"), + TradeOrderStatus::Requested, + ), + ( + TradeListingMessageType::FulfillmentUpdate, + KIND_TRADE_LISTING_FULFILLMENT_UPDATE_REQ, + serde_json::to_value(TradeFulfillmentUpdate { + status: TradeFulfillmentStatus::Shipped, + tracking: None, + eta: None, + notes: None, + }) + .expect("fulfillment"), + TradeOrderStatus::Accepted, + ), + ( + TradeListingMessageType::Receipt, + KIND_TRADE_LISTING_RECEIPT_REQ, + serde_json::to_value(TradeReceipt { + acknowledged: true, + at: 1, + note: None, + }) + .expect("receipt"), + TradeOrderStatus::Fulfilled, + ), + ( + TradeListingMessageType::ListingValidateResult, + KIND_TRADE_LISTING_VALIDATE_RES, + json!({"valid": true, "errors": []}), + TradeOrderStatus::Requested, + ), + ]; + + for (message_type, kind, payload, status_before) in cases { + set_order_status(&state, order_id, status_before).await; + if message_type != TradeListingMessageType::ListingValidateResult { + push_send_ok(); + } + let sender = match message_type { + TradeListingMessageType::OrderResponse + | TradeListingMessageType::OrderRevision + | TradeListingMessageType::Answer + | TradeListingMessageType::DiscountOffer + | TradeListingMessageType::FulfillmentUpdate => &seller_keys, + _ => &buyer_keys, + }; + let content = make_envelope_content(message_type, &listing_addr, Some(order_id), payload); + let tags = make_custom_tags(&rhi_pub, &listing_addr, Some(order_id)); + let event = make_event(sender, RadrootsNostrKind::Custom(kind), content, tags.clone()); + let _ = handle_event( + event, + tags, + rhi_keys.clone(), + client.clone(), + state.clone(), + ) + .await; + } + } + + #[tokio::test] + async fn handler_error_branches_are_covered() { + let _guard = test_guard(); + let (rhi_keys, seller_keys, buyer_keys) = make_keys(); + let client = make_client(&rhi_keys); + let listing_addr = listing_addr_for_seller(&seller_keys); + let parsed = TradeListingAddress::parse(&listing_addr).expect("listing"); + let buyer_pub = buyer_keys.public_key().to_hex(); + let seller_pub = seller_keys.public_key().to_hex(); + let state = state_with_order( + &listing_addr, + "order-1", + &buyer_pub, + &seller_pub, + TradeOrderStatus::Requested, + ) + .await; + + let bad_order = make_order("bad", &listing_addr, &buyer_pub, &seller_pub, TradeOrderStatus::Requested); + let event = make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ORDER_REQ), + "x".to_string(), + Vec::new(), + ); + assert!(matches!( + handle_order_request(&event, bad_order, &parsed, Some("order-1"), &client, &state).await, + Err(TradeListingDvmError::InvalidOrder) + )); + + let missing_state = Arc::new(AsyncMutex::new(TradeListingState::default())); + let order = make_order("order-2", &listing_addr, &buyer_pub, &seller_pub, TradeOrderStatus::Requested); + assert!(matches!( + handle_order_request(&event, order, &parsed, Some("order-2"), &client, &missing_state).await, + Err(TradeListingDvmError::ListingNotValidated) + )); + + set_order_status(&state, "order-1", TradeOrderStatus::Requested).await; + let seller_event = make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ORDER_RES), + "x".to_string(), + Vec::new(), + ); + state + .lock() + .await + .get_order_mut("order-1") + .expect("order") + .seen_event_ids + .insert(seller_event.id.to_string()); + assert!(handle_order_response( + &seller_event, + TradeOrderResponse { + accepted: true, + reason: None, + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await + .is_ok()); + + let wrong_buyer = make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ORDER_REVISION_RES), + "x".to_string(), + Vec::new(), + ); + assert!(matches!( + handle_order_revision_response( + &wrong_buyer, + TradeListingMessageType::OrderRevisionAccept, + TradeOrderRevisionResponse { + accepted: false, + reason: None, + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await, + Err(TradeListingDvmError::Unauthorized | TradeListingDvmError::InvalidOrder) + )); + + set_order_status(&state, "order-1", TradeOrderStatus::Requested).await; + let wrong_sender = make_event( + &rhi_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_CANCEL_REQ), + "x".to_string(), + Vec::new(), + ); + assert!(matches!( + handle_cancel( + &wrong_sender, + TradeListingCancel { reason: None }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await, + Err(TradeListingDvmError::Unauthorized) + )); + + let validate_event = make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_VALIDATE_REQ), + "x".to_string(), + Vec::new(), + ); + dvm_test_hooks() + .lock() + .expect("hooks") + .fetch_event_by_id_results + .push_back(Err(TradeListingDvmError::InvalidOrder)); + push_send_ok(); + assert!(handle_listing_validate_request( + &validate_event, + TradeListingValidateRequest { + listing_event: Some(RadrootsNostrEventPtr { + id: "x".to_string(), + relays: None, + }), + }, + &listing_addr, + &client, + &state, + ) + .await + .is_ok()); + + push_send_ok(); + assert!(handle_listing_validate_request( + &validate_event, + TradeListingValidateRequest { listing_event: None }, + "not-a-listing-addr", + &client, + &state, + ) + .await + .is_ok()); + + dvm_test_hooks() + .lock() + .expect("hooks") + .fetch_event_by_id_results + .push_back(Ok(validate_event.clone())); + dvm_test_hooks() + .lock() + .expect("hooks") + .validate_listing_results + .push_back(Err(TradeListingValidationError::MissingInventory)); + push_send_ok(); + assert!(handle_listing_validate_request( + &validate_event, + TradeListingValidateRequest { + listing_event: Some(RadrootsNostrEventPtr { + id: "x".to_string(), + relays: None, + }), + }, + &listing_addr, + &client, + &state, + ) + .await + .is_ok()); + + let unauthorized_order = make_order( + "order-3", + &listing_addr, + "different-buyer", + &seller_pub, + TradeOrderStatus::Requested, + ); + assert!(matches!( + handle_order_request( + &event, + unauthorized_order, + &parsed, + Some("order-3"), + &client, + &state, + ) + .await, + Err(TradeListingDvmError::Unauthorized) + )); + + let duplicate_order = make_order( + "order-1", + &listing_addr, + &buyer_pub, + &seller_pub, + TradeOrderStatus::Requested, + ); + assert!(handle_order_request( + &event, + duplicate_order, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await + .is_ok()); + + set_order_status(&state, "order-1", TradeOrderStatus::Requested).await; + let buyer_event = make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ORDER_RES), + "x".to_string(), + Vec::new(), + ); + assert!(matches!( + handle_order_response( + &buyer_event, + TradeOrderResponse { + accepted: false, + reason: None, + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await, + Err(TradeListingDvmError::Unauthorized) + )); + + set_order_status(&state, "order-1", TradeOrderStatus::Requested).await; + push_send_ok(); + assert!(handle_order_response( + &make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ORDER_RES), + "x".to_string(), + Vec::new(), + ), + TradeOrderResponse { + accepted: false, + reason: None, + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await + .is_ok()); + + set_order_status(&state, "order-1", TradeOrderStatus::Requested).await; + assert!(matches!( + handle_order_revision( + &make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ORDER_REVISION_REQ), + "x".to_string(), + Vec::new(), + ), + TradeOrderRevision { + revision_id: "r3".to_string(), + order_id: "other".to_string(), + changes: Vec::new(), + reason: None, + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await, + Err(TradeListingDvmError::InvalidOrder) + )); + + let seen_event = make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ORDER_REVISION_REQ), + "x".to_string(), + Vec::new(), + ); + state + .lock() + .await + .get_order_mut("order-1") + .expect("order") + .seen_event_ids + .insert(seen_event.id.to_string()); + assert!(handle_order_revision( + &seen_event, + TradeOrderRevision { + revision_id: "r4".to_string(), + order_id: "order-1".to_string(), + changes: Vec::new(), + reason: None, + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await + .is_ok()); + + set_order_status(&state, "order-1", TradeOrderStatus::Requested).await; + assert!(matches!( + handle_question( + &make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_QUESTION_REQ), + "x".to_string(), + Vec::new(), + ), + TradeQuestion { + question_id: "q".to_string(), + order_id: Some("other".to_string()), + listing_addr: None, + question_text: "q".to_string(), + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await, + Err(TradeListingDvmError::InvalidOrder) + )); + + set_order_status(&state, "order-1", TradeOrderStatus::Questioned).await; + assert!(matches!( + handle_answer( + &make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ANSWER_RES), + "x".to_string(), + Vec::new(), + ), + TradeAnswer { + question_id: "q".to_string(), + order_id: Some("other".to_string()), + listing_addr: None, + answer_text: "a".to_string(), + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await, + Err(TradeListingDvmError::InvalidOrder) + )); + + set_order_status(&state, "order-1", TradeOrderStatus::Requested).await; + assert!(matches!( + handle_discount_request( + &make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_DISCOUNT_REQ), + "x".to_string(), + Vec::new(), + ), + TradeDiscountRequest { + discount_id: "d".to_string(), + order_id: "other".to_string(), + value: sample_discount_value(), + conditions: None, + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await, + Err(TradeListingDvmError::InvalidOrder) + )); + + set_order_status(&state, "order-1", TradeOrderStatus::Requested).await; + assert!(matches!( + handle_discount_offer( + &make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_DISCOUNT_OFFER_RES), + "x".to_string(), + Vec::new(), + ), + TradeDiscountOffer { + discount_id: "d".to_string(), + order_id: "other".to_string(), + value: sample_discount_value(), + conditions: None, + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await, + Err(TradeListingDvmError::InvalidOrder) + )); + + set_order_status(&state, "order-1", TradeOrderStatus::Revised).await; + assert!(matches!( + handle_discount_decision( + &make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_DISCOUNT_ACCEPT_REQ), + "x".to_string(), + Vec::new(), + ), + TradeListingMessageType::DiscountAccept, + TradeDiscountDecision::Decline { reason: None }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await, + Err(TradeListingDvmError::InvalidOrder) + )); + assert!(matches!( + handle_discount_decision( + &make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_DISCOUNT_DECLINE_REQ), + "x".to_string(), + Vec::new(), + ), + TradeListingMessageType::DiscountDecline, + TradeDiscountDecision::Accept { + value: sample_discount_value(), + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await, + Err(TradeListingDvmError::InvalidOrder) + )); + + push_send_ok(); + assert!(handle_discount_decision( + &make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_DISCOUNT_DECLINE_REQ), + "x".to_string(), + Vec::new(), + ), + TradeListingMessageType::Cancel, + TradeDiscountDecision::Decline { reason: None }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await + .is_ok()); + + set_order_status(&state, "order-1", TradeOrderStatus::Requested).await; + let cancel_by_seller = make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_CANCEL_REQ), + "x".to_string(), + Vec::new(), + ); + push_send_ok(); + assert!(handle_cancel( + &cancel_by_seller, + TradeListingCancel { reason: None }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await + .is_ok()); + + set_order_status(&state, "order-1", TradeOrderStatus::Accepted).await; + assert!(matches!( + handle_fulfillment_update( + &make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_FULFILLMENT_UPDATE_REQ), + "x".to_string(), + Vec::new(), + ), + TradeFulfillmentUpdate { + status: TradeFulfillmentStatus::Shipped, + tracking: None, + eta: None, + notes: None, + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await, + Err(TradeListingDvmError::Unauthorized) + )); + + set_order_status(&state, "order-1", TradeOrderStatus::Fulfilled).await; + assert!(matches!( + handle_receipt( + &make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_RECEIPT_REQ), + "x".to_string(), + Vec::new(), + ), + TradeReceipt { + acknowledged: true, + at: 1, + note: None, + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await, + Err(TradeListingDvmError::Unauthorized) + )); + } + + #[tokio::test] + async fn handler_duplicate_optional_and_guard_branches_are_covered() { + let _guard = test_guard(); + let (rhi_keys, seller_keys, buyer_keys) = make_keys(); + let client = make_client(&rhi_keys); + let listing_addr = listing_addr_for_seller(&seller_keys); + let parsed = TradeListingAddress::parse(&listing_addr).expect("listing"); + let buyer_pub = buyer_keys.public_key().to_hex(); + let seller_pub = seller_keys.public_key().to_hex(); + let state = state_with_order( + &listing_addr, + "order-1", + &buyer_pub, + &seller_pub, + TradeOrderStatus::Requested, + ) + .await; + + let mismatched_addr = listing_addr_for_seller(&buyer_keys); + let mismatched_parsed = TradeListingAddress::parse(&mismatched_addr).expect("mismatched listing"); + let revision_event = make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ORDER_REVISION_REQ), + "revision".to_string(), + Vec::new(), + ); + assert!(matches!( + handle_order_revision( + &revision_event, + TradeOrderRevision { + revision_id: "r1".to_string(), + order_id: "order-1".to_string(), + changes: Vec::new(), + reason: None, + }, + &mismatched_parsed, + Some("order-1"), + &client, + &state, + ) + .await, + Err(TradeListingDvmError::Unauthorized) + )); + + let seen_revision_response = make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ORDER_REVISION_RES), + "seen".to_string(), + Vec::new(), + ); + mark_event_seen(&state, "order-1", seen_revision_response.id.to_string()).await; + assert!(handle_order_revision_response( + &seen_revision_response, + TradeListingMessageType::OrderRevisionAccept, + TradeOrderRevisionResponse { + accepted: true, + reason: None, + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await + .is_ok()); + + assert!(matches!( + handle_order_revision_response( + &make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ORDER_REVISION_RES), + "accept-invalid".to_string(), + Vec::new(), + ), + TradeListingMessageType::OrderRevisionAccept, + TradeOrderRevisionResponse { + accepted: false, + reason: None, + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await, + Err(TradeListingDvmError::InvalidOrder) + )); + assert!(matches!( + handle_order_revision_response( + &make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ORDER_REVISION_RES), + "decline-invalid".to_string(), + Vec::new(), + ), + TradeListingMessageType::OrderRevisionDecline, + TradeOrderRevisionResponse { + accepted: true, + reason: None, + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await, + Err(TradeListingDvmError::InvalidOrder) + )); + + set_order_status(&state, "order-1", TradeOrderStatus::Requested).await; + push_send_ok(); + assert!(handle_question( + &make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_QUESTION_REQ), + "question-ok".to_string(), + Vec::new(), + ), + TradeQuestion { + question_id: "q1".to_string(), + order_id: None, + listing_addr: Some(listing_addr.clone()), + question_text: "question".to_string(), + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await + .is_ok()); + + let seen_question = make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_QUESTION_REQ), + "question-seen".to_string(), + Vec::new(), + ); + mark_event_seen(&state, "order-1", seen_question.id.to_string()).await; + assert!(handle_question( + &seen_question, + TradeQuestion { + question_id: "q2".to_string(), + order_id: Some("order-1".to_string()), + listing_addr: None, + question_text: "question".to_string(), + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await + .is_ok()); + assert!(matches!( + handle_question( + &make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_QUESTION_REQ), + "question-unauthorized".to_string(), + Vec::new(), + ), + TradeQuestion { + question_id: "q3".to_string(), + order_id: Some("order-1".to_string()), + listing_addr: None, + question_text: "question".to_string(), + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await, + Err(TradeListingDvmError::Unauthorized) + )); + + set_order_status(&state, "order-1", TradeOrderStatus::Questioned).await; + push_send_ok(); + assert!(handle_answer( + &make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ANSWER_RES), + "answer-ok".to_string(), + Vec::new(), + ), + TradeAnswer { + question_id: "q1".to_string(), + order_id: None, + listing_addr: Some(listing_addr.clone()), + answer_text: "answer".to_string(), + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await + .is_ok()); + + let seen_answer = make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ANSWER_RES), + "answer-seen".to_string(), + Vec::new(), + ); + mark_event_seen(&state, "order-1", seen_answer.id.to_string()).await; + assert!(handle_answer( + &seen_answer, + TradeAnswer { + question_id: "q1".to_string(), + order_id: Some("order-1".to_string()), + listing_addr: None, + answer_text: "answer".to_string(), + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await + .is_ok()); + assert!(matches!( + handle_answer( + &make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_ANSWER_RES), + "answer-unauthorized".to_string(), + Vec::new(), + ), + TradeAnswer { + question_id: "q1".to_string(), + order_id: Some("order-1".to_string()), + listing_addr: None, + answer_text: "answer".to_string(), + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await, + Err(TradeListingDvmError::Unauthorized) + )); + + set_order_status(&state, "order-1", TradeOrderStatus::Requested).await; + let seen_discount_request = make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_DISCOUNT_REQ), + "discount-request-seen".to_string(), + Vec::new(), + ); + mark_event_seen(&state, "order-1", seen_discount_request.id.to_string()).await; + assert!(handle_discount_request( + &seen_discount_request, + TradeDiscountRequest { + discount_id: "d1".to_string(), + order_id: "order-1".to_string(), + value: sample_discount_value(), + conditions: None, + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await + .is_ok()); + assert!(matches!( + handle_discount_request( + &make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_DISCOUNT_REQ), + "discount-request-unauthorized".to_string(), + Vec::new(), + ), + TradeDiscountRequest { + discount_id: "d2".to_string(), + order_id: "order-1".to_string(), + value: sample_discount_value(), + conditions: None, + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await, + Err(TradeListingDvmError::Unauthorized) + )); + + set_order_status(&state, "order-1", TradeOrderStatus::Requested).await; + let seen_discount_offer = make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_DISCOUNT_OFFER_RES), + "discount-offer-seen".to_string(), + Vec::new(), + ); + mark_event_seen(&state, "order-1", seen_discount_offer.id.to_string()).await; + assert!(handle_discount_offer( + &seen_discount_offer, + TradeDiscountOffer { + discount_id: "d1".to_string(), + order_id: "order-1".to_string(), + value: sample_discount_value(), + conditions: None, + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await + .is_ok()); + assert!(matches!( + handle_discount_offer( + &make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_DISCOUNT_OFFER_RES), + "discount-offer-unauthorized".to_string(), + Vec::new(), + ), + TradeDiscountOffer { + discount_id: "d2".to_string(), + order_id: "order-1".to_string(), + value: sample_discount_value(), + conditions: None, + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await, + Err(TradeListingDvmError::Unauthorized) + )); + + set_order_status(&state, "order-1", TradeOrderStatus::Revised).await; + let seen_discount_decision = make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_DISCOUNT_DECLINE_REQ), + "discount-decision-seen".to_string(), + Vec::new(), + ); + mark_event_seen(&state, "order-1", seen_discount_decision.id.to_string()).await; + assert!(handle_discount_decision( + &seen_discount_decision, + TradeListingMessageType::DiscountDecline, + TradeDiscountDecision::Decline { reason: None }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await + .is_ok()); + assert!(matches!( + handle_discount_decision( + &make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_DISCOUNT_DECLINE_REQ), + "discount-decision-unauthorized".to_string(), + Vec::new(), + ), + TradeListingMessageType::DiscountDecline, + TradeDiscountDecision::Decline { reason: None }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await, + Err(TradeListingDvmError::Unauthorized) + )); + + set_order_status(&state, "order-1", TradeOrderStatus::Requested).await; + let seen_cancel = make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_CANCEL_REQ), + "cancel-seen".to_string(), + Vec::new(), + ); + mark_event_seen(&state, "order-1", seen_cancel.id.to_string()).await; + assert!(handle_cancel( + &seen_cancel, + TradeListingCancel { reason: None }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await + .is_ok()); + + set_order_status(&state, "order-1", TradeOrderStatus::Accepted).await; + let seen_fulfillment = make_event( + &seller_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_FULFILLMENT_UPDATE_REQ), + "fulfillment-seen".to_string(), + Vec::new(), + ); + mark_event_seen(&state, "order-1", seen_fulfillment.id.to_string()).await; + assert!(handle_fulfillment_update( + &seen_fulfillment, + TradeFulfillmentUpdate { + status: TradeFulfillmentStatus::Shipped, + tracking: None, + eta: None, + notes: None, + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await + .is_ok()); + + set_order_status(&state, "order-1", TradeOrderStatus::Fulfilled).await; + let seen_receipt = make_event( + &buyer_keys, + RadrootsNostrKind::Custom(KIND_TRADE_LISTING_RECEIPT_REQ), + "receipt-seen".to_string(), + Vec::new(), + ); + mark_event_seen(&state, "order-1", seen_receipt.id.to_string()).await; + assert!(handle_receipt( + &seen_receipt, + TradeReceipt { + acknowledged: true, + at: 1, + note: None, + }, + &parsed, + Some("order-1"), + &client, + &state, + ) + .await + .is_ok()); } }