lib

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

commit e469febdbb06389668783b479f013e0a5d8a820a
parent dd4648ca4885ff82f3fd0f52cb958a4eef50a34a
Author: triesap <tyson@radroots.org>
Date:   Sat, 21 Feb 2026 20:21:32 +0000

trade: add exhaustive listing coverage across codec and validation


- add branch-complete tests for listing codec parse and tag edge cases
- expand dvm, meta, and tags tests to cover envelope and chain validation paths
- extend validation and pricing tests for negative, missing, and mismatch scenarios
- verify `radroots-trade` strict 100-100-100 coverage through xtask gates

Diffstat:
Mcrates/trade/src/listing/codec.rs | 1414+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/trade/src/listing/dvm.rs | 164++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/trade/src/listing/dvm_kinds.rs | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/trade/src/listing/meta.rs | 99++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/trade/src/listing/price_ext.rs | 56+++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/trade/src/listing/tags.rs | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mcrates/trade/src/listing/validation.rs | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
7 files changed, 1969 insertions(+), 124 deletions(-)

diff --git a/crates/trade/src/listing/codec.rs b/crates/trade/src/listing/codec.rs @@ -244,12 +244,13 @@ fn listing_from_tags( "process" => set_optional(&mut product.process, tag.get(1)), "lot" => set_optional(&mut product.lot, tag.get(1)), "location" => { - if tag.len() >= 3 - || (!has_structured_location && location.is_none() && tag.len() >= 2) - { - let primary = tag.get(1).ok_or_else(|| { - TradeListingParseError::InvalidTag(TAG_LOCATION.to_string()) - })?; + let parse_structured_location = match tag.len() { + 0 | 1 => false, + 2 => !has_structured_location && location.is_none(), + _ => true, + }; + if parse_structured_location { + let primary = &tag[1]; if primary.trim().is_empty() { return Err(TradeListingParseError::InvalidTag(TAG_LOCATION.to_string())); } @@ -306,17 +307,11 @@ fn listing_from_tags( TAG_RADROOTS_BIN.to_string(), )); } - let bin_id = tag.get(1).and_then(|v| clean_value(v)).ok_or_else(|| { - TradeListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()) - })?; - let amount = tag.get(2).ok_or_else(|| { - TradeListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()) - })?; - let unit = tag.get(3).ok_or_else(|| { + let bin_id = clean_value(&tag[1]).ok_or_else(|| { TradeListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()) })?; - let amount = parse_decimal(amount, TAG_RADROOTS_BIN)?; - let unit = parse_unit(unit)?; + let amount = parse_decimal(&tag[2], TAG_RADROOTS_BIN)?; + let unit = parse_unit(&tag[3])?; if unit != unit.canonical_unit() { return Err(TradeListingParseError::InvalidTag( TAG_RADROOTS_BIN.to_string(), @@ -331,18 +326,17 @@ fn listing_from_tags( bin.quantity = Some(RadrootsCoreQuantity::new(amount, unit)); if tag.len() >= 5 { - let display_amount = tag.get(4).ok_or_else(|| { - TradeListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()) - })?; - let display_amount = parse_decimal(display_amount, TAG_RADROOTS_BIN)?; - let display_unit = tag.get(5).ok_or_else(|| { - TradeListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()) - })?; - let display_unit = parse_unit(display_unit)?; + if tag.len() < 6 { + return Err(TradeListingParseError::InvalidTag( + TAG_RADROOTS_BIN.to_string(), + )); + } + let display_amount = parse_decimal(&tag[4], TAG_RADROOTS_BIN)?; + let display_unit = parse_unit(&tag[5])?; bin.display_amount = Some(display_amount); bin.display_unit = Some(display_unit); if tag.len() == 7 { - bin.display_label = tag.get(6).and_then(|v| clean_value(v)); + bin.display_label = clean_value(&tag[6]); } } } @@ -357,25 +351,13 @@ fn listing_from_tags( TAG_RADROOTS_PRICE.to_string(), )); } - let bin_id = tag.get(1).and_then(|v| clean_value(v)).ok_or_else(|| { - TradeListingParseError::InvalidTag(TAG_RADROOTS_PRICE.to_string()) - })?; - let amount = tag.get(2).ok_or_else(|| { - TradeListingParseError::InvalidTag(TAG_RADROOTS_PRICE.to_string()) - })?; - let currency = tag.get(3).ok_or_else(|| { - TradeListingParseError::InvalidTag(TAG_RADROOTS_PRICE.to_string()) - })?; - let per_amount = tag.get(4).ok_or_else(|| { - TradeListingParseError::InvalidTag(TAG_RADROOTS_PRICE.to_string()) - })?; - let per_unit = tag.get(5).ok_or_else(|| { + let bin_id = clean_value(&tag[1]).ok_or_else(|| { TradeListingParseError::InvalidTag(TAG_RADROOTS_PRICE.to_string()) })?; - let amount = parse_decimal(amount, TAG_RADROOTS_PRICE)?; - let currency = parse_currency(currency)?; - let per_amount = parse_decimal(per_amount, TAG_RADROOTS_PRICE)?; - let per_unit = parse_unit(per_unit)?; + let amount = parse_decimal(&tag[2], TAG_RADROOTS_PRICE)?; + let currency = parse_currency(&tag[3])?; + let per_amount = parse_decimal(&tag[4], TAG_RADROOTS_PRICE)?; + let per_unit = parse_unit(&tag[5])?; let price_per_canonical_unit = RadrootsCoreQuantityPrice::new( RadrootsCoreMoney::new(amount, currency), RadrootsCoreQuantity::new(per_amount, per_unit), @@ -399,14 +381,8 @@ fn listing_from_tags( )); } if tag.len() == 8 { - let display_price = tag.get(6).ok_or_else(|| { - TradeListingParseError::InvalidTag(TAG_RADROOTS_PRICE.to_string()) - })?; - let display_unit = tag.get(7).ok_or_else(|| { - TradeListingParseError::InvalidTag(TAG_RADROOTS_PRICE.to_string()) - })?; - let display_price = parse_decimal(display_price, TAG_RADROOTS_PRICE)?; - let display_unit = parse_unit(display_unit)?; + let display_price = parse_decimal(&tag[6], TAG_RADROOTS_PRICE)?; + let display_unit = parse_unit(&tag[7])?; bin.display_price = Some(RadrootsCoreMoney::new(display_price, currency)); bin.display_price_unit = Some(display_unit); } @@ -490,9 +466,7 @@ fn listing_from_tags( }; let location = location.map(|mut loc| { - if loc.geohash.is_none() { - loc.geohash = geohash; - } + loc.geohash = loc.geohash.or(geohash); loc }); @@ -676,8 +650,14 @@ fn parse_plot_ref(tags: &[Vec<String>]) -> Result<Option<RadrootsPlotRef>, Trade #[cfg(test)] mod tests { use super::*; - use radroots_core::RadrootsCoreUnit; - use radroots_events::listing::RadrootsListingFarmRef; + use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountScope, + RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreMoney, + RadrootsCorePercent, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, + }; + use radroots_events::listing::{ + RadrootsListing, RadrootsListingFarmRef, RadrootsListingStatus, + }; fn farm_ref() -> RadrootsListingFarmRef { RadrootsListingFarmRef { @@ -686,15 +666,30 @@ mod tests { } } - #[test] - fn listing_parses_radroots_bins() { - let tags = vec![ + fn listing_d_tag() -> String { + "AAAAAAAAAAAAAAAAAAAAAg".to_string() + } + + fn base_event_tags() -> Vec<Vec<String>> { + vec![ + vec![TAG_D.into(), listing_d_tag()], + vec![TAG_P.into(), "seller".into()], + vec![ + TAG_A.into(), + format!("{KIND_FARM}:seller:{}", farm_ref().d_tag), + ], + ] + } + + fn base_trade_tags() -> Vec<Vec<String>> { + vec![ vec!["key".into(), "coffee".into()], vec!["title".into(), "Coffee".into()], vec!["category".into(), "coffee".into()], - vec!["radroots:primary_bin".into(), "bin-1".into()], + vec!["summary".into(), "Single origin".into()], + vec![TAG_RADROOTS_PRIMARY_BIN.into(), "bin-1".into()], vec![ - "radroots:bin".into(), + TAG_RADROOTS_BIN.into(), "bin-1".into(), "1000".into(), "g".into(), @@ -703,7 +698,7 @@ mod tests { "bag".into(), ], vec![ - "radroots:price".into(), + TAG_RADROOTS_PRICE.into(), "bin-1".into(), "0.01".into(), "USD".into(), @@ -712,11 +707,40 @@ mod tests { "10".into(), "kg".into(), ], - ]; + ] + } + + fn parse_base_listing_from_tags() -> RadrootsListing { + listing_from_tags( + &base_trade_tags(), + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .expect("listing") + } + + fn parse_error_tag(error: TradeListingParseError) -> String { + match error { + TradeListingParseError::MissingTag(tag) => tag, + TradeListingParseError::InvalidTag(tag) => tag, + TradeListingParseError::InvalidNumber(field) => field, + TradeListingParseError::InvalidUnit => "unit".to_string(), + TradeListingParseError::InvalidCurrency => "currency".to_string(), + TradeListingParseError::InvalidJson(field) => field, + TradeListingParseError::InvalidDiscount(kind) => kind, + } + } + + #[test] + fn listing_parses_radroots_bins() { + let tags = base_trade_tags(); let listing = listing_from_tags( &tags, - "AAAAAAAAAAAAAAAAAAAAAg".to_string(), + listing_d_tag(), farm_ref(), "seller".to_string(), None, @@ -739,31 +763,7 @@ mod tests { #[test] fn listing_from_tags_rejects_invalid_d_tag() { - let tags = vec![ - vec!["key".into(), "coffee".into()], - vec!["title".into(), "Coffee".into()], - vec!["category".into(), "coffee".into()], - vec!["radroots:primary_bin".into(), "bin-1".into()], - vec![ - "radroots:bin".into(), - "bin-1".into(), - "1000".into(), - "g".into(), - "1".into(), - "kg".into(), - "bag".into(), - ], - vec![ - "radroots:price".into(), - "bin-1".into(), - "0.01".into(), - "USD".into(), - "1".into(), - "g".into(), - "10".into(), - "kg".into(), - ], - ]; + let tags = base_trade_tags(); let err = listing_from_tags( &tags, @@ -775,10 +775,1242 @@ mod tests { ) .unwrap_err(); - assert!(matches!( - err, - TradeListingParseError::InvalidTag(tag) if tag == TAG_D - )); + assert_eq!(parse_error_tag(err), TAG_D.to_string()); + } + + #[test] + fn parse_scalar_helpers_cover_success_and_error_paths() { + assert_eq!( + parse_decimal("1.5", "f").unwrap(), + "1.5".parse::<RadrootsCoreDecimal>().unwrap() + ); + assert_eq!( + parse_error_tag(parse_decimal("x", "f").unwrap_err()), + "f".to_string() + ); + assert_eq!(parse_currency(" usd ").unwrap(), RadrootsCoreCurrency::USD); + assert_eq!( + parse_error_tag(parse_currency("12").unwrap_err()), + "currency".to_string() + ); + assert_eq!(parse_unit("g").unwrap(), RadrootsCoreUnit::MassG); + assert_eq!( + parse_error_tag(parse_unit("not-unit").unwrap_err()), + "unit".to_string() + ); + } + + #[test] + fn parse_error_display_covers_all_variants() { + let errors = [ + TradeListingParseError::MissingTag("d".into()), + TradeListingParseError::InvalidTag("a".into()), + TradeListingParseError::InvalidNumber("n".into()), + TradeListingParseError::InvalidUnit, + TradeListingParseError::InvalidCurrency, + TradeListingParseError::InvalidJson("j".into()), + TradeListingParseError::InvalidDiscount("x".into()), + ]; + for error in errors { + assert!(!error.to_string().trim().is_empty()); + } + } + + #[test] + fn parse_d_tag_covers_all_paths() { + assert_eq!( + parse_error_tag(parse_d_tag(&[]).unwrap_err()), + TAG_D.to_string() + ); + assert_eq!( + parse_error_tag(parse_d_tag(&[vec![TAG_D.into()]]).unwrap_err()), + TAG_D.to_string() + ); + assert_eq!( + parse_error_tag(parse_d_tag(&[vec![TAG_D.into(), " ".into()]]).unwrap_err()), + TAG_D.to_string() + ); + assert_eq!( + parse_error_tag(parse_d_tag(&[vec![TAG_D.into(), "invalid".into()]]).unwrap_err()), + TAG_D.to_string() + ); + assert_eq!( + parse_d_tag(&[vec![TAG_D.into(), listing_d_tag()]]).unwrap(), + listing_d_tag() + ); + } + + #[test] + fn listing_from_event_parts_uses_json_content_and_backfills_tags() { + let mut listing = parse_base_listing_from_tags(); + listing.d_tag = String::new(); + listing.farm.pubkey = String::new(); + listing.farm.d_tag = String::new(); + listing.resource_area = None; + listing.plot = None; + + let mut tags = base_event_tags(); + tags.push(vec![ + TAG_RADROOTS_RESOURCE_AREA.into(), + format!("{KIND_RESOURCE_AREA}:seller:AAAAAAAAAAAAAAAAAAAAAQ"), + ]); + tags.push(vec![ + TAG_RADROOTS_PLOT.into(), + format!("{KIND_PLOT}:seller:AAAAAAAAAAAAAAAAAAAAAw"), + ]); + + let parsed = listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap()) + .expect("event listing"); + assert_eq!(parsed.d_tag, listing_d_tag()); + assert_eq!(parsed.farm.pubkey, farm_ref().pubkey); + assert_eq!(parsed.farm.d_tag, farm_ref().d_tag); + assert_eq!( + parsed.resource_area.unwrap().d_tag, + "AAAAAAAAAAAAAAAAAAAAAQ" + ); + assert_eq!(parsed.plot.unwrap().d_tag, "AAAAAAAAAAAAAAAAAAAAAw"); + } + + #[test] + fn listing_from_event_parts_rejects_conflicting_content_values() { + let tags = base_event_tags(); + + let mut listing = parse_base_listing_from_tags(); + listing.d_tag = "AAAAAAAAAAAAAAAAAAAAAw".into(); + let err = + listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap()).unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_D.to_string()); + + let mut listing = parse_base_listing_from_tags(); + listing.farm.pubkey = "other".into(); + let err = + listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap()).unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_A.to_string()); + + let mut listing = parse_base_listing_from_tags(); + listing.farm.d_tag = "AAAAAAAAAAAAAAAAAAAAAw".into(); + let err = + listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap()).unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_A.to_string()); + + let mut listing = parse_base_listing_from_tags(); + listing.farm.d_tag = String::new(); + let parsed = listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap()) + .expect("backfill empty farm d_tag"); + assert_eq!(parsed.farm.d_tag, farm_ref().d_tag); + + let listing = parse_base_listing_from_tags(); + let mut mismatched_pubkey_tags = tags.clone(); + mismatched_pubkey_tags[1][1] = "other".into(); + let err = listing_from_event_parts( + &mismatched_pubkey_tags, + &serde_json::to_string(&listing).unwrap(), + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_P.to_string()); + + let mut listing = parse_base_listing_from_tags(); + listing.resource_area = Some(RadrootsResourceAreaRef { + pubkey: "seller".into(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".into(), + }); + let mut resource_tags = tags.clone(); + resource_tags.push(vec![ + TAG_RADROOTS_RESOURCE_AREA.into(), + format!("{KIND_RESOURCE_AREA}:seller:AAAAAAAAAAAAAAAAAAAAAw"), + ]); + let err = + listing_from_event_parts(&resource_tags, &serde_json::to_string(&listing).unwrap()) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_RESOURCE_AREA.to_string()); + + let mut listing = parse_base_listing_from_tags(); + listing.resource_area = Some(RadrootsResourceAreaRef { + pubkey: "other".into(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAw".into(), + }); + let mut resource_tags = tags.clone(); + resource_tags.push(vec![ + TAG_RADROOTS_RESOURCE_AREA.into(), + format!("{KIND_RESOURCE_AREA}:seller:AAAAAAAAAAAAAAAAAAAAAw"), + ]); + let err = + listing_from_event_parts(&resource_tags, &serde_json::to_string(&listing).unwrap()) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_RESOURCE_AREA.to_string()); + + let mut listing = parse_base_listing_from_tags(); + listing.plot = Some(RadrootsPlotRef { + pubkey: "seller".into(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".into(), + }); + let mut plot_tags = tags.clone(); + plot_tags.push(vec![ + TAG_RADROOTS_PLOT.into(), + format!("{KIND_PLOT}:seller:AAAAAAAAAAAAAAAAAAAAAw"), + ]); + let err = listing_from_event_parts(&plot_tags, &serde_json::to_string(&listing).unwrap()) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_PLOT.to_string()); + + let mut listing = parse_base_listing_from_tags(); + listing.plot = Some(RadrootsPlotRef { + pubkey: "other".into(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAw".into(), + }); + let mut plot_tags = tags.clone(); + plot_tags.push(vec![ + TAG_RADROOTS_PLOT.into(), + format!("{KIND_PLOT}:seller:AAAAAAAAAAAAAAAAAAAAAw"), + ]); + let err = listing_from_event_parts(&plot_tags, &serde_json::to_string(&listing).unwrap()) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_PLOT.to_string()); + } + + #[test] + fn listing_from_event_parts_accepts_matching_resource_and_plot_refs() { + let mut listing = parse_base_listing_from_tags(); + listing.resource_area = Some(RadrootsResourceAreaRef { + pubkey: "seller".into(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".into(), + }); + listing.plot = Some(RadrootsPlotRef { + pubkey: "seller".into(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAw".into(), + }); + let mut tags = base_event_tags(); + tags.push(vec![ + TAG_RADROOTS_RESOURCE_AREA.into(), + format!("{KIND_RESOURCE_AREA}:seller:AAAAAAAAAAAAAAAAAAAAAQ"), + ]); + tags.push(vec![ + TAG_RADROOTS_PLOT.into(), + format!("{KIND_PLOT}:seller:AAAAAAAAAAAAAAAAAAAAAw"), + ]); + let parsed = listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap()) + .expect("matching refs"); + assert_eq!( + parsed.resource_area.unwrap().d_tag, + "AAAAAAAAAAAAAAAAAAAAAQ" + ); + assert_eq!(parsed.plot.unwrap().d_tag, "AAAAAAAAAAAAAAAAAAAAAw"); + } + + #[test] + fn listing_from_event_parts_rejects_invalid_plot_tag_shapes() { + let mut tags = base_event_tags(); + tags.push(vec![TAG_RADROOTS_PLOT.into()]); + let err = listing_from_event_parts(&tags, "").unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_PLOT.to_string()); + + let mut tags = base_event_tags(); + tags.push(vec![TAG_RADROOTS_PLOT.into(), "bad".into()]); + let err = listing_from_event_parts(&tags, "").unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_PLOT.to_string()); + } + + #[test] + fn listing_from_event_parts_falls_back_to_tag_parser() { + let mut tags = base_event_tags(); + tags.extend(base_trade_tags()); + let listing = + listing_from_event_parts(&tags, "{invalid-json").expect("fallback tags parse"); + assert_eq!(listing.primary_bin_id, "bin-1"); + assert_eq!(listing.bins.len(), 1); + } + + #[test] + fn listing_tags_build_and_error_mapping_cover_paths() { + let listing = parse_base_listing_from_tags(); + let built = listing_tags_build(&listing).expect("build tags"); + assert!( + built + .iter() + .any(|tag| tag.get(0).map(|v| v.as_str()) == Some(TAG_RADROOTS_PRIMARY_BIN)) + ); + + let mapped = map_listing_tags_error(EventEncodeError::EmptyRequiredField("d")); + assert_eq!(parse_error_tag(mapped), "d".to_string()); + let mapped = map_listing_tags_error(EventEncodeError::InvalidField("f")); + assert_eq!(parse_error_tag(mapped), "f".to_string()); + let mapped = map_listing_tags_error(EventEncodeError::Json); + assert_eq!(parse_error_tag(mapped), "discount".to_string()); + let mapped = map_listing_tags_error(EventEncodeError::InvalidKind(1)); + assert_eq!(parse_error_tag(mapped), "kind".to_string()); + } + + #[test] + fn listing_from_tags_parses_trade_specific_optional_fields() { + let mut tags = base_trade_tags(); + tags.push(Vec::new()); + tags.push(vec![TAG_PRICE.into(), "ignored".into()]); + tags.push(vec![TAG_RADROOTS_PRIMARY_BIN.into(), "bin-1".into()]); + tags.push(vec![ + TAG_LOCATION.into(), + "Farm".into(), + "Town".into(), + "Region".into(), + "SE".into(), + ]); + tags.push(vec![TAG_GEOHASH.into(), "u6se".into()]); + tags.push(vec![TAG_INVENTORY.into(), "8".into()]); + tags.push(vec![TAG_PUBLISHED_AT.into(), "10".into()]); + tags.push(vec![TAG_EXPIRES_AT.into(), "20".into()]); + tags.push(vec![TAG_DELIVERY.into(), "other".into(), "drone".into()]); + tags.push(vec![ + TAG_IMAGE.into(), + "https://cdn/image.png".into(), + "100x200".into(), + ]); + tags.push(vec![TAG_IMAGE.into(), " ".into()]); + let discount = RadrootsCoreDiscount { + scope: RadrootsCoreDiscountScope::Bin, + threshold: RadrootsCoreDiscountThreshold::BinCount { + bin_id: "bin-1".into(), + min: 2, + }, + value: RadrootsCoreDiscountValue::Percent(RadrootsCorePercent::new( + "5".parse::<RadrootsCoreDecimal>().unwrap(), + )), + }; + tags.push(vec![ + TAG_RADROOTS_DISCOUNT.into(), + serde_json::to_string(&discount).unwrap(), + ]); + + let listing = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .expect("listing"); + + assert_eq!( + format!("{:?}", listing.availability), + "Some(Window { start: Some(10), end: Some(20) })" + ); + assert_eq!( + format!("{:?}", listing.delivery_method), + "Some(Other { method: \"drone\" })" + ); + assert_eq!( + listing.location.as_ref().unwrap().geohash.as_deref(), + Some("u6se") + ); + assert_eq!(listing.images.as_ref().unwrap().len(), 1); + assert_eq!(listing.discounts.as_ref().unwrap().len(), 1); + } + + #[test] + fn listing_from_tags_uses_unstructured_location_and_custom_delivery() { + let mut tags = base_trade_tags(); + tags.push(vec![TAG_LOCATION.into(), "Farm".into()]); + tags.push(vec![TAG_LOCATION.into(), "fallback".into()]); + tags.push(vec![TAG_DELIVERY.into(), "parcel".into()]); + + let listing = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .expect("listing"); + + assert_eq!(listing.product.location.as_deref(), Some("fallback")); + assert_eq!( + format!("{:?}", listing.delivery_method), + "Some(Other { method: \"parcel\" })" + ); + } + + #[test] + fn listing_from_tags_rejects_empty_structured_location_primary() { + let mut tags = base_trade_tags(); + tags.push(vec![ + TAG_LOCATION.into(), + " ".into(), + "Town".into(), + "Region".into(), + ]); + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_LOCATION.to_string()); + } + + #[test] + fn listing_from_tags_handles_short_location_tags_when_structured_present() { + let mut tags = base_trade_tags(); + tags.push(vec![ + TAG_LOCATION.into(), + "Farm".into(), + "Town".into(), + "Region".into(), + ]); + tags.push(vec![TAG_LOCATION.into()]); + tags.push(vec![TAG_LOCATION.into(), "fallback".into()]); + let listing = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .expect("listing"); + assert_eq!(listing.location.unwrap().primary, "Farm".to_string()); + } + + #[test] + fn listing_from_tags_rejects_invalid_tag_forms() { + let mut tags = base_trade_tags(); + tags.push(vec![TAG_RADROOTS_PRIMARY_BIN.into(), "other".into()]); + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRIMARY_BIN.to_string()); + + let mut tags = base_trade_tags(); + tags.push(vec![TAG_RADROOTS_BIN.into(), "bin-1".into(), "1000".into()]); + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string()); + + let mut tags = base_trade_tags(); + tags.push(vec![ + TAG_RADROOTS_PRICE.into(), + "bin-1".into(), + "1".into(), + "USD".into(), + "1".into(), + ]); + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string()); + + let mut tags = base_trade_tags(); + tags.push(vec![ + TAG_RADROOTS_BIN.into(), + "bin-1".into(), + "1000".into(), + "g".into(), + "1".into(), + "kg".into(), + "label".into(), + "extra".into(), + ]); + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string()); + + let mut tags = base_trade_tags(); + tags.push(vec![ + TAG_RADROOTS_BIN.into(), + " ".into(), + "1000".into(), + "g".into(), + ]); + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string()); + + let mut tags = base_trade_tags(); + tags.push(vec![ + TAG_RADROOTS_BIN.into(), + "bin-1".into(), + "1000".into(), + "kg".into(), + ]); + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string()); + + let mut tags = base_trade_tags(); + tags.push(vec![ + TAG_RADROOTS_BIN.into(), + "bin-1".into(), + "1000".into(), + "g".into(), + "1".into(), + ]); + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string()); + + let mut tags = base_trade_tags(); + tags.push(vec![ + TAG_RADROOTS_PRICE.into(), + " ".into(), + "1".into(), + "USD".into(), + "1".into(), + "g".into(), + ]); + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string()); + + let mut tags = base_trade_tags(); + tags.push(vec![ + TAG_RADROOTS_PRICE.into(), + "bin-1".into(), + "1".into(), + "USD".into(), + "1".into(), + "kg".into(), + ]); + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string()); + + let mut tags = base_trade_tags(); + tags.push(vec![ + TAG_RADROOTS_PRICE.into(), + "bin-1".into(), + "1".into(), + "USD".into(), + "1".into(), + "g".into(), + "9".into(), + ]); + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string()); + + let mut tags = base_trade_tags(); + tags.push(vec![ + TAG_RADROOTS_PRICE.into(), + "bin-1".into(), + "1".into(), + "USD".into(), + "1".into(), + "g".into(), + "9".into(), + "bad".into(), + ]); + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string()); + + let mut tags = base_trade_tags(); + tags.push(vec![ + TAG_RADROOTS_PRICE.into(), + "bin-1".into(), + "1".into(), + "USD".into(), + "1".into(), + "g".into(), + "9".into(), + "kg".into(), + "x".into(), + ]); + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string()); + + let mut tags = base_trade_tags(); + tags.push(vec![TAG_RADROOTS_PRIMARY_BIN.into()]); + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRIMARY_BIN.to_string()); + } + + #[test] + fn listing_from_tags_rejects_trade_field_parse_failures() { + let mut tags = base_trade_tags(); + tags.push(vec![TAG_RADROOTS_DISCOUNT.into(), "{".into()]); + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_DISCOUNT.to_string()); + + let mut tags = base_trade_tags(); + tags.push(vec![TAG_INVENTORY.into()]); + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_INVENTORY.to_string()); + + let mut tags = base_trade_tags(); + tags.push(vec![TAG_PUBLISHED_AT.into(), "bad".into()]); + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_PUBLISHED_AT.to_string()); + + let mut tags = base_trade_tags(); + tags.push(vec![TAG_EXPIRES_AT.into(), "bad".into()]); + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_EXPIRES_AT.to_string()); + + let mut tags = base_trade_tags(); + tags.push(vec![TAG_RADROOTS_DISCOUNT.into()]); + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_DISCOUNT.to_string()); + + let mut tags = base_trade_tags(); + tags.push(vec![TAG_PUBLISHED_AT.into()]); + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_PUBLISHED_AT.to_string()); + + let mut tags = base_trade_tags(); + tags.push(vec![TAG_EXPIRES_AT.into()]); + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_EXPIRES_AT.to_string()); + + let mut tags = base_trade_tags(); + tags.push(vec![TAG_IMAGE.into()]); + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_IMAGE.to_string()); + } + + #[test] + fn listing_from_tags_covers_bin_display_and_price_shape_edges() { + let tags = vec![ + vec!["key".into(), "coffee".into()], + vec!["title".into(), "Coffee".into()], + vec!["category".into(), "coffee".into()], + vec![TAG_RADROOTS_PRIMARY_BIN.into(), "bin-2".into()], + vec![ + TAG_RADROOTS_BIN.into(), + "bin-2".into(), + "500".into(), + "g".into(), + "1".into(), + ], + vec![ + TAG_RADROOTS_PRICE.into(), + "bin-2".into(), + "0.02".into(), + "USD".into(), + "1".into(), + "g".into(), + "10".into(), + ], + ]; + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string()); + + let tags = vec![ + vec!["key".into(), "coffee".into()], + vec!["title".into(), "Coffee".into()], + vec!["category".into(), "coffee".into()], + vec![TAG_RADROOTS_PRIMARY_BIN.into(), "bin-2".into()], + vec![ + TAG_RADROOTS_BIN.into(), + "bin-2".into(), + "500".into(), + "g".into(), + "1".into(), + "kg".into(), + ], + vec![ + TAG_RADROOTS_PRICE.into(), + "bin-2".into(), + "0.02".into(), + "USD".into(), + "1".into(), + "g".into(), + "10".into(), + ], + ]; + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string()); + } + + #[test] + fn listing_from_tags_rejects_missing_primary_bin_and_invalid_seller() { + let mut tags = base_trade_tags(); + tags.retain(|tag| tag[0] != TAG_RADROOTS_PRIMARY_BIN); + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRIMARY_BIN.to_string()); + + let err = listing_from_tags( + &base_trade_tags(), + listing_d_tag(), + farm_ref(), + "other".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_P.to_string()); + + let mut tags = base_trade_tags(); + tags[4][1] = "missing".into(); + let err = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .unwrap_err(); + assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRIMARY_BIN.to_string()); + } + + #[test] + fn parse_farm_and_reference_helpers_cover_all_paths() { + let valid_farm_tags = vec![vec![ + TAG_A.into(), + format!("{KIND_FARM}:seller:AAAAAAAAAAAAAAAAAAAAAA"), + ]]; + let farm = parse_farm_ref(&valid_farm_tags).unwrap(); + assert_eq!(farm.pubkey, farm_ref().pubkey); + assert_eq!(farm.d_tag, farm_ref().d_tag); + assert_eq!( + parse_error_tag(parse_farm_ref(&[]).unwrap_err()), + TAG_A.to_string() + ); + assert_eq!( + parse_error_tag(parse_farm_ref(&[vec![TAG_A.into()]]).unwrap_err()), + TAG_A.to_string() + ); + assert_eq!( + parse_error_tag(parse_farm_ref(&[vec![TAG_A.into(), "bad".into()]]).unwrap_err()), + TAG_A.to_string() + ); + assert_eq!( + parse_error_tag( + parse_farm_ref(&[vec![TAG_A.into(), format!("1:seller:{}", farm_ref().d_tag)]]) + .unwrap_err() + ), + TAG_A.to_string() + ); + assert_eq!( + parse_error_tag( + parse_farm_ref(&[vec![ + TAG_A.into(), + format!("{KIND_FARM}: :{}", farm_ref().d_tag) + ]]) + .unwrap_err() + ), + TAG_A.to_string() + ); + assert_eq!( + parse_error_tag( + parse_farm_ref(&[vec![TAG_A.into(), format!("{KIND_FARM}:seller:")]]).unwrap_err() + ), + TAG_A.to_string() + ); + assert_eq!( + parse_error_tag( + parse_farm_ref(&[vec![TAG_A.into(), format!("{KIND_FARM}")]]).unwrap_err() + ), + TAG_A.to_string() + ); + assert_eq!( + parse_error_tag( + parse_farm_ref(&[vec![TAG_A.into(), format!("{KIND_FARM}:seller")]]).unwrap_err() + ), + TAG_A.to_string() + ); + assert_eq!( + parse_error_tag( + parse_farm_ref(&[vec![TAG_A.into(), format!("{KIND_FARM}:seller:not-base64")]]) + .unwrap_err() + ), + TAG_A.to_string() + ); + + assert_eq!( + parse_farm_pubkey(&[vec![TAG_P.into(), "seller".into()]]).unwrap(), + "seller".to_string() + ); + assert_eq!( + parse_error_tag(parse_farm_pubkey(&[]).unwrap_err()), + TAG_P.to_string() + ); + assert_eq!( + parse_error_tag(parse_farm_pubkey(&[vec![TAG_P.into()]]).unwrap_err()), + TAG_P.to_string() + ); + assert_eq!( + parse_error_tag(parse_farm_pubkey(&[vec![TAG_P.into(), " ".into()]]).unwrap_err()), + TAG_P.to_string() + ); + + assert!(parse_resource_area(&[]).unwrap().is_none()); + let area_tag = vec![vec![ + TAG_RADROOTS_RESOURCE_AREA.into(), + format!("{KIND_RESOURCE_AREA}:seller:AAAAAAAAAAAAAAAAAAAAAQ"), + ]]; + assert!(parse_resource_area(&area_tag).unwrap().is_some()); + let missing_area = vec![vec![TAG_RADROOTS_RESOURCE_AREA.into()]]; + assert_eq!( + parse_error_tag(parse_resource_area(&missing_area).unwrap_err()), + TAG_RADROOTS_RESOURCE_AREA.to_string() + ); + let invalid_area_kind = vec![vec![TAG_RADROOTS_RESOURCE_AREA.into(), "bad".into()]]; + assert_eq!( + parse_error_tag(parse_resource_area(&invalid_area_kind).unwrap_err()), + TAG_RADROOTS_RESOURCE_AREA.to_string() + ); + let missing_area_pubkey = vec![vec![ + TAG_RADROOTS_RESOURCE_AREA.into(), + format!("{KIND_RESOURCE_AREA}"), + ]]; + assert_eq!( + parse_error_tag(parse_resource_area(&missing_area_pubkey).unwrap_err()), + TAG_RADROOTS_RESOURCE_AREA.to_string() + ); + let missing_area_d = vec![vec![ + TAG_RADROOTS_RESOURCE_AREA.into(), + format!("{KIND_RESOURCE_AREA}:seller"), + ]]; + assert_eq!( + parse_error_tag(parse_resource_area(&missing_area_d).unwrap_err()), + TAG_RADROOTS_RESOURCE_AREA.to_string() + ); + let bad_area = vec![vec![ + TAG_RADROOTS_RESOURCE_AREA.into(), + "1:seller:bad".into(), + ]]; + assert_eq!( + parse_error_tag(parse_resource_area(&bad_area).unwrap_err()), + TAG_RADROOTS_RESOURCE_AREA.to_string() + ); + let empty_area = vec![vec![ + TAG_RADROOTS_RESOURCE_AREA.into(), + format!("{KIND_RESOURCE_AREA}: :{}", listing_d_tag()), + ]]; + assert_eq!( + parse_error_tag(parse_resource_area(&empty_area).unwrap_err()), + TAG_RADROOTS_RESOURCE_AREA.to_string() + ); + let empty_area_d = vec![vec![ + TAG_RADROOTS_RESOURCE_AREA.into(), + format!("{KIND_RESOURCE_AREA}:seller:"), + ]]; + assert_eq!( + parse_error_tag(parse_resource_area(&empty_area_d).unwrap_err()), + TAG_RADROOTS_RESOURCE_AREA.to_string() + ); + let invalid_area_d = vec![vec![ + TAG_RADROOTS_RESOURCE_AREA.into(), + format!("{KIND_RESOURCE_AREA}:seller:not-base64"), + ]]; + assert_eq!( + parse_error_tag(parse_resource_area(&invalid_area_d).unwrap_err()), + TAG_RADROOTS_RESOURCE_AREA.to_string() + ); + + assert!(parse_plot_ref(&[]).unwrap().is_none()); + let plot_tag = vec![vec![ + TAG_RADROOTS_PLOT.into(), + format!("{KIND_PLOT}:seller:AAAAAAAAAAAAAAAAAAAAAQ"), + ]]; + assert!(parse_plot_ref(&plot_tag).unwrap().is_some()); + let missing_plot = vec![vec![TAG_RADROOTS_PLOT.into()]]; + assert_eq!( + parse_error_tag(parse_plot_ref(&missing_plot).unwrap_err()), + TAG_RADROOTS_PLOT.to_string() + ); + let missing_plot_pubkey = vec![vec![TAG_RADROOTS_PLOT.into(), format!("{KIND_PLOT}")]]; + assert_eq!( + parse_error_tag(parse_plot_ref(&missing_plot_pubkey).unwrap_err()), + TAG_RADROOTS_PLOT.to_string() + ); + let missing_plot_d = vec![vec![ + TAG_RADROOTS_PLOT.into(), + format!("{KIND_PLOT}:seller"), + ]]; + assert_eq!( + parse_error_tag(parse_plot_ref(&missing_plot_d).unwrap_err()), + TAG_RADROOTS_PLOT.to_string() + ); + let bad_plot = vec![vec![TAG_RADROOTS_PLOT.into(), "1:seller:bad".into()]]; + assert_eq!( + parse_error_tag(parse_plot_ref(&bad_plot).unwrap_err()), + TAG_RADROOTS_PLOT.to_string() + ); + let empty_plot = vec![vec![ + TAG_RADROOTS_PLOT.into(), + format!("{KIND_PLOT}: :{}", listing_d_tag()), + ]]; + assert_eq!( + parse_error_tag(parse_plot_ref(&empty_plot).unwrap_err()), + TAG_RADROOTS_PLOT.to_string() + ); + let empty_plot_d = vec![vec![ + TAG_RADROOTS_PLOT.into(), + format!("{KIND_PLOT}:seller:"), + ]]; + assert_eq!( + parse_error_tag(parse_plot_ref(&empty_plot_d).unwrap_err()), + TAG_RADROOTS_PLOT.to_string() + ); + let invalid_plot_d = vec![vec![ + TAG_RADROOTS_PLOT.into(), + format!("{KIND_PLOT}:seller:not-base64"), + ]]; + assert_eq!( + parse_error_tag(parse_plot_ref(&invalid_plot_d).unwrap_err()), + TAG_RADROOTS_PLOT.to_string() + ); + } + + #[test] + fn helper_functions_cover_assigners_and_classifiers() { + assert_eq!(clean_value(" value "), Some("value".into())); + assert_eq!(clean_value(" "), None); + assert_eq!(clean_value("null"), None); + + let mut s = String::new(); + let val = "one".to_string(); + set_if_empty(&mut s, Some(&val)); + assert_eq!(s, "one"); + let next = "two".to_string(); + set_if_empty(&mut s, Some(&next)); + assert_eq!(s, "one"); + let mut empty = String::new(); + let nullish = "null".to_string(); + set_if_empty(&mut empty, Some(&nullish)); + assert_eq!(empty, ""); + + let mut opt = None; + let v = "set".to_string(); + set_optional(&mut opt, Some(&v)); + assert_eq!(opt.as_deref(), Some("set")); + let w = "skip".to_string(); + set_optional(&mut opt, Some(&w)); + assert_eq!(opt.as_deref(), Some("set")); + let mut opt_none = None; + let blank = " ".to_string(); + set_optional(&mut opt_none, Some(&blank)); + assert_eq!(opt_none, None); + + assert!(matches!( + parse_status("ACTIVE"), + RadrootsListingStatus::Active + )); + assert!(matches!(parse_status("sold"), RadrootsListingStatus::Sold)); + assert_eq!( + format!("{:?}", parse_status("queued")), + "Other { value: \"queued\" }" + ); + + assert_eq!(parse_image_size("100x200").unwrap().w, 100); + assert!(parse_image_size("invalid").is_none()); + assert!(parse_image_size("100xbad").is_none()); + } + + #[test] + fn parse_discount_and_bin_helpers_cover_error_paths() { + let discount = RadrootsCoreDiscount { + scope: RadrootsCoreDiscountScope::OrderTotal, + threshold: RadrootsCoreDiscountThreshold::OrderQuantity { + min: RadrootsCoreQuantity::new("1".parse().unwrap(), RadrootsCoreUnit::MassG), + }, + value: RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new( + "1".parse().unwrap(), + RadrootsCoreCurrency::USD, + )), + }; + let payload = serde_json::to_string(&discount).unwrap(); + assert!(parse_discount(&payload).is_ok()); + assert_eq!( + parse_error_tag(parse_discount("{").unwrap_err()), + TAG_RADROOTS_DISCOUNT.to_string() + ); + assert_eq!( + parse_error_tag(TradeListingParseError::InvalidJson("x".into())), + "x".to_string() + ); + + let mut drafts = Vec::new(); + let mut order_index = 0usize; + let first = upsert_bin(&mut drafts, "a", &mut order_index); + first.quantity = Some(RadrootsCoreQuantity::new( + "1".parse().unwrap(), + RadrootsCoreUnit::MassG, + )); + first.price_per_canonical_unit = Some(RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new("1".parse().unwrap(), RadrootsCoreCurrency::USD), + RadrootsCoreQuantity::new("1".parse().unwrap(), RadrootsCoreUnit::MassG), + )); + + let second = upsert_bin(&mut drafts, "a", &mut order_index); + assert_eq!(second.order_index, 0); + assert_eq!(order_index, 1); + assert!(build_bins(drafts).is_ok()); + + let draft_missing_qty = BinDraft { + bin_id: "b".into(), + order_index: 0, + quantity: None, + display_amount: None, + display_unit: None, + display_label: None, + price_per_canonical_unit: Some(RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new("1".parse().unwrap(), RadrootsCoreCurrency::USD), + RadrootsCoreQuantity::new("1".parse().unwrap(), RadrootsCoreUnit::MassG), + )), + display_price: None, + display_price_unit: None, + }; + assert_eq!( + parse_error_tag(build_bins(vec![draft_missing_qty]).unwrap_err()), + TAG_RADROOTS_BIN.to_string() + ); + + let draft_missing_price = BinDraft { + bin_id: "b".into(), + order_index: 0, + quantity: Some(RadrootsCoreQuantity::new( + "1".parse().unwrap(), + RadrootsCoreUnit::MassG, + )), + display_amount: None, + display_unit: None, + display_label: None, + price_per_canonical_unit: None, + display_price: None, + display_price_unit: None, + }; + assert_eq!( + parse_error_tag(build_bins(vec![draft_missing_price]).unwrap_err()), + TAG_RADROOTS_PRICE.to_string() + ); + + let draft_mismatch = BinDraft { + bin_id: "b".into(), + order_index: 0, + quantity: Some(RadrootsCoreQuantity::new( + "1".parse().unwrap(), + RadrootsCoreUnit::MassG, + )), + display_amount: None, + display_unit: None, + display_label: None, + price_per_canonical_unit: Some(RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new("1".parse().unwrap(), RadrootsCoreCurrency::USD), + RadrootsCoreQuantity::new("1".parse().unwrap(), RadrootsCoreUnit::Each), + )), + display_price: None, + display_price_unit: None, + }; + assert_eq!( + parse_error_tag(build_bins(vec![draft_mismatch]).unwrap_err()), + TAG_RADROOTS_PRICE.to_string() + ); + + let tags = vec![ + vec!["key".into(), "coffee".into()], + vec!["title".into(), "Coffee".into()], + vec!["category".into(), "coffee".into()], + vec![TAG_RADROOTS_PRIMARY_BIN.into(), "bin-2".into()], + vec![ + TAG_RADROOTS_BIN.into(), + "bin-2".into(), + "500".into(), + "g".into(), + ], + vec![ + TAG_RADROOTS_PRICE.into(), + "bin-2".into(), + "0.02".into(), + "USD".into(), + "1".into(), + "g".into(), + ], + vec![TAG_GEOHASH.into()], + ]; + let listing = listing_from_tags( + &tags, + listing_d_tag(), + farm_ref(), + "seller".to_string(), + None, + None, + ) + .expect("compact listing"); + assert_eq!(listing.primary_bin_id, "bin-2"); } } diff --git a/crates/trade/src/listing/dvm.rs b/crates/trade/src/listing/dvm.rs @@ -374,8 +374,8 @@ pub enum TradeListingMessagePayload { #[cfg(test)] mod tests { use super::{ - TradeListingEnvelope, TradeListingEnvelopeError, TradeListingMessageType, - TradeListingValidateRequest, + TradeListingAddress, TradeListingAddressError, TradeListingEnvelope, + TradeListingEnvelopeError, TradeListingMessageType, TradeListingValidateRequest, }; use radroots_events::kinds::KIND_LISTING; @@ -411,6 +411,166 @@ mod tests { ); } + #[test] + fn envelope_accepts_non_empty_order_id_for_order_scoped() { + let env = TradeListingEnvelope::new( + TradeListingMessageType::OrderRequest, + format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"), + Some("order-1".to_string()), + TradeListingValidateRequest { + listing_event: None, + }, + ); + assert!(env.validate().is_ok()); + } + + #[test] + fn envelope_rejects_blank_order_id_for_order_scoped() { + let env = TradeListingEnvelope::new( + TradeListingMessageType::OrderRequest, + format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"), + Some(" ".to_string()), + TradeListingValidateRequest { + listing_event: None, + }, + ); + assert_eq!( + env.validate().unwrap_err(), + TradeListingEnvelopeError::MissingOrderId + ); + } + + #[test] + fn envelope_accepts_non_order_message_without_order_id() { + let env = TradeListingEnvelope::new( + TradeListingMessageType::ListingValidateResult, + format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"), + None, + TradeListingValidateRequest { + listing_event: None, + }, + ); + assert!(env.validate().is_ok()); + } + + #[test] + fn message_type_kind_and_request_flags_cover_all_variants() { + let cases = [ + (TradeListingMessageType::ListingValidateRequest, true, false), + (TradeListingMessageType::ListingValidateResult, false, true), + (TradeListingMessageType::OrderRequest, true, false), + (TradeListingMessageType::OrderResponse, false, true), + (TradeListingMessageType::OrderRevision, true, false), + (TradeListingMessageType::OrderRevisionAccept, false, true), + (TradeListingMessageType::OrderRevisionDecline, false, true), + (TradeListingMessageType::Question, true, false), + (TradeListingMessageType::Answer, false, true), + (TradeListingMessageType::DiscountRequest, true, false), + (TradeListingMessageType::DiscountOffer, false, true), + (TradeListingMessageType::DiscountAccept, true, false), + (TradeListingMessageType::DiscountDecline, true, false), + (TradeListingMessageType::Cancel, true, false), + (TradeListingMessageType::FulfillmentUpdate, true, false), + (TradeListingMessageType::Receipt, true, false), + ]; + + for (message_type, is_request, is_result) in cases { + assert_eq!(message_type.is_request(), is_request); + assert_eq!(message_type.is_result(), is_result); + assert!(message_type.kind() > 0); + } + } + + #[test] + fn envelope_validate_rejects_invalid_version() { + let mut env = TradeListingEnvelope::new( + TradeListingMessageType::ListingValidateRequest, + format!("{KIND_LISTING}:pubkey:AAAAAAAAAAAAAAAAAAAAAg"), + None, + TradeListingValidateRequest { + listing_event: None, + }, + ); + env.version = 9; + assert_eq!( + env.validate().unwrap_err(), + TradeListingEnvelopeError::InvalidVersion { + expected: super::TRADE_LISTING_ENVELOPE_VERSION, + got: 9 + } + ); + } + + #[test] + fn envelope_error_display_messages_are_stable() { + assert_eq!( + TradeListingEnvelopeError::MissingOrderId.to_string(), + "missing order_id for order-scoped message" + ); + assert_eq!( + TradeListingEnvelopeError::MissingListingAddr.to_string(), + "missing listing_addr" + ); + assert!( + TradeListingEnvelopeError::InvalidVersion { + expected: 1, + got: 2 + } + .to_string() + .contains("expected 1, got 2") + ); + } + + #[test] + fn trade_listing_address_parse_and_render_roundtrip() { + let addr_raw = format!("{KIND_LISTING}:seller:AAAAAAAAAAAAAAAAAAAAAg"); + let parsed = TradeListingAddress::parse(&addr_raw).expect("valid address"); + assert_eq!(parsed.kind, KIND_LISTING as u16); + assert_eq!(parsed.seller_pubkey, "seller"); + assert_eq!(parsed.listing_id, "AAAAAAAAAAAAAAAAAAAAAg"); + assert_eq!(parsed.as_str(), addr_raw); + } + + #[test] + fn trade_listing_address_parse_rejects_invalid_shapes() { + assert_eq!( + TradeListingAddress::parse("not-a-kind:seller:AAAAAAAAAAAAAAAAAAAAAg").unwrap_err(), + TradeListingAddressError::InvalidFormat + ); + assert_eq!( + TradeListingAddress::parse("30340:seller").unwrap_err(), + TradeListingAddressError::InvalidFormat + ); + assert_eq!( + TradeListingAddress::parse("30340:seller:AAAAAAAAAAAAAAAAAAAAAg:extra").unwrap_err(), + TradeListingAddressError::InvalidFormat + ); + assert_eq!( + TradeListingAddress::parse("0:seller:AAAAAAAAAAAAAAAAAAAAAg").unwrap_err(), + TradeListingAddressError::InvalidFormat + ); + assert_eq!( + TradeListingAddress::parse("30340: :AAAAAAAAAAAAAAAAAAAAAg").unwrap_err(), + TradeListingAddressError::InvalidFormat + ); + assert_eq!( + TradeListingAddress::parse("30340:seller: ").unwrap_err(), + TradeListingAddressError::InvalidFormat + ); + assert_eq!( + TradeListingAddress::parse("30340:seller:not-base64").unwrap_err(), + TradeListingAddressError::InvalidFormat + ); + } + + #[test] + fn trade_listing_address_error_display_message_is_stable() { + assert_eq!( + TradeListingAddressError::InvalidFormat.to_string(), + "invalid listing address format" + ); + } + #[cfg(feature = "serde_json")] #[test] fn envelope_event_build_includes_order_tag() { diff --git a/crates/trade/src/listing/dvm_kinds.rs b/crates/trade/src/listing/dvm_kinds.rs @@ -106,3 +106,57 @@ pub const fn is_trade_listing_dvm_result_kind(kind: u16) -> bool { pub const fn is_trade_listing_dvm_kind(kind: u16) -> bool { is_trade_listing_dvm_request_kind(kind) || is_trade_listing_dvm_result_kind(kind) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_request_and_result_kinds() { + for kind in [ + KIND_TRADE_LISTING_VALIDATE_REQ, + KIND_TRADE_LISTING_ORDER_REQ, + KIND_TRADE_LISTING_ORDER_REVISION_REQ, + KIND_TRADE_LISTING_QUESTION_REQ, + KIND_TRADE_LISTING_DISCOUNT_REQ, + KIND_TRADE_LISTING_DISCOUNT_ACCEPT_REQ, + KIND_TRADE_LISTING_DISCOUNT_DECLINE_REQ, + KIND_TRADE_LISTING_CANCEL_REQ, + KIND_TRADE_LISTING_FULFILLMENT_UPDATE_REQ, + KIND_TRADE_LISTING_RECEIPT_REQ, + ] { + assert!(is_trade_listing_dvm_request_kind(kind)); + assert!(is_trade_listing_dvm_kind(kind)); + assert!(!is_trade_listing_dvm_result_kind(kind)); + } + + for kind in [ + KIND_TRADE_LISTING_VALIDATE_RES, + KIND_TRADE_LISTING_ORDER_RES, + KIND_TRADE_LISTING_ORDER_REVISION_RES, + KIND_TRADE_LISTING_ANSWER_RES, + KIND_TRADE_LISTING_DISCOUNT_OFFER_RES, + ] { + assert!(is_trade_listing_dvm_result_kind(kind)); + assert!(is_trade_listing_dvm_kind(kind)); + assert!(!is_trade_listing_dvm_request_kind(kind)); + } + } + + #[test] + fn rejects_non_trade_dvm_kind() { + assert!(!is_trade_listing_dvm_kind(5000)); + assert!(!is_trade_listing_dvm_request_kind(5000)); + assert!(!is_trade_listing_dvm_result_kind(5000)); + } + + #[test] + fn dvm_kind_array_contains_expected_kind_values() { + assert_eq!(TRADE_LISTING_DVM_KINDS.len(), 15); + assert!(TRADE_LISTING_DVM_KINDS.contains(&KIND_TRADE_LISTING_VALIDATE_REQ)); + assert!(TRADE_LISTING_DVM_KINDS.contains(&KIND_TRADE_LISTING_VALIDATE_RES)); + assert!(TRADE_LISTING_DVM_KINDS.contains(&KIND_TRADE_LISTING_ORDER_REQ)); + assert!(TRADE_LISTING_DVM_KINDS.contains(&KIND_TRADE_LISTING_ORDER_RES)); + assert!(TRADE_LISTING_DVM_KINDS.contains(&KIND_TRADE_LISTING_RECEIPT_REQ)); + } +} diff --git a/crates/trade/src/listing/meta.rs b/crates/trade/src/listing/meta.rs @@ -258,9 +258,11 @@ impl FromStr for TradeListingStage { #[cfg(test)] mod tests { use super::{ - MARKER_CONVEYANCE_RESULT, MARKER_FULFILLMENT_RESULT, MARKER_INVOICE_RESULT, MARKER_LISTING, - MARKER_ORDER_RESULT, MARKER_PAYLOAD, MARKER_PAYMENT_RESULT, MARKER_PROOF, - MARKER_RECEIPT_RESULT, TradeListingStage, TradeListingStageParseError, + MARKER_ACCEPT_RESULT, MARKER_CANCEL_RESULT, MARKER_CONVEYANCE_RESULT, + MARKER_FULFILLMENT_RESULT, MARKER_INVOICE_RESULT, MARKER_LISTING, MARKER_ORDER_RESULT, + MARKER_PAYLOAD, MARKER_PAYMENT_RESULT, MARKER_PREVIOUS, MARKER_PROOF, + MARKER_RECEIPT_RESULT, MARKER_REFUND_RESULT, TradeListingStage, + TradeListingStageParseError, }; #[test] @@ -356,4 +358,95 @@ mod tests { MARKER_RECEIPT_RESULT ); } + + #[test] + fn marker_as_str_covers_all_variants() { + let cases = [ + (super::TradeListingMarker::Listing, "listing"), + (super::TradeListingMarker::Payload, "payload"), + (super::TradeListingMarker::Previous, "previous"), + (super::TradeListingMarker::OrderResult, "order_result"), + (super::TradeListingMarker::AcceptResult, "accept_result"), + ( + super::TradeListingMarker::ConveyanceResult, + "conveyance_result", + ), + (super::TradeListingMarker::InvoiceResult, "invoice_result"), + (super::TradeListingMarker::PaymentResult, "payment_result"), + ( + super::TradeListingMarker::FulfillmentResult, + "fulfillment_result", + ), + (super::TradeListingMarker::ReceiptResult, "receipt_result"), + (super::TradeListingMarker::CancelResult, "cancel_result"), + (super::TradeListingMarker::RefundResult, "refund_result"), + (super::TradeListingMarker::Proof, "proof"), + ]; + for (marker, name) in cases { + assert_eq!(marker.as_str(), name); + } + } + + #[test] + fn marker_constants_match_marker_strings() { + assert_eq!(MARKER_LISTING, super::TradeListingMarker::Listing.as_str()); + assert_eq!(MARKER_PAYLOAD, super::TradeListingMarker::Payload.as_str()); + assert_eq!( + MARKER_PREVIOUS, + super::TradeListingMarker::Previous.as_str() + ); + assert_eq!( + MARKER_ACCEPT_RESULT, + super::TradeListingMarker::AcceptResult.as_str() + ); + assert_eq!( + MARKER_CANCEL_RESULT, + super::TradeListingMarker::CancelResult.as_str() + ); + assert_eq!( + MARKER_REFUND_RESULT, + super::TradeListingMarker::RefundResult.as_str() + ); + } + + #[test] + fn stage_marker_tables_cover_all_variants_and_unknown_kinds() { + let request_cases = [ + ( + TradeListingStage::Conveyance, + vec![MARKER_ACCEPT_RESULT, MARKER_PAYLOAD], + ), + (TradeListingStage::Invoice, vec![MARKER_ACCEPT_RESULT]), + ( + TradeListingStage::Cancel, + vec![MARKER_PREVIOUS, MARKER_PAYLOAD], + ), + ]; + for (stage, expected) in request_cases { + assert_eq!(stage.request_markers(), expected.as_slice()); + } + + let result_cases = [ + (TradeListingStage::Accept, MARKER_ACCEPT_RESULT), + (TradeListingStage::Invoice, MARKER_INVOICE_RESULT), + (TradeListingStage::Payment, MARKER_PAYMENT_RESULT), + (TradeListingStage::Fulfillment, MARKER_FULFILLMENT_RESULT), + (TradeListingStage::Cancel, MARKER_CANCEL_RESULT), + (TradeListingStage::Refund, MARKER_REFUND_RESULT), + ]; + for (stage, expected) in result_cases { + assert_eq!(stage.result_marker(), expected); + } + + assert_eq!(TradeListingStage::from_request_kind(0), None); + assert_eq!(TradeListingStage::from_result_kind(0), None); + } + + #[test] + fn stage_parse_error_display_is_stable() { + assert_eq!( + TradeListingStageParseError::UnknownStage.to_string(), + "unknown trade listing stage" + ); + } } diff --git a/crates/trade/src/listing/price_ext.rs b/crates/trade/src/listing/price_ext.rs @@ -89,13 +89,32 @@ impl BinPricingTryExt for RadrootsListingBin { #[cfg(test)] mod tests { - use super::BinPricingTryExt; + use super::{BinPricingExt, BinPricingTryExt}; use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreQuantityPriceError, RadrootsCoreUnit, }; use radroots_events::listing::RadrootsListingBin; + fn valid_bin() -> RadrootsListingBin { + RadrootsListingBin { + bin_id: "bin-1".into(), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(2u32), + RadrootsCoreUnit::MassG, + ), + price_per_canonical_unit: RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new(RadrootsCoreDecimal::from(5u32), RadrootsCoreCurrency::USD), + RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::MassG), + ), + display_amount: None, + display_unit: None, + display_label: None, + display_price: None, + display_price_unit: None, + } + } + #[test] fn try_subtotal_for_rejects_unit_mismatch() { let bin = RadrootsListingBin { @@ -124,4 +143,39 @@ mod tests { } ); } + + #[test] + fn subtotal_and_total_for_count_follow_effective_quantity() { + let bin = valid_bin(); + let subtotal = bin.subtotal_for_count(3); + let total = bin.total_for_count(3); + + assert_eq!(subtotal.quantity_amount, RadrootsCoreDecimal::from(6u32)); + assert_eq!(subtotal.quantity_unit, RadrootsCoreUnit::MassG); + assert_eq!( + subtotal.price_amount.amount, + RadrootsCoreDecimal::from(30u32) + ); + assert_eq!(subtotal.price_currency, RadrootsCoreCurrency::USD); + + assert_eq!(total.quantity_amount, subtotal.quantity_amount); + assert_eq!(total.quantity_unit, subtotal.quantity_unit); + assert_eq!(total.price_amount, subtotal.price_amount); + assert_eq!(total.price_currency, subtotal.price_currency); + } + + #[test] + fn try_subtotal_and_try_total_match_non_fallible_paths() { + let bin = valid_bin(); + let subtotal = bin.try_subtotal_for_count(4).expect("subtotal"); + let total = bin.try_total_for_count(4).expect("total"); + + assert_eq!(subtotal.quantity_amount, RadrootsCoreDecimal::from(8u32)); + assert_eq!( + subtotal.price_amount.amount, + RadrootsCoreDecimal::from(40u32) + ); + assert_eq!(total.quantity_amount, subtotal.quantity_amount); + assert_eq!(total.price_amount, subtotal.price_amount); + } } diff --git a/crates/trade/src/listing/tags.rs b/crates/trade/src/listing/tags.rs @@ -84,27 +84,24 @@ pub fn validate_trade_listing_chain(tags: &[Vec<String>]) -> Result<(), JobParse } _ => {} } - - if has_root && has_d { - return Ok(()); - } } if !has_root { - return Err(JobParseError::MissingChainTag(TAG_E_ROOT)); - } - if !has_d { - return Err(JobParseError::MissingChainTag(TAG_D)); + Err(JobParseError::MissingChainTag(TAG_E_ROOT)) + } else if !has_d { + Err(JobParseError::MissingChainTag(TAG_D)) + } else { + Ok(()) } - Ok(()) } #[cfg(test)] mod tests { - use super::{trade_listing_dvm_tags, validate_trade_listing_chain}; + use super::{ + push_trade_listing_chain_tags, trade_listing_dvm_tags, validate_trade_listing_chain, + }; use radroots_events::kinds::KIND_LISTING; - use radroots_events::tags::{TAG_D, TAG_E_ROOT}; - use radroots_events_codec::job::error::JobParseError; + use radroots_events::tags::{TAG_D, TAG_E_PREV, TAG_E_ROOT}; #[test] fn validate_trade_listing_chain_ok() { @@ -118,12 +115,11 @@ mod tests { #[test] fn validate_trade_listing_chain_rejects_missing_root() { let tags = vec![vec![TAG_D.into(), "trade".into()]]; - match validate_trade_listing_chain(&tags) { - Err(JobParseError::MissingChainTag(tag)) => { - assert_eq!(tag, TAG_E_ROOT); - } - other => panic!("expected missing root tag, got {other:?}"), - } + let err = validate_trade_listing_chain(&tags).unwrap_err(); + assert_eq!( + err.to_string(), + format!("missing required chain tag: {TAG_E_ROOT}") + ); } #[test] @@ -132,12 +128,74 @@ mod tests { vec![TAG_E_ROOT.into(), " ".into()], vec![TAG_D.into(), "trade".into()], ]; - match validate_trade_listing_chain(&tags) { - Err(JobParseError::InvalidTag(tag)) => { - assert_eq!(tag, TAG_E_ROOT); - } - other => panic!("expected invalid root tag, got {other:?}"), - } + let err = validate_trade_listing_chain(&tags).unwrap_err(); + assert_eq!( + err.to_string(), + format!("invalid tag structure for '{TAG_E_ROOT}'") + ); + } + + #[test] + fn validate_trade_listing_chain_rejects_root_without_value() { + let tags = vec![vec![TAG_E_ROOT.into()], vec![TAG_D.into(), "trade".into()]]; + let err = validate_trade_listing_chain(&tags).unwrap_err(); + assert_eq!( + err.to_string(), + format!("invalid tag structure for '{TAG_E_ROOT}'") + ); + } + + #[test] + fn validate_trade_listing_chain_rejects_missing_trade_id() { + let tags = vec![vec![TAG_E_ROOT.into(), "root".into()]]; + let err = validate_trade_listing_chain(&tags).unwrap_err(); + assert_eq!( + err.to_string(), + format!("missing required chain tag: {TAG_D}") + ); + } + + #[test] + fn validate_trade_listing_chain_rejects_empty_trade_id() { + let tags = vec![ + vec![TAG_E_ROOT.into(), "root".into()], + vec![TAG_D.into(), " ".into()], + ]; + let err = validate_trade_listing_chain(&tags).unwrap_err(); + assert_eq!( + err.to_string(), + format!("invalid tag structure for '{TAG_D}'") + ); + } + + #[test] + fn validate_trade_listing_chain_rejects_trade_id_without_value() { + let tags = vec![vec![TAG_E_ROOT.into(), "root".into()], vec![TAG_D.into()]]; + let err = validate_trade_listing_chain(&tags).unwrap_err(); + assert_eq!( + err.to_string(), + format!("invalid tag structure for '{TAG_D}'") + ); + } + + #[test] + fn validate_trade_listing_chain_accepts_trade_id_before_root() { + let tags = vec![ + vec![TAG_D.into(), "trade".into()], + vec!["x".into(), "ignore".into()], + vec![TAG_E_ROOT.into(), "root".into()], + ]; + assert!(validate_trade_listing_chain(&tags).is_ok()); + } + + #[test] + fn validate_trade_listing_chain_ignores_unknown_single_key_tag() { + let tags = vec![ + vec!["x".into()], + vec![TAG_E_ROOT.into(), "root".into()], + vec![TAG_D.into(), "trade".into()], + ]; + assert!(validate_trade_listing_chain(&tags).is_ok()); } #[test] @@ -162,4 +220,41 @@ mod tests { ]; assert_eq!(tags, expected); } + + #[test] + fn push_trade_listing_chain_tags_appends_optional_fields() { + let mut tags = vec![vec![String::from("x"), String::from("seed")]]; + push_trade_listing_chain_tags( + &mut tags, + "root-id", + Some("prev-id".to_string()), + Some("trade-id".to_string()), + ); + + assert_eq!( + tags, + vec![ + vec![String::from("x"), String::from("seed")], + vec![String::from(TAG_E_ROOT), String::from("root-id")], + vec![String::from(TAG_E_PREV), String::from("prev-id")], + vec![String::from(TAG_D), String::from("trade-id")], + ] + ); + } + + #[test] + fn push_trade_listing_chain_tags_skips_missing_optional_fields() { + let mut tags = Vec::new(); + push_trade_listing_chain_tags( + &mut tags, + "root-id", + Option::<String>::None, + Option::<String>::None, + ); + + assert_eq!( + tags, + vec![vec![String::from(TAG_E_ROOT), String::from("root-id")]] + ); + } } diff --git a/crates/trade/src/listing/validation.rs b/crates/trade/src/listing/validation.rs @@ -147,9 +147,6 @@ pub fn validate_listing_event( let listing = listing_from_event_parts(&event.tags, &event.content) .map_err(|error| TradeListingValidationError::ParseError { error })?; let listing_id = listing.d_tag.trim().to_string(); - if listing_id.is_empty() { - return Err(TradeListingValidationError::MissingListingId); - } let seller_pubkey = event.author.clone(); if listing.farm.pubkey != seller_pubkey { @@ -361,6 +358,12 @@ mod tests { } } + fn assert_validation_err(listing: RadrootsListing, expected: TradeListingValidationError) { + let event = base_event(&listing); + let err = validate_listing_event(&event).unwrap_err(); + assert_eq!(format!("{err}"), format!("{expected}")); + } + #[test] fn validate_listing_ok() { let listing = base_listing(); @@ -439,4 +442,158 @@ mod tests { let err = validate_listing_event(&event).unwrap_err(); assert!(matches!(err, TradeListingValidationError::MissingInventory)); } + + #[test] + fn validate_listing_rejects_invalid_kind() { + let listing = base_listing(); + let mut event = base_event(&listing); + event.kind = 0; + let err = validate_listing_event(&event).unwrap_err(); + assert!(matches!( + err, + TradeListingValidationError::InvalidKind { kind: 0 } + )); + } + + #[test] + fn validate_listing_rejects_missing_title() { + let mut listing = base_listing(); + listing.product.title = " ".into(); + assert_validation_err(listing, TradeListingValidationError::MissingTitle); + } + + #[test] + fn validate_listing_rejects_missing_description() { + let mut listing = base_listing(); + listing.product.summary = Some(" ".into()); + assert_validation_err(listing, TradeListingValidationError::MissingDescription); + } + + #[test] + fn validate_listing_rejects_missing_product_type() { + let mut listing = base_listing(); + listing.product.category = " ".into(); + listing.product.key = " ".into(); + assert_validation_err(listing, TradeListingValidationError::MissingProductType); + } + + #[test] + fn validate_listing_rejects_missing_bins() { + let mut listing = base_listing(); + listing.bins.clear(); + assert_validation_err(listing, TradeListingValidationError::MissingBins); + } + + #[test] + fn validate_listing_rejects_missing_primary_bin_id() { + let mut listing = base_listing(); + listing.primary_bin_id = " ".into(); + assert_validation_err(listing, TradeListingValidationError::MissingPrimaryBin); + } + + #[test] + fn validate_listing_rejects_primary_bin_not_found() { + let mut listing = base_listing(); + listing.primary_bin_id = "missing".into(); + assert_validation_err(listing, TradeListingValidationError::MissingPrimaryBin); + } + + #[test] + fn validate_listing_rejects_negative_quantity() { + let mut listing = base_listing(); + listing.bins[0].quantity.amount = "-1".parse().unwrap(); + assert_validation_err(listing, TradeListingValidationError::InvalidBin); + } + + #[test] + fn validate_listing_rejects_non_canonical_quantity() { + let mut listing = base_listing(); + listing.bins[0].quantity.unit = RadrootsCoreUnit::MassKg; + assert_validation_err(listing, TradeListingValidationError::InvalidBin); + } + + #[test] + fn validate_listing_rejects_non_canonical_price_quantity() { + let mut listing = base_listing(); + listing.bins[0].price_per_canonical_unit.quantity.unit = RadrootsCoreUnit::MassKg; + assert_validation_err(listing, TradeListingValidationError::InvalidPrice); + } + + #[test] + fn validate_listing_rejects_negative_price_amount() { + let mut listing = base_listing(); + listing.bins[0].price_per_canonical_unit.amount.amount = "-1".parse().unwrap(); + assert_validation_err(listing, TradeListingValidationError::InvalidPrice); + } + + #[test] + fn validate_listing_rejects_price_unit_mismatch() { + let mut listing = base_listing(); + listing.bins[0].price_per_canonical_unit.quantity.unit = RadrootsCoreUnit::Each; + assert_validation_err(listing, TradeListingValidationError::InvalidPrice); + } + + #[test] + fn validate_listing_rejects_negative_inventory() { + let mut listing = base_listing(); + listing.inventory_available = Some("-1".parse().unwrap()); + assert_validation_err(listing, TradeListingValidationError::InvalidInventory); + } + + #[test] + fn validate_listing_rejects_missing_availability() { + let mut listing = base_listing(); + listing.availability = None; + assert_validation_err(listing, TradeListingValidationError::MissingAvailability); + } + + #[test] + fn validate_listing_rejects_missing_location() { + let mut listing = base_listing(); + listing.location = None; + assert_validation_err(listing, TradeListingValidationError::MissingLocation); + } + + #[test] + fn validate_listing_rejects_missing_delivery_method() { + let mut listing = base_listing(); + listing.delivery_method = None; + assert_validation_err(listing, TradeListingValidationError::MissingDeliveryMethod); + } + + #[test] + fn validation_error_display_covers_all_variants() { + let errors = vec![ + TradeListingValidationError::InvalidKind { kind: 9 }, + TradeListingValidationError::MissingListingId, + TradeListingValidationError::ListingEventNotFound { + listing_addr: "addr".into(), + }, + TradeListingValidationError::ListingEventFetchFailed { + listing_addr: "addr".into(), + }, + TradeListingValidationError::ParseError { + error: crate::listing::codec::TradeListingParseError::InvalidTag("d".into()), + }, + TradeListingValidationError::InvalidSeller, + TradeListingValidationError::MissingFarmProfile, + TradeListingValidationError::MissingFarmRecord, + TradeListingValidationError::MissingTitle, + TradeListingValidationError::MissingDescription, + TradeListingValidationError::MissingProductType, + TradeListingValidationError::MissingBins, + TradeListingValidationError::MissingPrimaryBin, + TradeListingValidationError::InvalidBin, + TradeListingValidationError::MissingPrice, + TradeListingValidationError::InvalidPrice, + TradeListingValidationError::MissingInventory, + TradeListingValidationError::InvalidInventory, + TradeListingValidationError::MissingAvailability, + TradeListingValidationError::MissingLocation, + TradeListingValidationError::MissingDeliveryMethod, + ]; + for error in errors { + assert!(!error.to_string().trim().is_empty()); + } + } }