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:
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());
+ }
+ }
}