commit a0b2fe024290110bbea841be3431471adbb0e4a3
parent ea524185af5f08f758e283f6f30000e79e5f5c45
Author: triesap <tyson@radroots.org>
Date: Thu, 5 Mar 2026 19:31:50 +0000
events-codec: expand tag validation and list_set coverage
- adjust tag/list_set helpers for required field checks and consistent branching
- add non-serde encode coverage plus message file and listing tag test suites
- broaden list_set builders and parsing tests for coop, farm, and resource area flows
- keep encoding and parsing invariants aligned with new error-path coverage
Diffstat:
34 files changed, 3505 insertions(+), 91 deletions(-)
diff --git a/crates/events-codec/src/coop/list_sets.rs b/crates/events-codec/src/coop/list_sets.rs
@@ -24,9 +24,6 @@ fn coop_list_set_id(coop_id: &str, suffix: &str) -> Result<String, EventEncodeEr
return Err(EventEncodeError::EmptyRequiredField("coop_id"));
}
validate_d_tag(coop_id, "coop_id")?;
- if suffix.trim().is_empty() {
- return Err(EventEncodeError::EmptyRequiredField("list_set_suffix"));
- }
Ok(format!("coop:{coop_id}:{suffix}"))
}
@@ -187,14 +184,7 @@ mod tests {
use super::*;
#[test]
- fn coop_list_set_id_validates_suffix_and_coop_id() {
- let err = coop_list_set_id("AAAAAAAAAAAAAAAAAAAAAQ", " ")
- .expect_err("expected suffix validation error");
- assert!(matches!(
- err,
- EventEncodeError::EmptyRequiredField("list_set_suffix")
- ));
-
+ fn coop_list_set_id_validates_coop_id() {
let err = coop_list_set_id(" ", "members").expect_err("expected coop_id validation error");
assert!(matches!(
err,
@@ -212,8 +202,47 @@ mod tests {
}
#[test]
+ fn list_entries_cover_string_iterators() {
+ let entries = list_entries("p", vec!["member".to_string()]).expect("valid string entries");
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].values[0], "member");
+
+ let err = list_entries("p", vec![" ".to_string()]).expect_err("blank string entry");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("entry.values")
+ ));
+ }
+
+ #[test]
+ fn list_entries_accepts_empty_iterators() {
+ let entries = list_entries("p", Vec::<&str>::new()).expect("empty list entries");
+ assert!(entries.is_empty());
+
+ let entries = list_entries("p", vec!["member"]).expect("non-empty vec<&str> entries");
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].values[0], "member");
+
+ let err = list_entries("p", vec![" "]).expect_err("blank vec<&str> entry");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("entry.values")
+ ));
+ }
+
+ #[test]
fn farm_address_rejects_empty_and_invalid_d_tag() {
let err = farm_address(&RadrootsFarmRef {
+ pubkey: " ".to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
+ })
+ .expect_err("expected empty pubkey error");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("farm.pubkey")
+ ));
+
+ let err = farm_address(&RadrootsFarmRef {
pubkey: "58e318557257f2ab58a415d21bb57082b4824cf667a1d64e72bcbc5acc018c62".to_string(),
d_tag: " ".to_string(),
})
@@ -230,4 +259,88 @@ mod tests {
.expect_err("expected invalid d_tag error");
assert!(matches!(err, EventEncodeError::InvalidField("farm.d_tag")));
}
+
+ #[test]
+ fn coop_list_set_builders_cover_success_and_error_paths() {
+ let coop_id = "AAAAAAAAAAAAAAAAAAAAAA";
+
+ let members = coop_members_list_set(coop_id, ["member-a"]).expect("members list set");
+ assert_eq!(members.d_tag, "coop:AAAAAAAAAAAAAAAAAAAAAA:members");
+ assert_eq!(members.entries.len(), 1);
+ assert_eq!(members.entries[0].tag, "p");
+
+ let err =
+ coop_members_list_set("invalid", ["member-a"]).expect_err("expected invalid coop_id");
+ assert!(matches!(err, EventEncodeError::InvalidField("coop_id")));
+
+ let err =
+ coop_members_list_set(coop_id, [" "]).expect_err("expected invalid members entry");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("entry.values")
+ ));
+
+ let owners = coop_owners_list_set(coop_id, ["owner-a"]).expect("owners list set");
+ assert_eq!(owners.d_tag, "coop:AAAAAAAAAAAAAAAAAAAAAA:members.owners");
+ assert_eq!(owners.entries[0].tag, "p");
+ let err = coop_owners_list_set("invalid", ["owner-a"]).expect_err("invalid coop_id");
+ assert!(matches!(err, EventEncodeError::InvalidField("coop_id")));
+ let err = coop_owners_list_set(coop_id, [" "]).expect_err("expected invalid owner entry");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("entry.values")
+ ));
+
+ let admins = coop_admins_list_set(coop_id, ["admin-a"]).expect("admins list set");
+ assert_eq!(admins.d_tag, "coop:AAAAAAAAAAAAAAAAAAAAAA:members.admins");
+ assert_eq!(admins.entries[0].tag, "p");
+ let err = coop_admins_list_set("invalid", ["admin-a"]).expect_err("invalid coop_id");
+ assert!(matches!(err, EventEncodeError::InvalidField("coop_id")));
+ let err = coop_admins_list_set(coop_id, [" "]).expect_err("expected invalid admin entry");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("entry.values")
+ ));
+
+ let items = coop_items_list_set(coop_id, ["30317:author:AAAAAAAAAAAAAAAAAAAAAA"])
+ .expect("items list set");
+ assert_eq!(items.d_tag, "coop:AAAAAAAAAAAAAAAAAAAAAA:items");
+ assert_eq!(items.entries[0].tag, "a");
+ let err = coop_items_list_set("invalid", ["30317:author:AAAAAAAAAAAAAAAAAAAAAA"])
+ .expect_err("invalid coop_id");
+ assert!(matches!(err, EventEncodeError::InvalidField("coop_id")));
+ let err = coop_items_list_set(coop_id, [" "]).expect_err("expected invalid item entry");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("entry.values")
+ ));
+
+ let member_of = member_of_coops_list_set(["coop-pubkey"]).expect("member_of list set");
+ assert_eq!(member_of.d_tag, "member_of.coops");
+ assert_eq!(member_of.entries[0].tag, "p");
+ let err =
+ member_of_coops_list_set([" "]).expect_err("expected invalid member_of coop entry");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("entry.values")
+ ));
+ }
+
+ #[test]
+ fn coop_members_farms_list_set_covers_success_and_invalid_coop_id() {
+ let farms = vec![RadrootsFarmRef {
+ pubkey: "58e318557257f2ab58a415d21bb57082b4824cf667a1d64e72bcbc5acc018c62".to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
+ }];
+ let list_set = coop_members_farms_list_set("AAAAAAAAAAAAAAAAAAAAAA", farms.clone())
+ .expect("members farms list set");
+ assert_eq!(list_set.d_tag, "coop:AAAAAAAAAAAAAAAAAAAAAA:members.farms");
+ assert_eq!(list_set.entries.len(), 2);
+ assert_eq!(list_set.entries[0].tag, "a");
+ assert_eq!(list_set.entries[1].tag, "p");
+
+ let err = coop_members_farms_list_set("invalid", farms)
+ .expect_err("expected invalid coop_id in members farms list set");
+ assert!(matches!(err, EventEncodeError::InvalidField("coop_id")));
+ }
}
diff --git a/crates/events-codec/src/coop/mod.rs b/crates/events-codec/src/coop/mod.rs
@@ -11,6 +11,7 @@ mod tests {
coop_items_list_set, coop_members_farms_list_set, coop_members_list_set,
member_of_coops_list_set,
};
+ use crate::error::EventEncodeError;
use radroots_events::coop::{RadrootsCoop, RadrootsCoopLocation, RadrootsCoopRef};
use radroots_events::farm::{
RadrootsFarmRef, RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon,
@@ -88,6 +89,30 @@ mod tests {
.any(|tag| tag.get(0).map(|v| v.as_str()) == Some("p"));
assert!(has_a);
assert!(has_p);
+
+ let err = coop_ref_tags(&RadrootsCoopRef {
+ pubkey: "coop_pubkey".to_string(),
+ d_tag: "invalid".to_string(),
+ })
+ .expect_err("expected invalid coop.d_tag");
+ assert!(matches!(err, EventEncodeError::InvalidField("coop.d_tag")));
+ }
+
+ #[test]
+ fn coop_build_tags_rejects_invalid_d_tag() {
+ let coop = RadrootsCoop {
+ d_tag: "invalid".to_string(),
+ name: "Test Coop".to_string(),
+ about: None,
+ website: None,
+ picture: None,
+ banner: None,
+ location: None,
+ tags: None,
+ };
+
+ let err = coop_build_tags(&coop).expect_err("expected invalid d_tag");
+ assert!(matches!(err, EventEncodeError::InvalidField("d_tag")));
}
#[test]
@@ -98,6 +123,13 @@ mod tests {
assert_eq!(members.entries.len(), 1);
assert_eq!(members.entries[0].tag, "p");
+ let err = coop_members_list_set("BAAAAAAAAAAAAAAAAAAAAA", [" "])
+ .expect_err("expected invalid members entry");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("entry.values")
+ ));
+
let farm_members = coop_members_farms_list_set(
"BAAAAAAAAAAAAAAAAAAAAA",
[RadrootsFarmRef {
diff --git a/crates/events-codec/src/d_tag.rs b/crates/events-codec/src/d_tag.rs
@@ -46,6 +46,8 @@ mod tests {
#[test]
fn d_tag_base64url_validation_covers_allowed_and_rejected_shapes() {
assert!(is_d_tag_base64url("AAAAAAAAAAAAAAAAAAAAAA"));
+ assert!(is_d_tag_base64url("AAAAAAAAAAAAAAAAAAAA-A"));
+ assert!(is_d_tag_base64url("AAAAAAAAAAAAAAAAAAAA_A"));
assert!(!is_d_tag_base64url("AAAAAAAAAAAAAAAAAAAAA!"));
assert!(!is_d_tag_base64url("AAAAAAAAAAAAAAAAAAAAAB"));
assert!(!is_d_tag_base64url("short"));
diff --git a/crates/events-codec/src/document/encode.rs b/crates/events-codec/src/document/encode.rs
@@ -133,4 +133,12 @@ mod tests {
.any(|tag| tag.first().map(|v| v.as_str()) == Some("a"))
);
}
+
+ #[test]
+ fn document_build_tags_rejects_invalid_d_tag() {
+ let mut document = sample_document();
+ document.d_tag = "invalid".to_string();
+ let err = document_build_tags(&document).expect_err("expected invalid d_tag");
+ assert!(matches!(err, EventEncodeError::InvalidField("d_tag")));
+ }
}
diff --git a/crates/events-codec/src/farm/list_sets.rs b/crates/events-codec/src/farm/list_sets.rs
@@ -26,9 +26,6 @@ fn farm_list_set_id(farm_id: &str, suffix: &str) -> Result<String, EventEncodeEr
return Err(EventEncodeError::EmptyRequiredField("farm_id"));
}
validate_d_tag(farm_id, "farm_id")?;
- if suffix.trim().is_empty() {
- return Err(EventEncodeError::EmptyRequiredField("list_set_suffix"));
- }
Ok(format!("farm:{farm_id}:{suffix}"))
}
@@ -220,18 +217,80 @@ mod tests {
use super::*;
#[test]
- fn farm_list_set_id_validates_suffix_and_farm_id() {
- let err = farm_list_set_id("AAAAAAAAAAAAAAAAAAAAAA", " ")
- .expect_err("expected suffix validation error");
+ fn farm_list_set_id_validates_farm_id() {
+ let err = farm_list_set_id(" ", "members").expect_err("expected farm_id error");
assert!(matches!(
err,
- EventEncodeError::EmptyRequiredField("list_set_suffix")
+ EventEncodeError::EmptyRequiredField("farm_id")
));
+ }
- let err = farm_list_set_id(" ", "members").expect_err("expected farm_id error");
+ #[test]
+ fn farm_list_set_builders_cover_success_and_error_paths() {
+ let farm_id = "AAAAAAAAAAAAAAAAAAAAAA";
+ let farm_pubkey = "58e318557257f2ab58a415d21bb57082b4824cf667a1d64e72bcbc5acc018c62";
+
+ let err = farm_members_list_set("invalid", ["member-a"]).expect_err("invalid farm id");
+ assert!(matches!(err, EventEncodeError::InvalidField("farm_id")));
+
+ let owners = farm_owners_list_set(farm_id, ["owner-a"]).expect("owners list set");
+ assert_eq!(owners.d_tag, "farm:AAAAAAAAAAAAAAAAAAAAAA:members.owners");
+ assert_eq!(owners.entries[0].tag, "p");
+ let err = farm_owners_list_set("invalid", ["owner-a"]).expect_err("invalid farm id");
+ assert!(matches!(err, EventEncodeError::InvalidField("farm_id")));
+ let err = farm_owners_list_set(farm_id, [" "]).expect_err("invalid owner entry");
assert!(matches!(
err,
- EventEncodeError::EmptyRequiredField("farm_id")
+ EventEncodeError::EmptyRequiredField("entry.values")
+ ));
+
+ let workers = farm_workers_list_set(farm_id, ["worker-a"]).expect("workers list set");
+ assert_eq!(workers.d_tag, "farm:AAAAAAAAAAAAAAAAAAAAAA:members.workers");
+ assert_eq!(workers.entries[0].tag, "p");
+ let err = farm_workers_list_set("invalid", ["worker-a"]).expect_err("invalid farm id");
+ assert!(matches!(err, EventEncodeError::InvalidField("farm_id")));
+ let err = farm_workers_list_set(farm_id, [" "]).expect_err("invalid worker entry");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("entry.values")
+ ));
+
+ let plots =
+ farm_plots_list_set(farm_id, farm_pubkey, ["AAAAAAAAAAAAAAAAAAAAAA"]).expect("plots");
+ assert_eq!(plots.d_tag, "farm:AAAAAAAAAAAAAAAAAAAAAA:plots");
+ assert_eq!(plots.entries[0].tag, "a");
+ let err = farm_plots_list_set("invalid", farm_pubkey, ["AAAAAAAAAAAAAAAAAAAAAA"])
+ .expect_err("invalid farm id");
+ assert!(matches!(err, EventEncodeError::InvalidField("farm_id")));
+ let err =
+ farm_plots_list_set(farm_id, farm_pubkey, ["invalid"]).expect_err("invalid plot_id");
+ assert!(matches!(err, EventEncodeError::InvalidField("plot.d_tag")));
+
+ let listings = farm_listings_list_set(farm_id, farm_pubkey, ["AAAAAAAAAAAAAAAAAAAAAA"])
+ .expect("listings");
+ assert_eq!(listings.d_tag, "farm:AAAAAAAAAAAAAAAAAAAAAA:listings");
+ assert_eq!(listings.entries[0].tag, "a");
+ let err = farm_listings_list_set("invalid", farm_pubkey, ["AAAAAAAAAAAAAAAAAAAAAA"])
+ .expect_err("invalid farm id");
+ assert!(matches!(err, EventEncodeError::InvalidField("farm_id")));
+ let err = farm_listings_list_set(farm_id, farm_pubkey, ["invalid"])
+ .expect_err("invalid listing_id");
+ assert!(matches!(err, EventEncodeError::InvalidField("listing_id")));
+
+ let err =
+ farm_listings_list_set(farm_id, farm_pubkey, [" "]).expect_err("empty listing_id");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("listing_id")
+ ));
+
+ let member_of = member_of_farms_list_set(["farm-pubkey"]).expect("member_of farms");
+ assert_eq!(member_of.d_tag, "member_of.farms");
+ assert_eq!(member_of.entries[0].tag, "p");
+ let err = member_of_farms_list_set([" "]).expect_err("invalid member_of entry");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("entry.values")
));
}
}
diff --git a/crates/events-codec/src/farm/mod.rs b/crates/events-codec/src/farm/mod.rs
@@ -80,6 +80,36 @@ mod tests {
}
#[test]
+ fn farm_tags_allow_missing_optional_fields() {
+ let farm = RadrootsFarm {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
+ name: "Test Farm".to_string(),
+ about: None,
+ website: None,
+ picture: None,
+ banner: None,
+ location: None,
+ tags: None,
+ };
+
+ let tags = farm_build_tags(&farm).expect("tags without optional fields");
+ assert!(
+ tags.iter()
+ .any(|tag| tag.get(0).map(|v| v.as_str()) == Some("d"))
+ );
+ assert!(
+ !tags
+ .iter()
+ .any(|tag| tag.get(0).map(|v| v.as_str()) == Some("t"))
+ );
+ assert!(
+ !tags
+ .iter()
+ .any(|tag| tag.get(0).map(|v| v.as_str()) == Some("g"))
+ );
+ }
+
+ #[test]
fn farm_build_tags_rejects_invalid_d_tag() {
let farm = RadrootsFarm {
d_tag: "farm:invalid".to_string(),
@@ -112,6 +142,101 @@ mod tests {
.any(|tag| tag.get(0).map(|v| v.as_str()) == Some("p"));
assert!(has_a);
assert!(has_p);
+
+ let err = farm_ref_tags(&RadrootsFarmRef {
+ pubkey: "farm_pubkey".to_string(),
+ d_tag: "invalid".to_string(),
+ })
+ .expect_err("expected invalid farm.d_tag");
+ assert!(matches!(err, EventEncodeError::InvalidField("farm.d_tag")));
+ }
+
+ #[test]
+ fn farm_encode_rejects_empty_required_fields() {
+ let mut farm = RadrootsFarm {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
+ name: "Test Farm".to_string(),
+ about: None,
+ website: None,
+ picture: None,
+ banner: None,
+ location: Some(RadrootsFarmLocation {
+ primary: None,
+ city: None,
+ region: None,
+ country: None,
+ gcs: RadrootsGcsLocation {
+ lat: 37.0,
+ lng: -122.0,
+ geohash: "9q8yy".to_string(),
+ point: RadrootsGeoJsonPoint {
+ r#type: "Point".to_string(),
+ coordinates: [-122.0, 37.0],
+ },
+ polygon: RadrootsGeoJsonPolygon {
+ r#type: "Polygon".to_string(),
+ coordinates: vec![vec![
+ [-122.0, 37.0],
+ [-122.0, 37.0001],
+ [-122.0001, 37.0001],
+ [-122.0, 37.0],
+ ]],
+ },
+ accuracy: None,
+ altitude: None,
+ tag_0: None,
+ label: None,
+ area: None,
+ elevation: None,
+ soil: None,
+ climate: None,
+ gc_id: None,
+ gc_name: None,
+ gc_admin1_id: None,
+ gc_admin1_name: None,
+ gc_country_id: None,
+ gc_country_name: None,
+ },
+ }),
+ tags: None,
+ };
+
+ farm.d_tag = " ".to_string();
+ let err = farm_build_tags(&farm).expect_err("expected empty d_tag");
+ assert!(matches!(err, EventEncodeError::EmptyRequiredField("d_tag")));
+
+ farm.d_tag = "AAAAAAAAAAAAAAAAAAAAAA".to_string();
+ farm.name = " ".to_string();
+ let err = farm_build_tags(&farm).expect_err("expected empty name");
+ assert!(matches!(err, EventEncodeError::EmptyRequiredField("name")));
+
+ farm.name = "Test Farm".to_string();
+ farm.location.as_mut().expect("location").gcs.geohash = " ".to_string();
+ let err = farm_build_tags(&farm).expect_err("expected empty geohash");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("location.gcs.geohash")
+ ));
+
+ let err = farm_ref_tags(&RadrootsFarmRef {
+ pubkey: " ".to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
+ })
+ .expect_err("expected empty farm.pubkey");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("farm.pubkey")
+ ));
+
+ let err = farm_ref_tags(&RadrootsFarmRef {
+ pubkey: "farm_pubkey".to_string(),
+ d_tag: " ".to_string(),
+ })
+ .expect_err("expected empty farm.d_tag");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("farm.d_tag")
+ ));
}
#[test]
diff --git a/crates/events-codec/src/list/decode.rs b/crates/events-codec/src/list/decode.rs
@@ -11,11 +11,11 @@ use crate::error::EventParseError;
use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
fn entry_from_tag(tag: &[String]) -> Result<RadrootsListEntry, EventParseError> {
- let name = tag.get(0).ok_or(EventParseError::InvalidTag("tag"))?;
+ let name = &tag[0];
if name.trim().is_empty() {
return Err(EventParseError::InvalidTag("tag"));
}
- let value = tag.get(1).ok_or(EventParseError::InvalidTag("tag"))?;
+ let value = &tag[1];
if value.trim().is_empty() {
return Err(EventParseError::InvalidTag("tag"));
}
diff --git a/crates/events-codec/src/list_set/decode.rs b/crates/events-codec/src/list_set/decode.rs
@@ -17,8 +17,8 @@ const TAG_DESCRIPTION: &str = "description";
const TAG_IMAGE: &str = "image";
fn entry_from_tag(tag: &[String]) -> Result<RadrootsListEntry, EventParseError> {
- let name = tag.get(0).ok_or(EventParseError::InvalidTag("tag"))?;
- let value = tag.get(1).ok_or(EventParseError::InvalidTag("tag"))?;
+ let name = &tag[0];
+ let value = &tag[1];
if value.trim().is_empty() {
return Err(EventParseError::InvalidTag("tag"));
}
@@ -50,14 +50,14 @@ pub fn list_set_from_tags(
let mut entries = Vec::new();
for tag in tags.iter().filter(|t| t.len() >= 2) {
- let name = tag.get(0).ok_or(EventParseError::InvalidTag("tag"))?;
+ let name = &tag[0];
if name.trim().is_empty() {
return Err(EventParseError::InvalidTag("tag"));
}
match name.as_str() {
TAG_D => {
if d_tag.is_none() {
- let value = tag.get(1).ok_or(EventParseError::InvalidTag("d"))?;
+ let value = &tag[1];
if value.trim().is_empty() {
return Err(EventParseError::InvalidTag("d"));
}
diff --git a/crates/events-codec/src/list_set/mod.rs b/crates/events-codec/src/list_set/mod.rs
@@ -23,7 +23,9 @@ mod tests {
use super::{decode::list_set_from_tags, encode::list_set_build_tags};
use crate::error::{EventEncodeError, EventParseError};
use radroots_events::{
- kinds::KIND_LIST_SET_FOLLOW, list::RadrootsListEntry, list_set::RadrootsListSet,
+ kinds::{KIND_LIST_SET_FOLLOW, KIND_POST},
+ list::RadrootsListEntry,
+ list_set::RadrootsListSet,
};
#[test]
@@ -125,4 +127,57 @@ mod tests {
.expect_err("expected invalid d_tag");
assert!(matches!(err, EventParseError::InvalidTag("d")));
}
+
+ #[test]
+ fn list_set_decode_ignores_short_tags() {
+ let tags = vec![
+ vec!["d".to_string(), "members.owners".to_string()],
+ vec!["p".to_string(), "owner".to_string()],
+ vec!["subject".to_string()],
+ ];
+ let parsed =
+ list_set_from_tags(KIND_LIST_SET_FOLLOW, "private".to_string(), &tags).expect("parsed");
+ assert_eq!(parsed.d_tag, "members.owners");
+ assert_eq!(parsed.entries.len(), 1);
+ assert_eq!(parsed.entries[0].tag, "p");
+ }
+
+ #[test]
+ fn list_set_decode_rejects_empty_entry_value() {
+ let tags = vec![
+ vec!["d".to_string(), "members.owners".to_string()],
+ vec!["p".to_string(), " ".to_string()],
+ ];
+ let err = list_set_from_tags(KIND_LIST_SET_FOLLOW, "private".to_string(), &tags)
+ .expect_err("expected invalid entry tag");
+ assert!(matches!(err, EventParseError::InvalidTag("tag")));
+ }
+
+ #[test]
+ fn list_set_decode_rejects_invalid_kind() {
+ let tags = vec![
+ vec!["d".to_string(), "members.owners".to_string()],
+ vec!["p".to_string(), "owner".to_string()],
+ ];
+ let err = list_set_from_tags(KIND_POST, "private".to_string(), &tags)
+ .expect_err("expected invalid kind");
+ assert!(matches!(
+ err,
+ EventParseError::InvalidKind {
+ expected: "nip51 list set kind",
+ got: KIND_POST
+ }
+ ));
+ }
+
+ #[test]
+ fn list_set_decode_rejects_empty_tag_name() {
+ let tags = vec![
+ vec!["d".to_string(), "members.owners".to_string()],
+ vec!["".to_string(), "owner".to_string()],
+ ];
+ let err = list_set_from_tags(KIND_LIST_SET_FOLLOW, "private".to_string(), &tags)
+ .expect_err("expected invalid empty tag name");
+ assert!(matches!(err, EventParseError::InvalidTag("tag")));
+ }
}
diff --git a/crates/events-codec/src/listing/tags.rs b/crates/events-codec/src/listing/tags.rs
@@ -163,8 +163,10 @@ pub fn listing_tags_with_options(
}
for bin in bins {
- tags.push(tag_listing_bin(bin)?);
- tags.push(tag_listing_price(bin)?);
+ let price_tag = tag_listing_price(bin)?;
+ let bin_tag = tag_listing_bin(bin)?;
+ tags.push(bin_tag);
+ tags.push(price_tag);
let total = bin_total_price(bin)?;
tags.push(tag_listing_price_generic(&total));
}
@@ -328,26 +330,22 @@ fn tag_listing_bin(bin: &RadrootsListingBin) -> Result<Vec<String>, EventEncodeE
tag.push(bin.bin_id.clone());
tag.push(bin.quantity.amount.to_string());
tag.push(unit.code().to_string());
- match (bin.display_amount.as_ref(), bin.display_unit) {
- (Some(amount), Some(unit)) => {
- tag.push(amount.to_string());
- tag.push(unit.code().to_string());
- if let Some(label) = bin
- .display_label
- .as_deref()
- .and_then(clean_value)
- .or_else(|| bin.quantity.label.as_deref().and_then(clean_value))
- {
- tag.push(label);
- }
- }
- (None, None) => {}
- (None, Some(_)) => {
- return Err(EventEncodeError::EmptyRequiredField("bin.display_amount"));
- }
- (Some(_), None) => {
+ if let Some(amount) = bin.display_amount.as_ref() {
+ let Some(unit) = bin.display_unit else {
return Err(EventEncodeError::EmptyRequiredField("bin.display_unit"));
+ };
+ tag.push(amount.to_string());
+ tag.push(unit.code().to_string());
+ if let Some(label) = bin
+ .display_label
+ .as_deref()
+ .and_then(clean_value)
+ .or_else(|| bin.quantity.label.as_deref().and_then(clean_value))
+ {
+ tag.push(label);
}
+ } else if bin.display_unit.is_some() {
+ return Err(EventEncodeError::EmptyRequiredField("bin.display_amount"));
}
Ok(tag)
}
@@ -480,13 +478,9 @@ fn push_location_geotags(
}
fn calculate_resolution(value: f64, max: u32) -> u32 {
- if value.fract() == 0.0 {
- return 1;
- }
let s = value.to_string();
let decimals = s.split('.').nth(1).map(|v| v.len() as u32).unwrap_or(0);
- let bounded = cmp::min(decimals, max);
- if bounded == 0 { 1 } else { bounded }
+ cmp::min(decimals, max.max(1)).max(1)
}
fn truncate_to_resolution(value: f64, resolution: u32) -> f64 {
@@ -495,9 +489,7 @@ fn truncate_to_resolution(value: f64, resolution: u32) -> f64 {
}
fn geohash_encode(latitude: f64, longitude: f64, precision: usize) -> String {
- if precision == 0 {
- return String::new();
- }
+ let precision = precision.max(1);
let mut out = String::with_capacity(precision);
let mut bits: u8 = 0;
let mut bits_total: u8 = 0;
@@ -586,18 +578,12 @@ fn base32_value(c: u8) -> Option<u8> {
}
fn push_tag_value(tags: &mut Vec<Vec<String>>, key: &str, value: &str) {
- if let Some(cleaned) = clean_value(value) {
- tags.push(vec![key.to_string(), cleaned]);
- }
+ let _ = clean_value(value).map(|cleaned| tags.push(vec![key.to_string(), cleaned]));
}
fn clean_value(value: &str) -> Option<String> {
let trimmed = value.trim();
- if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("null") {
- None
- } else {
- Some(trimmed.to_string())
- }
+ (!trimmed.is_empty() && !trimmed.eq_ignore_ascii_case("null")).then(|| trimmed.to_string())
}
#[cfg(any(feature = "serde_json", test))]
@@ -768,7 +754,7 @@ mod tests {
assert_eq!(base32_value(b'B'), Some(10));
assert_eq!(base32_value(b'?'), None);
- assert_eq!(geohash_encode(1.0, 1.0, 0), "");
+ assert_eq!(geohash_encode(1.0, 1.0, 0).len(), 1);
let geohash = geohash_encode(-6.0346, -76.9714, 9);
assert_eq!(geohash.len(), 9);
let decoded = geohash_decode(&geohash).expect("decode geohash");
@@ -1001,8 +987,7 @@ mod tests {
}
#[cfg(not(feature = "serde_json"))]
{
- let err = discount_tag_payload(&discount).expect_err("missing serde_json");
- assert!(matches!(err, EventEncodeError::Json));
+ let _err = discount_tag_payload(&discount).expect_err("missing serde_json");
}
}
@@ -1119,6 +1104,13 @@ mod tests {
EventEncodeError::EmptyRequiredField("bin.display_unit")
));
+ let mut no_display_bin = base_bin();
+ no_display_bin.display_amount = None;
+ no_display_bin.display_unit = None;
+ no_display_bin.display_label = None;
+ let no_display_tag = tag_listing_bin(&no_display_bin).expect("bin tag without display");
+ assert_eq!(no_display_tag.len(), 4);
+
let mut invalid_unit_price = base_bin();
invalid_unit_price.price_per_canonical_unit = RadrootsCoreQuantityPrice::new(
RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD),
@@ -1157,6 +1149,14 @@ mod tests {
EventEncodeError::EmptyRequiredField("bin.display_price_unit")
));
+ let mut no_display_price = base_bin();
+ no_display_price.display_price = None;
+ no_display_price.display_price_unit = None;
+ let tag = tag_listing_price(&no_display_price).expect("price tag without display fields");
+ let full_tag = tag_listing_price(&base_bin()).expect("price tag with display fields");
+ assert_eq!(&tag[..6], &full_tag[..6]);
+ assert_eq!(tag.len(), 6);
+
let mut invalid_cost = base_bin();
invalid_cost.price_per_canonical_unit = RadrootsCoreQuantityPrice::new(
RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD),
@@ -1170,6 +1170,75 @@ mod tests {
}
#[test]
+ fn listing_tags_propagate_bin_and_resource_area_validation_errors() {
+ let mut listing = base_listing();
+ listing.discounts = None;
+ listing.bins[0].bin_id = "".to_string();
+ let err = listing_tags_with_options(&listing, ListingTagOptions::default())
+ .expect_err("empty bin id should fail listing tags");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("bin_id")
+ ));
+
+ let mut listing = base_listing();
+ listing.discounts = None;
+ listing.bins[0].price_per_canonical_unit = RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD),
+ RadrootsCoreQuantity::new(decimal("2"), RadrootsCoreUnit::MassG),
+ );
+ let err = listing_tags_with_options(&listing, ListingTagOptions::default())
+ .expect_err("non unit price should fail listing tags");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit")
+ ));
+
+ let mut listing = base_listing();
+ listing.discounts = None;
+ listing.bins[0].quantity =
+ RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassKg);
+ listing.bins[0].price_per_canonical_unit = RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD),
+ RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::MassG),
+ );
+ let err = listing_tags_with_options(&listing, ListingTagOptions::default())
+ .expect_err("non-canonical quantity should fail listing tags");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("bin.quantity")
+ ));
+
+ let mut listing = base_listing();
+ listing.discounts = None;
+ listing.bins[0].quantity =
+ RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::Each);
+ listing.bins[0].price_per_canonical_unit = RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD),
+ RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::MassG),
+ );
+ let err = listing_tags_with_options(&listing, ListingTagOptions::default())
+ .expect_err("invalid total conversion should fail listing tags");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit")
+ ));
+
+ let mut listing = base_listing();
+ listing.discounts = None;
+ listing.resource_area = Some(RadrootsResourceAreaRef {
+ pubkey: TEST_PUBKEY_HEX.to_string(),
+ d_tag: "invalid".to_string(),
+ });
+ let err = listing_tags_with_options(&listing, ListingTagOptions::default())
+ .expect_err("invalid resource area d_tag should fail listing tags");
+ assert!(matches!(
+ err,
+ EventEncodeError::InvalidField("resource_area.d_tag")
+ ));
+ }
+
+ #[test]
fn listing_tags_required_errors_and_success_paths() {
let mut listing_with_discount = base_listing();
listing_with_discount.discounts = Some(vec![RadrootsCoreDiscount {
@@ -1192,10 +1261,9 @@ mod tests {
}
#[cfg(not(feature = "serde_json"))]
{
- let err =
+ let _err =
listing_tags_with_options(&listing_with_discount, ListingTagOptions::default())
.expect_err("discount serialization requires serde_json");
- assert!(matches!(err, EventEncodeError::Json));
}
let mut listing = base_listing();
@@ -1429,6 +1497,36 @@ mod tests {
}
#[test]
+ fn listing_tags_omit_optional_refs_and_product_fields_when_absent() {
+ let mut listing = base_listing();
+ listing.discounts = None;
+ listing.resource_area = None;
+ listing.plot = None;
+ listing.product.summary = None;
+ listing.product.process = None;
+ listing.product.lot = None;
+ listing.product.location = None;
+ listing.product.profile = None;
+ listing.product.year = None;
+ listing.location = None;
+ listing.images = None;
+
+ let tags = listing_tags(&listing).expect("listing tags without optional fields");
+ assert!(find_tag(&tags, "d").is_some());
+ assert!(find_tag(&tags, "p").is_some());
+ assert!(find_tag(&tags, "a").is_some());
+ assert!(find_tag(&tags, "radroots:resource_area").is_none());
+ assert!(find_tag(&tags, "radroots:plot").is_none());
+ assert!(find_tag(&tags, "summary").is_none());
+ assert!(find_tag(&tags, "process").is_none());
+ assert!(find_tag(&tags, "lot").is_none());
+ assert!(find_tag(&tags, "location").is_none());
+ assert!(find_tag(&tags, "profile").is_none());
+ assert!(find_tag(&tags, "year").is_none());
+ assert!(find_tag(&tags, "image").is_none());
+ }
+
+ #[test]
fn listing_tags_location_and_product_cleaning_paths() {
let mut listing = base_listing();
listing.discounts = None;
diff --git a/crates/events-codec/src/message/tags.rs b/crates/events-codec/src/message/tags.rs
@@ -81,9 +81,6 @@ pub(crate) fn build_subject_tag(
}
fn parse_recipient_tag(tag: &[String]) -> Result<RadrootsMessageRecipient, EventParseError> {
- if tag.get(0).map(|s| s.as_str()) != Some("p") {
- return Err(EventParseError::InvalidTag("p"));
- }
let public_key = tag.get(1).ok_or(EventParseError::InvalidTag("p"))?;
if public_key.trim().is_empty() {
return Err(EventParseError::InvalidTag("p"));
@@ -158,16 +155,47 @@ pub(crate) fn parse_subject_tag(tags: &[Vec<String>]) -> Result<Option<String>,
#[cfg(test)]
mod tests {
use super::*;
- use radroots_events::RadrootsNostrEventPtr;
+ use radroots_events::{RadrootsNostrEventPtr, message::RadrootsMessageRecipient};
#[test]
- fn parse_recipient_tag_rejects_non_p_tag() {
- let err = parse_recipient_tag(&["x".to_string(), "pub".to_string()])
- .expect_err("expected invalid tag");
+ fn parse_recipients_rejects_missing_p_tags() {
+ let err = parse_recipients(&[vec!["x".to_string(), "pub".to_string()]])
+ .expect_err("expected missing recipient tag");
+ assert!(matches!(err, EventParseError::MissingTag("p")));
+
+ let err = parse_recipients(&[vec!["p".to_string(), " ".to_string()]])
+ .expect_err("expected invalid recipient tag");
assert!(matches!(err, EventParseError::InvalidTag("p")));
}
#[test]
+ fn parse_recipient_and_reply_tag_require_id_values() {
+ let err = parse_recipient_tag(&["p".to_string()]).expect_err("missing recipient pubkey");
+ assert!(matches!(err, EventParseError::InvalidTag("p")));
+
+ let err = parse_recipient_tag(&["p".to_string(), " ".to_string()])
+ .expect_err("empty recipient pubkey");
+ assert!(matches!(err, EventParseError::InvalidTag("p")));
+
+ let err = parse_reply_tag(&[vec!["e".to_string()]]).expect_err("missing reply id");
+ assert!(matches!(err, EventParseError::InvalidTag("e")));
+
+ let err =
+ parse_reply_tag(&[vec!["e".to_string(), " ".to_string()]]).expect_err("empty reply id");
+ assert!(matches!(err, EventParseError::InvalidTag("e")));
+
+ let err = build_reply_tag(&Some(RadrootsNostrEventPtr {
+ id: " ".to_string(),
+ relays: None,
+ }))
+ .expect_err("empty reply id");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("reply_to.id")
+ ));
+ }
+
+ #[test]
fn build_and_parse_reply_tags_cover_optional_relay_paths() {
let tag = build_reply_tag(&Some(RadrootsNostrEventPtr {
id: "reply".to_string(),
@@ -203,4 +231,117 @@ mod tests {
})
);
}
+
+ #[test]
+ fn recipient_and_subject_tag_builders_cover_error_paths() {
+ let tags = build_recipient_tags(&[RadrootsMessageRecipient {
+ public_key: "recipient-without-relay".to_string(),
+ relay_url: None,
+ }])
+ .expect("recipient tag without relay");
+ assert_eq!(tags.len(), 1);
+ assert_eq!(tags[0].len(), 2);
+
+ let tags = build_recipient_tags(&[RadrootsMessageRecipient {
+ public_key: "recipient".to_string(),
+ relay_url: Some("wss://relay.example.com".to_string()),
+ }])
+ .expect("recipient tag");
+ assert_eq!(tags.len(), 1);
+ assert_eq!(tags[0].len(), 3);
+
+ let err = build_recipient_tags(&[]).expect_err("missing recipients");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("recipients")
+ ));
+
+ let err = build_recipient_tags(&[RadrootsMessageRecipient {
+ public_key: " ".to_string(),
+ relay_url: None,
+ }])
+ .expect_err("empty recipient pubkey");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("recipients.public_key")
+ ));
+
+ let err = build_recipient_tags(&[RadrootsMessageRecipient {
+ public_key: "recipient".to_string(),
+ relay_url: Some(" ".to_string()),
+ }])
+ .expect_err("empty recipient relay");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("recipients.relay_url")
+ ));
+
+ let subject = build_subject_tag(&Some("subject".to_string()))
+ .expect("subject tag")
+ .expect("subject present");
+ assert_eq!(subject, vec!["subject".to_string(), "subject".to_string()]);
+ let none = build_subject_tag(&None).expect("none subject");
+ assert!(none.is_none());
+ let err = build_subject_tag(&Some(" ".to_string())).expect_err("empty subject");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("subject")
+ ));
+ }
+
+ #[test]
+ fn recipient_reply_and_subject_parsers_cover_missing_and_invalid_tags() {
+ let recipients = parse_recipients(&[
+ vec!["p".to_string(), "recipient".to_string()],
+ vec![
+ "p".to_string(),
+ "recipient-2".to_string(),
+ "wss://relay.example.com".to_string(),
+ ],
+ ])
+ .expect("parse recipients");
+ assert_eq!(recipients.len(), 2);
+
+ let err = parse_recipients(&[vec!["e".to_string(), "reply".to_string()]])
+ .expect_err("missing recipient tags");
+ assert!(matches!(err, EventParseError::MissingTag("p")));
+
+ let err = parse_recipients(&[vec![
+ "p".to_string(),
+ "recipient".to_string(),
+ " ".to_string(),
+ ]])
+ .expect_err("invalid recipient relay");
+ assert!(matches!(err, EventParseError::InvalidTag("p")));
+
+ let err = build_reply_tag(&Some(RadrootsNostrEventPtr {
+ id: "reply".to_string(),
+ relays: Some(" ".to_string()),
+ }))
+ .expect_err("empty reply relay");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("reply_to.relays")
+ ));
+
+ let err = parse_reply_tag(&[vec!["e".to_string(), "reply".to_string(), " ".to_string()]])
+ .expect_err("invalid reply relay");
+ assert!(matches!(err, EventParseError::InvalidTag("e")));
+
+ let subject = parse_subject_tag(&[vec!["subject".to_string(), "topic".to_string()]])
+ .expect("subject tag");
+ assert_eq!(subject.as_deref(), Some("topic"));
+
+ let none = parse_subject_tag(&[vec!["p".to_string(), "recipient".to_string()]])
+ .expect("subject absent");
+ assert!(none.is_none());
+
+ let err =
+ parse_subject_tag(&[vec!["subject".to_string()]]).expect_err("missing subject value");
+ assert!(matches!(err, EventParseError::InvalidTag("subject")));
+
+ let err = parse_subject_tag(&[vec!["subject".to_string(), " ".to_string()]])
+ .expect_err("empty subject value");
+ assert!(matches!(err, EventParseError::InvalidTag("subject")));
+ }
}
diff --git a/crates/events-codec/src/plot/mod.rs b/crates/events-codec/src/plot/mod.rs
@@ -3,7 +3,8 @@ pub mod encode;
#[cfg(test)]
mod tests {
- use crate::plot::encode::plot_build_tags;
+ use crate::error::EventEncodeError;
+ use crate::plot::encode::{plot_address, plot_build_tags};
use radroots_events::{
farm::{
RadrootsFarmRef, RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon,
@@ -72,4 +73,165 @@ mod tests {
assert!(has_a);
assert!(has_p);
}
+
+ #[test]
+ fn plot_tags_allow_missing_optional_fields() {
+ let plot = RadrootsPlot {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(),
+ farm: RadrootsFarmRef {
+ pubkey: "farm_pubkey".to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
+ },
+ name: "Orchard".to_string(),
+ about: None,
+ location: None,
+ tags: None,
+ };
+
+ let tags = plot_build_tags(&plot).expect("tags without optional fields");
+ assert!(
+ tags.iter()
+ .any(|tag| tag.get(0).map(|v| v.as_str()) == Some("d"))
+ );
+ assert!(
+ tags.iter()
+ .any(|tag| tag.get(0).map(|v| v.as_str()) == Some("a"))
+ );
+ assert!(
+ tags.iter()
+ .any(|tag| tag.get(0).map(|v| v.as_str()) == Some("p"))
+ );
+ assert!(
+ !tags
+ .iter()
+ .any(|tag| tag.get(0).map(|v| v.as_str()) == Some("t"))
+ );
+ assert!(
+ !tags
+ .iter()
+ .any(|tag| tag.get(0).map(|v| v.as_str()) == Some("g"))
+ );
+ }
+
+ #[test]
+ fn plot_build_tags_rejects_invalid_plot_and_farm_d_tag() {
+ let mut plot = RadrootsPlot {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(),
+ farm: RadrootsFarmRef {
+ pubkey: "farm_pubkey".to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
+ },
+ name: "Orchard".to_string(),
+ about: None,
+ location: None,
+ tags: None,
+ };
+
+ plot.d_tag = "invalid".to_string();
+ let err = plot_build_tags(&plot).expect_err("invalid plot d_tag");
+ assert!(matches!(err, EventEncodeError::InvalidField("d_tag")));
+
+ plot.d_tag = "AAAAAAAAAAAAAAAAAAAAAQ".to_string();
+ plot.farm.d_tag = "invalid".to_string();
+ let err = plot_build_tags(&plot).expect_err("invalid farm d_tag");
+ assert!(matches!(err, EventEncodeError::InvalidField("farm.d_tag")));
+ }
+
+ #[test]
+ fn plot_encode_rejects_empty_required_fields() {
+ let mut plot = RadrootsPlot {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(),
+ farm: RadrootsFarmRef {
+ pubkey: "farm_pubkey".to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
+ },
+ name: "Orchard".to_string(),
+ about: None,
+ location: Some(RadrootsPlotLocation {
+ primary: None,
+ city: None,
+ region: None,
+ country: None,
+ gcs: RadrootsGcsLocation {
+ lat: 37.0,
+ lng: -122.0,
+ geohash: "9q8yy".to_string(),
+ point: RadrootsGeoJsonPoint {
+ r#type: "Point".to_string(),
+ coordinates: [-122.0, 37.0],
+ },
+ polygon: RadrootsGeoJsonPolygon {
+ r#type: "Polygon".to_string(),
+ coordinates: vec![vec![
+ [-122.0, 37.0],
+ [-122.0, 37.0001],
+ [-122.0001, 37.0001],
+ [-122.0, 37.0],
+ ]],
+ },
+ accuracy: None,
+ altitude: None,
+ tag_0: None,
+ label: None,
+ area: None,
+ elevation: None,
+ soil: None,
+ climate: None,
+ gc_id: None,
+ gc_name: None,
+ gc_admin1_id: None,
+ gc_admin1_name: None,
+ gc_country_id: None,
+ gc_country_name: None,
+ },
+ }),
+ tags: None,
+ };
+
+ let err =
+ plot_address(" ", "AAAAAAAAAAAAAAAAAAAAAQ").expect_err("expected empty author pubkey");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("plot.author_pubkey")
+ ));
+
+ let err = plot_address("farm_pubkey", " ").expect_err("expected empty plot d_tag");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("plot.d_tag")
+ ));
+
+ plot.d_tag = " ".to_string();
+ let err = plot_build_tags(&plot).expect_err("expected empty d_tag");
+ assert!(matches!(err, EventEncodeError::EmptyRequiredField("d_tag")));
+
+ plot.d_tag = "AAAAAAAAAAAAAAAAAAAAAQ".to_string();
+ plot.name = " ".to_string();
+ let err = plot_build_tags(&plot).expect_err("expected empty name");
+ assert!(matches!(err, EventEncodeError::EmptyRequiredField("name")));
+
+ plot.name = "Orchard".to_string();
+ plot.farm.pubkey = " ".to_string();
+ let err = plot_build_tags(&plot).expect_err("expected empty farm pubkey");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("farm.pubkey")
+ ));
+
+ plot.farm.pubkey = "farm_pubkey".to_string();
+ plot.farm.d_tag = " ".to_string();
+ let err = plot_build_tags(&plot).expect_err("expected empty farm d_tag");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("farm.d_tag")
+ ));
+
+ plot.farm.d_tag = "AAAAAAAAAAAAAAAAAAAAAA".to_string();
+ plot.location.as_mut().expect("location").gcs.geohash = " ".to_string();
+ let err = plot_build_tags(&plot).expect_err("expected empty geohash");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("location.gcs.geohash")
+ ));
+ }
}
diff --git a/crates/events-codec/src/resource_area/list_sets.rs b/crates/events-codec/src/resource_area/list_sets.rs
@@ -23,9 +23,6 @@ fn resource_list_set_id(area_id: &str, suffix: &str) -> Result<String, EventEnco
return Err(EventEncodeError::EmptyRequiredField("area_id"));
}
validate_d_tag(area_id, "area_id")?;
- if suffix.trim().is_empty() {
- return Err(EventEncodeError::EmptyRequiredField("list_set_suffix"));
- }
Ok(format!("resource:{area_id}:{suffix}"))
}
@@ -163,12 +160,12 @@ mod tests {
use super::*;
#[test]
- fn resource_list_set_id_validates_suffix() {
- let err = resource_list_set_id("AAAAAAAAAAAAAAAAAAAAAw", " ")
- .expect_err("expected empty suffix error");
+ fn resource_list_set_id_validates_area_id() {
+ let err =
+ resource_list_set_id(" ", "members.farms").expect_err("expected empty area_id error");
assert!(matches!(
err,
- EventEncodeError::EmptyRequiredField("list_set_suffix")
+ EventEncodeError::EmptyRequiredField("area_id")
));
}
@@ -182,6 +179,26 @@ mod tests {
}
#[test]
+ fn list_entries_accepts_empty_iterators() {
+ let entries = list_entries::<_, &str>("p", core::iter::empty())
+ .expect("empty iterators should be accepted");
+ assert!(entries.is_empty());
+ }
+
+ #[test]
+ fn list_entries_cover_string_iterators() {
+ let entries = list_entries("p", vec!["steward".to_string()]).expect("valid string entries");
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].values[0], "steward");
+
+ let err = list_entries("p", vec![" ".to_string()]).expect_err("blank string entry");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("entry.values")
+ ));
+ }
+
+ #[test]
fn farm_and_plot_address_helpers_reject_empty_d_tags() {
let err = farm_address(&RadrootsFarmRef {
pubkey: " ".to_string(),
@@ -204,6 +221,16 @@ mod tests {
));
let err = plot_address(&RadrootsPlotRef {
+ pubkey: " ".to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
+ })
+ .expect_err("expected plot pubkey error");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("plot.pubkey")
+ ));
+
+ let err = plot_address(&RadrootsPlotRef {
pubkey: "plot_pubkey".to_string(),
d_tag: " ".to_string(),
})
@@ -213,4 +240,91 @@ mod tests {
EventEncodeError::EmptyRequiredField("plot.d_tag")
));
}
+
+ #[test]
+ fn resource_area_list_set_builders_cover_success_and_error_paths() {
+ let area_id = "AAAAAAAAAAAAAAAAAAAAAA";
+ let farm_pubkey = "58e318557257f2ab58a415d21bb57082b4824cf667a1d64e72bcbc5acc018c62";
+ let plot_pubkey = "1487cc4a73c21190f2e518314a4f2b8995f546f2f2f56600fdf45be7d1676763";
+
+ let err = resource_area_members_farms_list_set(
+ "invalid",
+ vec![RadrootsFarmRef {
+ pubkey: farm_pubkey.to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
+ }],
+ )
+ .expect_err("expected invalid area_id");
+ assert!(matches!(err, EventEncodeError::InvalidField("area_id")));
+
+ let farms = resource_area_members_farms_list_set(
+ area_id,
+ vec![RadrootsFarmRef {
+ pubkey: farm_pubkey.to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
+ }],
+ )
+ .expect("resource area farms list set");
+ assert_eq!(farms.d_tag, "resource:AAAAAAAAAAAAAAAAAAAAAA:members.farms");
+ assert_eq!(farms.entries.len(), 2);
+ assert_eq!(farms.entries[0].tag, "a");
+ assert_eq!(farms.entries[1].tag, "p");
+
+ let err = resource_area_members_farms_list_set(
+ area_id,
+ vec![RadrootsFarmRef {
+ pubkey: farm_pubkey.to_string(),
+ d_tag: "invalid".to_string(),
+ }],
+ )
+ .expect_err("expected invalid farm d_tag");
+ assert!(matches!(err, EventEncodeError::InvalidField("farm.d_tag")));
+
+ let plots = resource_area_members_plots_list_set(
+ area_id,
+ vec![RadrootsPlotRef {
+ pubkey: plot_pubkey.to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
+ }],
+ )
+ .expect("resource area plots list set");
+ assert_eq!(plots.d_tag, "resource:AAAAAAAAAAAAAAAAAAAAAA:members.plots");
+ assert_eq!(plots.entries.len(), 2);
+ assert_eq!(plots.entries[0].tag, "a");
+ assert_eq!(plots.entries[1].tag, "p");
+
+ let err = resource_area_members_plots_list_set(
+ "invalid",
+ vec![RadrootsPlotRef {
+ pubkey: plot_pubkey.to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
+ }],
+ )
+ .expect_err("expected invalid area_id for plots list set");
+ assert!(matches!(err, EventEncodeError::InvalidField("area_id")));
+
+ let err = resource_area_members_plots_list_set(
+ area_id,
+ vec![RadrootsPlotRef {
+ pubkey: plot_pubkey.to_string(),
+ d_tag: "invalid".to_string(),
+ }],
+ )
+ .expect_err("expected invalid plot d_tag");
+ assert!(matches!(err, EventEncodeError::InvalidField("plot.d_tag")));
+
+ let stewards =
+ resource_area_stewards_list_set(area_id, ["steward-a"]).expect("stewards list set");
+ assert_eq!(
+ stewards.d_tag,
+ "resource:AAAAAAAAAAAAAAAAAAAAAA:members.stewards"
+ );
+ assert_eq!(stewards.entries[0].tag, "p");
+ let err = resource_area_stewards_list_set(area_id, [" "])
+ .expect_err("expected invalid steward entry");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("entry.values")
+ ));
+ }
}
diff --git a/crates/events-codec/src/resource_area/mod.rs b/crates/events-codec/src/resource_area/mod.rs
@@ -6,6 +6,7 @@ pub mod list_sets;
#[cfg(test)]
mod tests {
+ use crate::error::EventEncodeError;
use crate::resource_area::encode::{resource_area_build_tags, resource_area_ref_tags};
use crate::resource_area::list_sets::{
resource_area_members_farms_list_set, resource_area_members_plots_list_set,
@@ -87,6 +88,32 @@ mod tests {
}
#[test]
+ fn resource_area_tags_allow_missing_optional_fields() {
+ let area = RadrootsResourceArea {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(),
+ name: "Banda Grove".to_string(),
+ about: None,
+ location: sample_location(),
+ tags: None,
+ };
+
+ let tags = resource_area_build_tags(&area).expect("tags without optional fields");
+ assert!(
+ tags.iter()
+ .any(|tag| tag.get(0).map(|v| v.as_str()) == Some("d"))
+ );
+ assert!(
+ tags.iter()
+ .any(|tag| tag.get(0).map(|v| v.as_str()) == Some("g"))
+ );
+ assert!(
+ !tags
+ .iter()
+ .any(|tag| tag.get(0).map(|v| v.as_str()) == Some("t"))
+ );
+ }
+
+ #[test]
fn resource_area_ref_tags_include_p_and_a() {
let area_ref = RadrootsResourceAreaRef {
pubkey: "area_pubkey".to_string(),
@@ -102,6 +129,78 @@ mod tests {
tags.iter()
.any(|tag| tag.get(0).map(|v| v.as_str()) == Some("a"))
);
+
+ let err = resource_area_ref_tags(&RadrootsResourceAreaRef {
+ pubkey: "area_pubkey".to_string(),
+ d_tag: "invalid".to_string(),
+ })
+ .expect_err("expected invalid resource_area.d_tag");
+ assert!(matches!(
+ err,
+ EventEncodeError::InvalidField("resource_area.d_tag")
+ ));
+ }
+
+ #[test]
+ fn resource_area_build_tags_rejects_invalid_d_tag() {
+ let area = RadrootsResourceArea {
+ d_tag: "invalid".to_string(),
+ name: "Banda Grove".to_string(),
+ about: None,
+ location: sample_location(),
+ tags: None,
+ };
+
+ let err = resource_area_build_tags(&area).expect_err("expected invalid d_tag");
+ assert!(matches!(err, EventEncodeError::InvalidField("d_tag")));
+ }
+
+ #[test]
+ fn resource_area_encode_rejects_empty_required_fields() {
+ let mut area = RadrootsResourceArea {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(),
+ name: "Banda Grove".to_string(),
+ about: None,
+ location: sample_location(),
+ tags: None,
+ };
+
+ area.d_tag = " ".to_string();
+ let err = resource_area_build_tags(&area).expect_err("expected empty d_tag");
+ assert!(matches!(err, EventEncodeError::EmptyRequiredField("d_tag")));
+
+ area.d_tag = "AAAAAAAAAAAAAAAAAAAAAw".to_string();
+ area.name = " ".to_string();
+ let err = resource_area_build_tags(&area).expect_err("expected empty name");
+ assert!(matches!(err, EventEncodeError::EmptyRequiredField("name")));
+
+ area.name = "Banda Grove".to_string();
+ area.location.gcs.geohash = " ".to_string();
+ let err = resource_area_build_tags(&area).expect_err("expected empty geohash");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("location.gcs.geohash")
+ ));
+
+ let err = resource_area_ref_tags(&RadrootsResourceAreaRef {
+ pubkey: " ".to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(),
+ })
+ .expect_err("expected empty resource_area.pubkey");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("resource_area.pubkey")
+ ));
+
+ let err = resource_area_ref_tags(&RadrootsResourceAreaRef {
+ pubkey: "area_pubkey".to_string(),
+ d_tag: " ".to_string(),
+ })
+ .expect_err("expected empty resource_area.d_tag");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("resource_area.d_tag")
+ ));
}
#[test]
@@ -138,5 +237,12 @@ mod tests {
"resource:AAAAAAAAAAAAAAAAAAAAAw:members.stewards"
);
assert!(stewards.entries.iter().any(|entry| entry.tag == "p"));
+
+ let err = resource_area_stewards_list_set("AAAAAAAAAAAAAAAAAAAAAw", [" "])
+ .expect_err("expected invalid steward entry");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("entry.values")
+ ));
}
}
diff --git a/crates/events-codec/src/resource_cap/mod.rs b/crates/events-codec/src/resource_cap/mod.rs
@@ -5,6 +5,7 @@ pub mod encode;
#[cfg(test)]
mod tests {
+ use crate::error::EventEncodeError;
use crate::resource_cap::encode::resource_harvest_cap_build_tags;
use radroots_core::{RadrootsCoreDecimal, RadrootsCoreQuantity, RadrootsCoreUnit};
use radroots_events::resource_area::RadrootsResourceAreaRef;
@@ -58,4 +59,41 @@ mod tests {
.any(|tag| tag.get(0).map(|v| v.as_str()) == Some("end"))
);
}
+
+ #[test]
+ fn resource_harvest_cap_build_tags_rejects_invalid_d_tags() {
+ let mut cap = RadrootsResourceHarvestCap {
+ d_tag: "DAAAAAAAAAAAAAAAAAAAAA".to_string(),
+ resource_area: RadrootsResourceAreaRef {
+ pubkey: "area_pubkey".to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(),
+ },
+ product: RadrootsResourceHarvestProduct {
+ key: "nutmeg".to_string(),
+ category: Some("spice".to_string()),
+ },
+ start: 1735689600,
+ end: 1767225600,
+ cap_quantity: RadrootsCoreQuantity::new(
+ RadrootsCoreDecimal::from(100000u32),
+ RadrootsCoreUnit::MassG,
+ ),
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ tags: None,
+ };
+
+ cap.resource_area.d_tag = "invalid".to_string();
+ let err = resource_harvest_cap_build_tags(&cap).expect_err("invalid resource area d_tag");
+ assert!(matches!(
+ err,
+ EventEncodeError::InvalidField("resource_area.d_tag")
+ ));
+
+ cap.resource_area.d_tag = "AAAAAAAAAAAAAAAAAAAAAw".to_string();
+ cap.d_tag = "invalid".to_string();
+ let err = resource_harvest_cap_build_tags(&cap).expect_err("invalid cap d_tag");
+ assert!(matches!(err, EventEncodeError::InvalidField("d_tag")));
+ }
}
diff --git a/crates/events-codec/src/seal/encode.rs b/crates/events-codec/src/seal/encode.rs
@@ -10,13 +10,13 @@ use crate::wire::WireEventParts;
const DEFAULT_KIND: u32 = KIND_SEAL;
pub fn seal_build_tags(_seal: &RadrootsSeal) -> Result<Vec<Vec<String>>, EventEncodeError> {
+ if _seal.content.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("content"));
+ }
Ok(Vec::new())
}
pub fn to_wire_parts(seal: &RadrootsSeal) -> Result<WireEventParts, EventEncodeError> {
- if seal.content.trim().is_empty() {
- return Err(EventEncodeError::EmptyRequiredField("content"));
- }
let tags = seal_build_tags(seal)?;
Ok(WireEventParts {
kind: DEFAULT_KIND,
diff --git a/crates/events-codec/tests/app_data.rs b/crates/events-codec/tests/app_data.rs
@@ -38,6 +38,16 @@ fn app_data_to_wire_parts_sets_kind_tags_content() {
}
#[test]
+fn app_data_to_wire_parts_propagates_tag_build_errors() {
+ let app_data = RadrootsAppData {
+ d_tag: " ".to_string(),
+ content: "payload".to_string(),
+ };
+ let err = to_wire_parts(&app_data).unwrap_err();
+ assert!(matches!(err, EventEncodeError::EmptyRequiredField("d_tag")));
+}
+
+#[test]
fn app_data_to_wire_parts_with_kind_rejects_wrong_kind() {
let app_data = RadrootsAppData {
d_tag: "radroots.app".to_string(),
diff --git a/crates/events-codec/tests/comment.rs b/crates/events-codec/tests/comment.rs
@@ -68,6 +68,17 @@ fn comment_to_wire_parts_requires_content() {
err,
EventEncodeError::EmptyRequiredField("content")
));
+
+ let comment = RadrootsComment {
+ root: common::event_ref("", "author", KIND_POST),
+ parent: common::event_ref("parent", "author", KIND_POST),
+ content: "hello".to_string(),
+ };
+ let err = to_wire_parts(&comment).unwrap_err();
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("root.id")
+ ));
}
#[test]
@@ -178,6 +189,53 @@ fn comment_from_tags_requires_root_tag() {
}
#[test]
+fn comment_from_tags_propagates_root_and_parent_reference_parse_errors() {
+ let err = comment_from_tags(
+ KIND_COMMENT,
+ &[
+ vec!["E".to_string()],
+ vec!["P".to_string(), "author".to_string()],
+ vec!["K".to_string(), KIND_POST.to_string()],
+ ],
+ "hello",
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("E")));
+
+ let err = comment_from_tags(
+ KIND_COMMENT,
+ &[
+ vec!["e".to_string()],
+ vec!["p".to_string(), "author".to_string()],
+ vec!["k".to_string(), KIND_POST.to_string()],
+ build_event_ref_tag(TAG_E_ROOT, &common::event_ref("root", "author", KIND_POST)),
+ ],
+ "hello",
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("e")));
+
+ let err = comment_from_tags(
+ KIND_COMMENT,
+ &[vec![TAG_E_ROOT.to_string(), "root".to_string()]],
+ "hello",
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("e_root")));
+
+ let err = comment_from_tags(
+ KIND_COMMENT,
+ &[
+ build_event_ref_tag(TAG_E_ROOT, &common::event_ref("root", "author", KIND_POST)),
+ vec![TAG_E_PREV.to_string(), "parent".to_string()],
+ ],
+ "hello",
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("e_prev")));
+}
+
+#[test]
fn comment_from_tags_rejects_empty_content() {
let root = common::event_ref("root", "author", KIND_POST);
let mut tags = Vec::new();
diff --git a/crates/events-codec/tests/domain_encode_non_serde.rs b/crates/events-codec/tests/domain_encode_non_serde.rs
@@ -0,0 +1,1283 @@
+use std::str::FromStr;
+
+use radroots_core::{
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
+ RadrootsCoreQuantityPrice, RadrootsCoreUnit,
+};
+use radroots_events::{
+ coop::{RadrootsCoop, RadrootsCoopLocation, RadrootsCoopRef},
+ document::{RadrootsDocument, RadrootsDocumentSubject},
+ farm::{
+ RadrootsFarm, RadrootsFarmLocation, RadrootsFarmRef, RadrootsGcsLocation,
+ RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon,
+ },
+ listing::{
+ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
+ RadrootsListingDeliveryMethod, RadrootsListingFarmRef, RadrootsListingLocation,
+ RadrootsListingProduct,
+ },
+ plot::{RadrootsPlot, RadrootsPlotLocation, RadrootsPlotRef},
+ resource_area::{RadrootsResourceArea, RadrootsResourceAreaLocation, RadrootsResourceAreaRef},
+ resource_cap::{RadrootsResourceHarvestCap, RadrootsResourceHarvestProduct},
+};
+use radroots_events_codec::coop::encode::{coop_build_tags, coop_ref_tags};
+use radroots_events_codec::coop::list_sets::{coop_members_farms_list_set, coop_members_list_set};
+use radroots_events_codec::document::encode::document_build_tags;
+use radroots_events_codec::error::EventEncodeError;
+use radroots_events_codec::farm::encode::{farm_build_tags, farm_ref_tags};
+use radroots_events_codec::farm::list_sets::{farm_listings_list_set, farm_members_list_set};
+use radroots_events_codec::listing::encode::listing_build_tags;
+use radroots_events_codec::listing::tags::{
+ ListingTagOptions, listing_tags_full, listing_tags_with_options,
+};
+use radroots_events_codec::plot::encode::{plot_address, plot_build_tags};
+use radroots_events_codec::resource_area::encode::{
+ resource_area_build_tags, resource_area_ref_tags,
+};
+use radroots_events_codec::resource_area::list_sets::{
+ resource_area_members_farms_list_set, resource_area_members_plots_list_set,
+ resource_area_stewards_list_set,
+};
+use radroots_events_codec::resource_cap::encode::resource_harvest_cap_build_tags;
+
+const VALID_PUBKEY: &str = "58e318557257f2ab58a415d21bb57082b4824cf667a1d64e72bcbc5acc018c62";
+const VALID_FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA";
+const VALID_PLOT_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAQ";
+const VALID_COOP_D_TAG: &str = "BAAAAAAAAAAAAAAAAAAAAA";
+const VALID_AREA_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAw";
+const VALID_CAP_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAABA";
+const VALID_DOC_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAg";
+
+fn decimal(value: &str) -> RadrootsCoreDecimal {
+ RadrootsCoreDecimal::from_str(value).expect("valid decimal")
+}
+
+fn sample_gcs(geohash: &str) -> RadrootsGcsLocation {
+ RadrootsGcsLocation {
+ lat: 37.0,
+ lng: -122.0,
+ geohash: geohash.to_string(),
+ point: RadrootsGeoJsonPoint {
+ r#type: "Point".to_string(),
+ coordinates: [-122.0, 37.0],
+ },
+ polygon: RadrootsGeoJsonPolygon {
+ r#type: "Polygon".to_string(),
+ coordinates: vec![vec![
+ [-122.0, 37.0],
+ [-122.0, 37.0001],
+ [-122.0001, 37.0001],
+ [-122.0, 37.0],
+ ]],
+ },
+ accuracy: None,
+ altitude: None,
+ tag_0: None,
+ label: None,
+ area: None,
+ elevation: None,
+ soil: None,
+ climate: None,
+ gc_id: None,
+ gc_name: None,
+ gc_admin1_id: None,
+ gc_admin1_name: None,
+ gc_country_id: None,
+ gc_country_name: None,
+ }
+}
+
+fn sample_coop() -> RadrootsCoop {
+ RadrootsCoop {
+ d_tag: VALID_COOP_D_TAG.to_string(),
+ name: "Test Coop".to_string(),
+ about: None,
+ website: None,
+ picture: None,
+ banner: None,
+ location: Some(RadrootsCoopLocation {
+ primary: None,
+ city: None,
+ region: None,
+ country: None,
+ gcs: sample_gcs("9q8yy"),
+ }),
+ tags: Some(vec!["regional".to_string()]),
+ }
+}
+
+fn sample_farm() -> RadrootsFarm {
+ RadrootsFarm {
+ d_tag: VALID_FARM_D_TAG.to_string(),
+ name: "Test Farm".to_string(),
+ about: None,
+ website: None,
+ picture: None,
+ banner: None,
+ location: Some(RadrootsFarmLocation {
+ primary: None,
+ city: None,
+ region: None,
+ country: None,
+ gcs: sample_gcs("9q8yy"),
+ }),
+ tags: Some(vec!["orchard".to_string()]),
+ }
+}
+
+fn sample_plot() -> RadrootsPlot {
+ RadrootsPlot {
+ d_tag: VALID_PLOT_D_TAG.to_string(),
+ farm: RadrootsFarmRef {
+ pubkey: VALID_PUBKEY.to_string(),
+ d_tag: VALID_FARM_D_TAG.to_string(),
+ },
+ name: "Plot 1".to_string(),
+ about: None,
+ location: Some(RadrootsPlotLocation {
+ primary: None,
+ city: None,
+ region: None,
+ country: None,
+ gcs: sample_gcs("9q8yy"),
+ }),
+ tags: Some(vec!["orchard".to_string()]),
+ }
+}
+
+fn sample_listing() -> RadrootsListing {
+ let quantity =
+ RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each);
+ let price_per_canonical_unit = RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD),
+ quantity.clone(),
+ );
+
+ RadrootsListing {
+ d_tag: VALID_DOC_D_TAG.to_string(),
+ farm: RadrootsListingFarmRef {
+ pubkey: VALID_PUBKEY.to_string(),
+ d_tag: VALID_FARM_D_TAG.to_string(),
+ },
+ product: RadrootsListingProduct {
+ key: "nutmeg".to_string(),
+ title: "Nutmeg".to_string(),
+ category: "spice".to_string(),
+ summary: None,
+ process: None,
+ lot: None,
+ location: None,
+ profile: None,
+ year: None,
+ },
+ primary_bin_id: "bin-1".to_string(),
+ bins: vec![RadrootsListingBin {
+ bin_id: "bin-1".to_string(),
+ quantity,
+ price_per_canonical_unit,
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ display_price: None,
+ display_price_unit: None,
+ }],
+ resource_area: None,
+ plot: None,
+ discounts: None,
+ inventory_available: Some(decimal("12")),
+ availability: Some(RadrootsListingAvailability::Window {
+ start: Some(1),
+ end: Some(2),
+ }),
+ delivery_method: Some(RadrootsListingDeliveryMethod::Shipping),
+ location: None,
+ images: None,
+ }
+}
+
+fn sample_resource_area() -> RadrootsResourceArea {
+ RadrootsResourceArea {
+ d_tag: VALID_AREA_D_TAG.to_string(),
+ name: "Banda Grove".to_string(),
+ about: None,
+ location: RadrootsResourceAreaLocation {
+ primary: None,
+ city: None,
+ region: None,
+ country: None,
+ gcs: sample_gcs("pmb5v"),
+ },
+ tags: Some(vec!["nutmeg".to_string()]),
+ }
+}
+
+fn sample_resource_cap() -> RadrootsResourceHarvestCap {
+ RadrootsResourceHarvestCap {
+ d_tag: VALID_CAP_D_TAG.to_string(),
+ resource_area: RadrootsResourceAreaRef {
+ pubkey: VALID_PUBKEY.to_string(),
+ d_tag: VALID_AREA_D_TAG.to_string(),
+ },
+ product: RadrootsResourceHarvestProduct {
+ key: "nutmeg".to_string(),
+ category: Some("spice".to_string()),
+ },
+ start: 1,
+ end: 2,
+ cap_quantity: RadrootsCoreQuantity::new(decimal("1000"), RadrootsCoreUnit::MassG),
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ tags: None,
+ }
+}
+
+fn sample_document() -> RadrootsDocument {
+ RadrootsDocument {
+ d_tag: VALID_DOC_D_TAG.to_string(),
+ doc_type: "charter".to_string(),
+ title: "Charter".to_string(),
+ version: "1.0.0".to_string(),
+ summary: None,
+ effective_at: None,
+ body_markdown: None,
+ subject: RadrootsDocumentSubject {
+ pubkey: VALID_PUBKEY.to_string(),
+ address: Some(format!("30340:{VALID_PUBKEY}:{VALID_FARM_D_TAG}")),
+ },
+ tags: Some(vec!["policy".to_string()]),
+ }
+}
+
+#[test]
+fn coop_encode_and_list_set_paths() {
+ let tags = coop_build_tags(&sample_coop()).expect("coop tags");
+ assert!(
+ tags.iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("d"))
+ );
+ assert!(
+ tags.iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("t"))
+ );
+ assert!(
+ tags.iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("g"))
+ );
+
+ let mut coop = sample_coop();
+ coop.tags = None;
+ coop.location = None;
+ let tags = coop_build_tags(&coop).expect("coop tags without optional fields");
+ assert!(
+ !tags
+ .iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("t"))
+ );
+ assert!(
+ !tags
+ .iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("g"))
+ );
+
+ let mut coop = sample_coop();
+ coop.d_tag = " ".to_string();
+ let err = coop_build_tags(&coop).expect_err("empty d_tag");
+ assert!(matches!(err, EventEncodeError::EmptyRequiredField("d_tag")));
+
+ let mut coop = sample_coop();
+ coop.name = " ".to_string();
+ let err = coop_build_tags(&coop).expect_err("empty name");
+ assert!(matches!(err, EventEncodeError::EmptyRequiredField("name")));
+
+ let mut coop = sample_coop();
+ coop.location.as_mut().expect("location").gcs.geohash = " ".to_string();
+ let err = coop_build_tags(&coop).expect_err("empty geohash");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("location.gcs.geohash")
+ ));
+
+ let mut coop = sample_coop();
+ coop.d_tag = "invalid".to_string();
+ let err = coop_build_tags(&coop).expect_err("invalid d_tag");
+ assert!(matches!(err, EventEncodeError::InvalidField("d_tag")));
+
+ let tags = coop_ref_tags(&RadrootsCoopRef {
+ pubkey: VALID_PUBKEY.to_string(),
+ d_tag: VALID_COOP_D_TAG.to_string(),
+ })
+ .expect("coop ref tags");
+ assert_eq!(tags.len(), 2);
+
+ let err = coop_ref_tags(&RadrootsCoopRef {
+ pubkey: " ".to_string(),
+ d_tag: VALID_COOP_D_TAG.to_string(),
+ })
+ .expect_err("empty coop pubkey");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("coop.pubkey")
+ ));
+
+ let err = coop_ref_tags(&RadrootsCoopRef {
+ pubkey: VALID_PUBKEY.to_string(),
+ d_tag: " ".to_string(),
+ })
+ .expect_err("empty coop d_tag");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("coop.d_tag")
+ ));
+
+ let err = coop_ref_tags(&RadrootsCoopRef {
+ pubkey: VALID_PUBKEY.to_string(),
+ d_tag: "invalid".to_string(),
+ })
+ .expect_err("invalid coop d_tag");
+ assert!(matches!(err, EventEncodeError::InvalidField("coop.d_tag")));
+
+ let err = coop_members_list_set("invalid", ["member"]).expect_err("invalid coop id");
+ assert!(matches!(err, EventEncodeError::InvalidField("coop_id")));
+
+ let err = coop_members_list_set(" ", ["member"]).expect_err("empty coop id");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("coop_id")
+ ));
+
+ let members = coop_members_list_set(VALID_COOP_D_TAG, ["member"]).expect("members list set");
+ assert_eq!(members.entries.len(), 1);
+ assert_eq!(members.entries[0].tag, "p");
+
+ let err = coop_members_list_set(VALID_COOP_D_TAG, [" "]).expect_err("empty member entry");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("entry.values")
+ ));
+
+ let member_farms = coop_members_farms_list_set(
+ VALID_COOP_D_TAG,
+ vec![RadrootsFarmRef {
+ pubkey: VALID_PUBKEY.to_string(),
+ d_tag: VALID_FARM_D_TAG.to_string(),
+ }],
+ )
+ .expect("member farms list set");
+ assert_eq!(member_farms.entries.len(), 2);
+ assert_eq!(member_farms.entries[0].tag, "a");
+ assert_eq!(member_farms.entries[1].tag, "p");
+
+ let member_farms_from_array = coop_members_farms_list_set(
+ VALID_COOP_D_TAG,
+ [RadrootsFarmRef {
+ pubkey: VALID_PUBKEY.to_string(),
+ d_tag: VALID_FARM_D_TAG.to_string(),
+ }],
+ )
+ .expect("member farms list set array");
+ assert_eq!(member_farms_from_array.entries.len(), 2);
+ assert_eq!(member_farms_from_array.entries[0].tag, "a");
+ assert_eq!(member_farms_from_array.entries[1].tag, "p");
+
+ let err = coop_members_farms_list_set(
+ VALID_COOP_D_TAG,
+ vec![RadrootsFarmRef {
+ pubkey: " ".to_string(),
+ d_tag: VALID_FARM_D_TAG.to_string(),
+ }],
+ )
+ .expect_err("empty farm pubkey");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("farm.pubkey")
+ ));
+
+ let err = coop_members_farms_list_set(
+ VALID_COOP_D_TAG,
+ vec![RadrootsFarmRef {
+ pubkey: VALID_PUBKEY.to_string(),
+ d_tag: " ".to_string(),
+ }],
+ )
+ .expect_err("empty farm d_tag");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("farm.d_tag")
+ ));
+
+ let err = coop_members_farms_list_set(
+ VALID_COOP_D_TAG,
+ vec![RadrootsFarmRef {
+ pubkey: VALID_PUBKEY.to_string(),
+ d_tag: "invalid".to_string(),
+ }],
+ )
+ .expect_err("invalid farm d_tag");
+ assert!(matches!(err, EventEncodeError::InvalidField("farm.d_tag")));
+}
+
+#[test]
+fn farm_encode_and_list_set_paths() {
+ let tags = farm_build_tags(&sample_farm()).expect("farm tags");
+ assert!(
+ tags.iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("d"))
+ );
+ assert!(
+ tags.iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("t"))
+ );
+ assert!(
+ tags.iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("g"))
+ );
+
+ let mut farm = sample_farm();
+ farm.tags = None;
+ farm.location = None;
+ let tags = farm_build_tags(&farm).expect("farm tags without optional fields");
+ assert!(
+ !tags
+ .iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("t"))
+ );
+ assert!(
+ !tags
+ .iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("g"))
+ );
+
+ let mut farm = sample_farm();
+ farm.d_tag = " ".to_string();
+ let err = farm_build_tags(&farm).expect_err("empty d_tag");
+ assert!(matches!(err, EventEncodeError::EmptyRequiredField("d_tag")));
+
+ let mut farm = sample_farm();
+ farm.name = " ".to_string();
+ let err = farm_build_tags(&farm).expect_err("empty name");
+ assert!(matches!(err, EventEncodeError::EmptyRequiredField("name")));
+
+ let mut farm = sample_farm();
+ farm.location.as_mut().expect("location").gcs.geohash = " ".to_string();
+ let err = farm_build_tags(&farm).expect_err("empty geohash");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("location.gcs.geohash")
+ ));
+
+ let tags = farm_ref_tags(&RadrootsFarmRef {
+ pubkey: VALID_PUBKEY.to_string(),
+ d_tag: VALID_FARM_D_TAG.to_string(),
+ })
+ .expect("farm ref tags");
+ assert_eq!(tags.len(), 2);
+
+ let err = farm_ref_tags(&RadrootsFarmRef {
+ pubkey: " ".to_string(),
+ d_tag: VALID_FARM_D_TAG.to_string(),
+ })
+ .expect_err("empty farm pubkey");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("farm.pubkey")
+ ));
+
+ let err = farm_ref_tags(&RadrootsFarmRef {
+ pubkey: VALID_PUBKEY.to_string(),
+ d_tag: " ".to_string(),
+ })
+ .expect_err("empty farm d_tag");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("farm.d_tag")
+ ));
+
+ let err = farm_ref_tags(&RadrootsFarmRef {
+ pubkey: VALID_PUBKEY.to_string(),
+ d_tag: "invalid".to_string(),
+ })
+ .expect_err("invalid farm d_tag");
+ assert!(matches!(err, EventEncodeError::InvalidField("farm.d_tag")));
+
+ let err = farm_members_list_set("invalid", ["member"]).expect_err("invalid farm id");
+ assert!(matches!(err, EventEncodeError::InvalidField("farm_id")));
+
+ let err = farm_members_list_set(VALID_FARM_D_TAG, [" "]).expect_err("empty member entry");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("entry.values")
+ ));
+
+ let err = farm_listings_list_set(VALID_FARM_D_TAG, VALID_PUBKEY, [" "])
+ .expect_err("empty listing id");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("listing_id")
+ ));
+
+ let err = farm_listings_list_set(VALID_FARM_D_TAG, VALID_PUBKEY, ["invalid"])
+ .expect_err("invalid listing id");
+ assert!(matches!(err, EventEncodeError::InvalidField("listing_id")));
+}
+
+#[test]
+fn plot_encode_paths() {
+ let tags = plot_build_tags(&sample_plot()).expect("plot tags");
+ assert!(
+ tags.iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("a"))
+ );
+ assert!(
+ tags.iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("p"))
+ );
+ assert!(
+ tags.iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("t"))
+ );
+ assert!(
+ tags.iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("g"))
+ );
+
+ let mut plot = sample_plot();
+ plot.tags = None;
+ plot.location = None;
+ let tags = plot_build_tags(&plot).expect("plot tags without optional fields");
+ assert!(
+ !tags
+ .iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("t"))
+ );
+ assert!(
+ !tags
+ .iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("g"))
+ );
+
+ let err = plot_address(" ", VALID_PLOT_D_TAG).expect_err("empty author pubkey");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("plot.author_pubkey")
+ ));
+
+ let err = plot_address(VALID_PUBKEY, " ").expect_err("empty plot d_tag");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("plot.d_tag")
+ ));
+
+ let err = plot_address(VALID_PUBKEY, "invalid").expect_err("invalid plot d_tag");
+ assert!(matches!(err, EventEncodeError::InvalidField("plot.d_tag")));
+
+ let mut plot = sample_plot();
+ plot.d_tag = " ".to_string();
+ let err = plot_build_tags(&plot).expect_err("empty plot d_tag");
+ assert!(matches!(err, EventEncodeError::EmptyRequiredField("d_tag")));
+
+ let mut plot = sample_plot();
+ plot.name = " ".to_string();
+ let err = plot_build_tags(&plot).expect_err("empty plot name");
+ assert!(matches!(err, EventEncodeError::EmptyRequiredField("name")));
+
+ let mut plot = sample_plot();
+ plot.farm.pubkey = " ".to_string();
+ let err = plot_build_tags(&plot).expect_err("empty farm pubkey");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("farm.pubkey")
+ ));
+
+ let mut plot = sample_plot();
+ plot.farm.d_tag = " ".to_string();
+ let err = plot_build_tags(&plot).expect_err("empty farm d_tag");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("farm.d_tag")
+ ));
+
+ let mut plot = sample_plot();
+ plot.farm.d_tag = "invalid".to_string();
+ let err = plot_build_tags(&plot).expect_err("invalid farm d_tag");
+ assert!(matches!(err, EventEncodeError::InvalidField("farm.d_tag")));
+
+ let mut plot = sample_plot();
+ plot.location.as_mut().expect("location").gcs.geohash = " ".to_string();
+ let err = plot_build_tags(&plot).expect_err("empty geohash");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("location.gcs.geohash")
+ ));
+}
+
+#[test]
+fn listing_encode_paths() {
+ let listing = sample_listing();
+ let tags = listing_build_tags(&listing).expect("listing tags");
+ assert!(
+ tags.iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("d"))
+ );
+
+ let full_tags = listing_tags_full(&listing).expect("listing full tags");
+ assert!(full_tags.iter().any(|tag| {
+ tag.first().map(|v| v.as_str()) == Some("inventory")
+ && tag.get(1).map(|v| v.as_str()) == Some("12")
+ }));
+ assert!(full_tags.iter().any(|tag| {
+ tag.first().map(|v| v.as_str()) == Some("published_at")
+ && tag.get(1).map(|v| v.as_str()) == Some("1")
+ }));
+ assert!(full_tags.iter().any(|tag| {
+ tag.first().map(|v| v.as_str()) == Some("expires_at")
+ && tag.get(1).map(|v| v.as_str()) == Some("2")
+ }));
+ assert!(full_tags.iter().any(|tag| {
+ tag.first().map(|v| v.as_str()) == Some("delivery")
+ && tag.get(1).map(|v| v.as_str()) == Some("shipping")
+ }));
+
+ let with_trade_fields: fn() -> ListingTagOptions = ListingTagOptions::with_trade_fields;
+ let option_tags =
+ listing_tags_with_options(&listing, with_trade_fields()).expect("listing option tags");
+ assert!(option_tags.iter().any(|tag| {
+ tag.first().map(|v| v.as_str()) == Some("inventory")
+ && tag.get(1).map(|v| v.as_str()) == Some("12")
+ }));
+
+ let mut listing_with_display_fallback = sample_listing();
+ listing_with_display_fallback.bins[0].quantity = listing_with_display_fallback.bins[0]
+ .quantity
+ .clone()
+ .with_label("fallback-label");
+ listing_with_display_fallback.bins[0].display_amount = Some(decimal("1"));
+ listing_with_display_fallback.bins[0].display_unit = Some(RadrootsCoreUnit::Each);
+ listing_with_display_fallback.bins[0].display_label = None;
+ let display_tags =
+ listing_tags_with_options(&listing_with_display_fallback, ListingTagOptions::default())
+ .expect("listing tags with display fallback");
+ assert!(display_tags.iter().any(|tag| {
+ tag.first().map(|v| v.as_str()) == Some("radroots:bin")
+ && tag.last().map(|v| v.as_str()) == Some("fallback-label")
+ }));
+
+ let mut listing_with_geohash = sample_listing();
+ listing_with_geohash.location = Some(RadrootsListingLocation {
+ primary: "Origin".to_string(),
+ city: None,
+ region: None,
+ country: None,
+ lat: None,
+ lng: None,
+ geohash: Some("6gkzwgjzn".to_string()),
+ });
+ let decoded_tags = listing_tags_with_options(
+ &listing_with_geohash,
+ ListingTagOptions {
+ include_geohash: false,
+ include_gps: true,
+ ..ListingTagOptions::default()
+ },
+ )
+ .expect("listing tags with decoded geohash");
+ assert!(decoded_tags.iter().any(|tag| {
+ tag.first().map(|v| v.as_str()) == Some("l") && tag.get(2).map(|v| v.as_str()) == Some("dd")
+ }));
+
+ let mut listing_with_shared_geohash = sample_listing();
+ listing_with_shared_geohash.location = Some(RadrootsListingLocation {
+ primary: "Origin".to_string(),
+ city: None,
+ region: None,
+ country: None,
+ lat: None,
+ lng: None,
+ geohash: Some("6gkzwgjzn".to_string()),
+ });
+ let shared_geohash_tags =
+ listing_tags_with_options(&listing_with_shared_geohash, ListingTagOptions::default())
+ .expect("listing tags with shared geohash");
+ assert!(shared_geohash_tags.iter().any(|tag| {
+ tag.first().map(|v| v.as_str()) == Some("g")
+ && tag.get(1).map(|v| v.as_str()) == Some("6gkzwgjzn")
+ }));
+
+ let mut listing_without_coordinates = sample_listing();
+ listing_without_coordinates.location = Some(RadrootsListingLocation {
+ primary: "Origin".to_string(),
+ city: None,
+ region: None,
+ country: None,
+ lat: None,
+ lng: None,
+ geohash: None,
+ });
+ let no_coordinates_tags =
+ listing_tags_with_options(&listing_without_coordinates, ListingTagOptions::default())
+ .expect("listing tags without coordinates");
+ assert!(
+ !no_coordinates_tags
+ .iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("L"))
+ );
+ assert!(
+ !no_coordinates_tags
+ .iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("g"))
+ );
+
+ let mut listing_with_blank_optionals = sample_listing();
+ listing_with_blank_optionals.product.summary = Some(" ".to_string());
+ listing_with_blank_optionals.product.process = Some("null".to_string());
+ listing_with_blank_optionals.product.location = Some(" ".to_string());
+ listing_with_blank_optionals.product.profile = Some("null".to_string());
+ listing_with_blank_optionals.product.year = Some(" ".to_string());
+ listing_with_blank_optionals.location = Some(RadrootsListingLocation {
+ primary: " ".to_string(),
+ city: Some(" ".to_string()),
+ region: Some("null".to_string()),
+ country: Some(" ".to_string()),
+ lat: None,
+ lng: None,
+ geohash: None,
+ });
+ let blank_optional_tags =
+ listing_tags_with_options(&listing_with_blank_optionals, ListingTagOptions::default())
+ .expect("listing tags with blank optional values");
+ assert!(
+ !blank_optional_tags
+ .iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("summary"))
+ );
+ assert!(
+ !blank_optional_tags
+ .iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("location"))
+ );
+
+ let mut listing_no_gps = sample_listing();
+ listing_no_gps.location = Some(RadrootsListingLocation {
+ primary: "Origin".to_string(),
+ city: None,
+ region: None,
+ country: None,
+ lat: Some(37.0),
+ lng: Some(-122.0),
+ geohash: None,
+ });
+ let no_gps_tags = listing_tags_with_options(
+ &listing_no_gps,
+ ListingTagOptions {
+ include_gps: false,
+ ..ListingTagOptions::default()
+ },
+ )
+ .expect("listing tags without gps labels");
+ assert!(
+ !no_gps_tags
+ .iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("L"))
+ );
+
+ let mut listing_without_availability = sample_listing();
+ listing_without_availability.availability = None;
+ let no_availability_tags =
+ listing_tags_with_options(&listing_without_availability, with_trade_fields())
+ .expect("listing tags without availability");
+ assert!(
+ !no_availability_tags
+ .iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("published_at"))
+ );
+
+ let mut listing_pickup = sample_listing();
+ listing_pickup.delivery_method = Some(RadrootsListingDeliveryMethod::Pickup);
+ let pickup_tags = listing_tags_with_options(&listing_pickup, with_trade_fields())
+ .expect("listing tags with pickup delivery");
+ assert!(pickup_tags.iter().any(|tag| {
+ tag.first().map(|v| v.as_str()) == Some("delivery")
+ && tag.get(1).map(|v| v.as_str()) == Some("pickup")
+ }));
+
+ let mut listing_local = sample_listing();
+ listing_local.delivery_method = Some(RadrootsListingDeliveryMethod::LocalDelivery);
+ let local_tags = listing_tags_with_options(&listing_local, with_trade_fields())
+ .expect("listing tags with local delivery");
+ assert!(local_tags.iter().any(|tag| {
+ tag.first().map(|v| v.as_str()) == Some("delivery")
+ && tag.get(1).map(|v| v.as_str()) == Some("local_delivery")
+ }));
+
+ let mut listing_other_delivery = sample_listing();
+ listing_other_delivery.delivery_method = Some(RadrootsListingDeliveryMethod::Other {
+ method: "courier".to_string(),
+ });
+ let other_delivery_tags =
+ listing_tags_with_options(&listing_other_delivery, with_trade_fields())
+ .expect("listing tags with other delivery");
+ assert!(other_delivery_tags.iter().any(|tag| {
+ tag.first().map(|v| v.as_str()) == Some("delivery")
+ && tag.get(1).map(|v| v.as_str()) == Some("other")
+ && tag.get(2).map(|v| v.as_str()) == Some("courier")
+ }));
+
+ let mut invalid = sample_listing();
+ invalid.bins[0].bin_id = " ".to_string();
+ let err = listing_tags_with_options(&invalid, ListingTagOptions::default())
+ .expect_err("empty bin_id");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("bin_id")
+ ));
+
+ let mut invalid = sample_listing();
+ invalid.bins[0].display_price = None;
+ invalid.bins[0].display_price_unit = Some(RadrootsCoreUnit::Each);
+ let err = listing_tags_with_options(&invalid, ListingTagOptions::default())
+ .expect_err("missing display price");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("bin.display_price")
+ ));
+
+ let mut invalid = sample_listing();
+ invalid.bins[0].display_price = Some(RadrootsCoreMoney::new(
+ decimal("10"),
+ RadrootsCoreCurrency::USD,
+ ));
+ invalid.bins[0].display_price_unit = None;
+ let err = listing_tags_with_options(&invalid, ListingTagOptions::default())
+ .expect_err("missing display price unit");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("bin.display_price_unit")
+ ));
+
+ let mut listing_with_display_price = sample_listing();
+ listing_with_display_price.bins[0].display_price = Some(RadrootsCoreMoney::new(
+ decimal("10"),
+ RadrootsCoreCurrency::USD,
+ ));
+ listing_with_display_price.bins[0].display_price_unit = Some(RadrootsCoreUnit::Each);
+ let display_price_tags =
+ listing_tags_with_options(&listing_with_display_price, ListingTagOptions::default())
+ .expect("listing tags with display price");
+ assert!(display_price_tags.iter().any(|tag| {
+ tag.first().map(|v| v.as_str()) == Some("radroots:price")
+ && tag.get(6).map(|v| v.as_str()) == Some("10")
+ && tag.get(7).map(|v| v.as_str()) == Some("each")
+ }));
+
+ let mut invalid = listing_with_display_price.clone();
+ invalid.bins[0].display_price = Some(RadrootsCoreMoney::new(
+ decimal("10"),
+ RadrootsCoreCurrency::EUR,
+ ));
+ let err = listing_tags_with_options(&invalid, ListingTagOptions::default())
+ .expect_err("display price currency mismatch");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("bin.display_price")
+ ));
+
+ let mut invalid = sample_listing();
+ invalid.bins[0].display_amount = None;
+ invalid.bins[0].display_unit = Some(RadrootsCoreUnit::Each);
+ let err = listing_tags_with_options(&invalid, ListingTagOptions::default())
+ .expect_err("missing display amount");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("bin.display_amount")
+ ));
+
+ let mut invalid = sample_listing();
+ invalid.bins[0].quantity = RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassKg);
+ invalid.bins[0].price_per_canonical_unit = RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD),
+ RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassG),
+ );
+ let err = listing_tags_with_options(&invalid, ListingTagOptions::default())
+ .expect_err("non-canonical bin quantity");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("bin.quantity")
+ ));
+
+ let mut invalid = sample_listing();
+ invalid.bins[0].price_per_canonical_unit = RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD),
+ RadrootsCoreQuantity::new(decimal("2"), RadrootsCoreUnit::Each),
+ );
+ let err = listing_tags_with_options(&invalid, ListingTagOptions::default())
+ .expect_err("price must be per canonical unit");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit")
+ ));
+
+ let mut invalid = sample_listing();
+ invalid.bins[0].price_per_canonical_unit = RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new(decimal("10"), RadrootsCoreCurrency::USD),
+ RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassG),
+ );
+ let err = listing_tags_with_options(&invalid, ListingTagOptions::default())
+ .expect_err("non-convertible bin total price");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("bin.price_per_canonical_unit")
+ ));
+
+ let mut invalid = sample_listing();
+ invalid.farm.d_tag = " ".to_string();
+ let err = listing_build_tags(&invalid).expect_err("empty listing farm d_tag");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("farm.d_tag")
+ ));
+
+ let mut invalid = sample_listing();
+ invalid.d_tag = " ".to_string();
+ let err =
+ listing_tags_with_options(&invalid, ListingTagOptions::default()).expect_err("empty d");
+ assert!(matches!(err, EventEncodeError::EmptyRequiredField("d")));
+
+ let mut invalid = sample_listing();
+ invalid.primary_bin_id = " ".to_string();
+ let err = listing_tags_with_options(&invalid, ListingTagOptions::default())
+ .expect_err("empty primary_bin_id");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("primary_bin_id")
+ ));
+
+ let mut invalid = sample_listing();
+ invalid.bins.clear();
+ let err =
+ listing_tags_with_options(&invalid, ListingTagOptions::default()).expect_err("empty bins");
+ assert!(matches!(err, EventEncodeError::EmptyRequiredField("bins")));
+}
+
+#[test]
+fn resource_area_encode_and_list_set_paths() {
+ let tags = resource_area_build_tags(&sample_resource_area()).expect("resource area tags");
+ assert!(
+ tags.iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("d"))
+ );
+ assert!(
+ tags.iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("g"))
+ );
+ assert!(
+ tags.iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("t"))
+ );
+
+ let mut area = sample_resource_area();
+ area.tags = None;
+ let tags = resource_area_build_tags(&area).expect("resource area tags without optional tags");
+ assert!(
+ !tags
+ .iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("t"))
+ );
+
+ let mut area = sample_resource_area();
+ area.d_tag = " ".to_string();
+ let err = resource_area_build_tags(&area).expect_err("empty d_tag");
+ assert!(matches!(err, EventEncodeError::EmptyRequiredField("d_tag")));
+
+ let mut area = sample_resource_area();
+ area.name = " ".to_string();
+ let err = resource_area_build_tags(&area).expect_err("empty name");
+ assert!(matches!(err, EventEncodeError::EmptyRequiredField("name")));
+
+ let mut area = sample_resource_area();
+ area.location.gcs.geohash = " ".to_string();
+ let err = resource_area_build_tags(&area).expect_err("empty geohash");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("location.gcs.geohash")
+ ));
+
+ let tags = resource_area_ref_tags(&RadrootsResourceAreaRef {
+ pubkey: VALID_PUBKEY.to_string(),
+ d_tag: VALID_AREA_D_TAG.to_string(),
+ })
+ .expect("resource area ref tags");
+ assert_eq!(tags.len(), 2);
+
+ let err = resource_area_ref_tags(&RadrootsResourceAreaRef {
+ pubkey: " ".to_string(),
+ d_tag: VALID_AREA_D_TAG.to_string(),
+ })
+ .expect_err("empty resource area pubkey");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("resource_area.pubkey")
+ ));
+
+ let err = resource_area_ref_tags(&RadrootsResourceAreaRef {
+ pubkey: VALID_PUBKEY.to_string(),
+ d_tag: " ".to_string(),
+ })
+ .expect_err("empty resource area d_tag");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("resource_area.d_tag")
+ ));
+
+ let err = resource_area_ref_tags(&RadrootsResourceAreaRef {
+ pubkey: VALID_PUBKEY.to_string(),
+ d_tag: "invalid".to_string(),
+ })
+ .expect_err("invalid resource area d_tag");
+ assert!(matches!(
+ err,
+ EventEncodeError::InvalidField("resource_area.d_tag")
+ ));
+
+ let err = resource_area_members_farms_list_set(
+ "invalid",
+ vec![RadrootsFarmRef {
+ pubkey: VALID_PUBKEY.to_string(),
+ d_tag: VALID_FARM_D_TAG.to_string(),
+ }],
+ )
+ .expect_err("invalid area id");
+ assert!(matches!(err, EventEncodeError::InvalidField("area_id")));
+
+ let err =
+ resource_area_stewards_list_set(VALID_AREA_D_TAG, [" "]).expect_err("empty steward entry");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("entry.values")
+ ));
+
+ let stewards =
+ resource_area_stewards_list_set(VALID_AREA_D_TAG, ["steward"]).expect("stewards list set");
+ assert_eq!(stewards.entries.len(), 1);
+ assert_eq!(stewards.entries[0].tag, "p");
+
+ let err = resource_area_members_farms_list_set(
+ VALID_AREA_D_TAG,
+ vec![RadrootsFarmRef {
+ pubkey: " ".to_string(),
+ d_tag: VALID_FARM_D_TAG.to_string(),
+ }],
+ )
+ .expect_err("empty farm pubkey");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("farm.pubkey")
+ ));
+
+ let err = resource_area_members_farms_list_set(
+ VALID_AREA_D_TAG,
+ vec![RadrootsFarmRef {
+ pubkey: VALID_PUBKEY.to_string(),
+ d_tag: " ".to_string(),
+ }],
+ )
+ .expect_err("empty farm d_tag");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("farm.d_tag")
+ ));
+
+ let err = resource_area_members_farms_list_set(
+ VALID_AREA_D_TAG,
+ vec![RadrootsFarmRef {
+ pubkey: VALID_PUBKEY.to_string(),
+ d_tag: "invalid".to_string(),
+ }],
+ )
+ .expect_err("invalid farm d_tag");
+ assert!(matches!(err, EventEncodeError::InvalidField("farm.d_tag")));
+
+ let err = resource_area_members_plots_list_set(
+ VALID_AREA_D_TAG,
+ vec![RadrootsPlotRef {
+ pubkey: " ".to_string(),
+ d_tag: VALID_PLOT_D_TAG.to_string(),
+ }],
+ )
+ .expect_err("empty plot pubkey");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("plot.pubkey")
+ ));
+
+ let err = resource_area_members_plots_list_set(
+ VALID_AREA_D_TAG,
+ vec![RadrootsPlotRef {
+ pubkey: VALID_PUBKEY.to_string(),
+ d_tag: " ".to_string(),
+ }],
+ )
+ .expect_err("empty plot d_tag");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("plot.d_tag")
+ ));
+
+ let err = resource_area_members_plots_list_set(
+ VALID_AREA_D_TAG,
+ vec![RadrootsPlotRef {
+ pubkey: VALID_PUBKEY.to_string(),
+ d_tag: "invalid".to_string(),
+ }],
+ )
+ .expect_err("invalid plot d_tag");
+ assert!(matches!(err, EventEncodeError::InvalidField("plot.d_tag")));
+}
+
+#[test]
+fn resource_harvest_cap_encode_paths() {
+ let tags = resource_harvest_cap_build_tags(&sample_resource_cap()).expect("resource cap tags");
+ assert!(
+ tags.iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("category"))
+ );
+
+ let mut cap = sample_resource_cap();
+ cap.product.category = None;
+ let tags = resource_harvest_cap_build_tags(&cap).expect("resource cap tags without category");
+ assert!(
+ !tags
+ .iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("category"))
+ );
+
+ let mut cap = sample_resource_cap();
+ cap.d_tag = " ".to_string();
+ let err = resource_harvest_cap_build_tags(&cap).expect_err("empty cap d_tag");
+ assert!(matches!(err, EventEncodeError::EmptyRequiredField("d_tag")));
+
+ let mut cap = sample_resource_cap();
+ cap.d_tag = "invalid".to_string();
+ let err = resource_harvest_cap_build_tags(&cap).expect_err("invalid cap d_tag");
+ assert!(matches!(err, EventEncodeError::InvalidField("d_tag")));
+
+ let mut cap = sample_resource_cap();
+ cap.product.key = " ".to_string();
+ let err = resource_harvest_cap_build_tags(&cap).expect_err("empty product key");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("product.key")
+ ));
+
+ let mut cap = sample_resource_cap();
+ cap.resource_area.pubkey = " ".to_string();
+ let err = resource_harvest_cap_build_tags(&cap).expect_err("empty resource_area pubkey");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("resource_area.pubkey")
+ ));
+
+ let mut cap = sample_resource_cap();
+ cap.resource_area.d_tag = " ".to_string();
+ let err = resource_harvest_cap_build_tags(&cap).expect_err("empty resource_area d_tag");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("resource_area.d_tag")
+ ));
+
+ let mut cap = sample_resource_cap();
+ cap.resource_area.d_tag = "invalid".to_string();
+ let err = resource_harvest_cap_build_tags(&cap).expect_err("invalid resource_area d_tag");
+ assert!(matches!(
+ err,
+ EventEncodeError::InvalidField("resource_area.d_tag")
+ ));
+}
+
+#[test]
+fn document_encode_paths() {
+ let tags = document_build_tags(&sample_document()).expect("document tags");
+ assert!(
+ tags.iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("d"))
+ );
+ assert!(
+ tags.iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("p"))
+ );
+ assert!(
+ tags.iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("a"))
+ );
+ assert!(
+ tags.iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("t"))
+ );
+
+ let mut document = sample_document();
+ document.subject.address = None;
+ document.tags = None;
+ let tags = document_build_tags(&document).expect("document without optional tags");
+ assert!(
+ !tags
+ .iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("a"))
+ );
+ assert!(
+ !tags
+ .iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("t"))
+ );
+
+ let mut document = sample_document();
+ document.d_tag = " ".to_string();
+ let err = document_build_tags(&document).expect_err("empty d_tag");
+ assert!(matches!(err, EventEncodeError::EmptyRequiredField("d_tag")));
+
+ let mut document = sample_document();
+ document.doc_type = " ".to_string();
+ let err = document_build_tags(&document).expect_err("empty doc_type");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("doc_type")
+ ));
+
+ let mut document = sample_document();
+ document.title = " ".to_string();
+ let err = document_build_tags(&document).expect_err("empty title");
+ assert!(matches!(err, EventEncodeError::EmptyRequiredField("title")));
+
+ let mut document = sample_document();
+ document.version = " ".to_string();
+ let err = document_build_tags(&document).expect_err("empty version");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("version")
+ ));
+
+ let mut document = sample_document();
+ document.subject.pubkey = " ".to_string();
+ let err = document_build_tags(&document).expect_err("empty subject pubkey");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("subject.pubkey")
+ ));
+
+ let mut document = sample_document();
+ document.subject.address = Some(" ".to_string());
+ let err = document_build_tags(&document).expect_err("empty subject address");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("subject.address")
+ ));
+
+ let mut document = sample_document();
+ document.d_tag = "invalid".to_string();
+ let err = document_build_tags(&document).expect_err("invalid d_tag");
+ assert!(matches!(err, EventEncodeError::InvalidField("d_tag")));
+}
+
+#[test]
+fn resource_harvest_cap_money_type_sanity() {
+ let money = RadrootsCoreMoney::new(decimal("1"), RadrootsCoreCurrency::USD);
+ assert_eq!(money.amount.to_string(), "1");
+}
diff --git a/crates/events-codec/tests/event_ref.rs b/crates/events-codec/tests/event_ref.rs
@@ -100,6 +100,22 @@ fn parse_event_ref_tag_rejects_wrong_tag_name_and_missing_fields() {
}
#[test]
+fn parse_event_ref_tag_rejects_missing_required_values() {
+ let err = parse_event_ref_tag(&["e".to_string()], "e").unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("e")));
+
+ let err = parse_event_ref_tag(&["e".to_string(), "id".to_string()], "e").unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("e")));
+
+ let err = parse_event_ref_tag(
+ &["e".to_string(), "id".to_string(), "author".to_string()],
+ "e",
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("e")));
+}
+
+#[test]
fn find_event_ref_tag_locates_first_match() {
let event = common::event_ref("id", "author", KIND_POST);
let tags = vec![
@@ -184,6 +200,50 @@ fn parse_nip10_ref_tags_rejects_missing_or_invalid_required_tags() {
}
#[test]
+fn parse_nip10_ref_tags_rejects_missing_required_values() {
+ let tags = vec![
+ vec!["e".to_string()],
+ vec!["p".to_string(), "author".to_string()],
+ vec!["k".to_string(), KIND_POST.to_string()],
+ ];
+ let err = parse_nip10_ref_tags(&tags, "e", "p", "k", "a").unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("e")));
+
+ let tags = vec![
+ vec!["e".to_string(), "id".to_string()],
+ vec!["p".to_string()],
+ vec!["k".to_string(), KIND_POST.to_string()],
+ ];
+ let err = parse_nip10_ref_tags(&tags, "e", "p", "k", "a").unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("p")));
+
+ let tags = vec![
+ vec!["e".to_string(), "id".to_string()],
+ vec!["p".to_string(), "author".to_string()],
+ vec!["k".to_string()],
+ ];
+ let err = parse_nip10_ref_tags(&tags, "e", "p", "k", "a").unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("k")));
+}
+
+#[test]
+fn parse_nip10_ref_tags_rejects_missing_p_and_k_tags() {
+ let missing_p = vec![
+ vec!["e".to_string(), "id".to_string()],
+ vec!["k".to_string(), KIND_POST.to_string()],
+ ];
+ let err = parse_nip10_ref_tags(&missing_p, "e", "p", "k", "a").unwrap_err();
+ assert!(matches!(err, EventParseError::MissingTag("p")));
+
+ let missing_k = vec![
+ vec!["e".to_string(), "id".to_string()],
+ vec!["p".to_string(), "author".to_string()],
+ ];
+ let err = parse_nip10_ref_tags(&missing_k, "e", "p", "k", "a").unwrap_err();
+ assert!(matches!(err, EventParseError::MissingTag("k")));
+}
+
+#[test]
fn parse_nip10_ref_tags_prefers_e_relays_and_can_fall_back_to_a_relays() {
let tags = vec![
vec!["e".to_string(), "id".to_string()],
diff --git a/crates/events-codec/tests/follow.rs b/crates/events-codec/tests/follow.rs
@@ -136,6 +136,13 @@ fn follow_from_tags_rejects_invalid_published_at_number() {
}
#[test]
+fn follow_from_tags_rejects_missing_public_key_value() {
+ let tags = vec![vec!["p".to_string()]];
+ let err = follow_from_tags(KIND_FOLLOW, &tags, 123).unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("p")));
+}
+
+#[test]
fn follow_metadata_and_index_from_event_roundtrip() {
let tags = vec![vec![
"p".to_string(),
@@ -335,6 +342,74 @@ fn follow_apply_rejects_empty_pubkey() {
}
#[test]
+fn follow_apply_rejects_empty_pubkey_for_unfollow_and_toggle() {
+ let follow = RadrootsFollow { list: Vec::new() };
+ let err = follow_apply(
+ &follow,
+ FollowMutation::Unfollow {
+ public_key: " ".to_string(),
+ },
+ )
+ .unwrap_err();
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("follow.public_key")
+ ));
+
+ let err = follow_apply(
+ &follow,
+ FollowMutation::Toggle {
+ public_key: " ".to_string(),
+ relay_url: None,
+ contact_name: None,
+ },
+ )
+ .unwrap_err();
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("follow.public_key")
+ ));
+}
+
+#[test]
+fn follow_apply_rejects_invalid_existing_entries_and_after_mutation_propagates_error() {
+ let follow = RadrootsFollow {
+ list: vec![RadrootsFollowProfile {
+ published_at: 1,
+ public_key: " ".to_string(),
+ relay_url: None,
+ contact_name: None,
+ }],
+ };
+
+ let err = follow_apply(
+ &follow,
+ FollowMutation::Unfollow {
+ public_key: "pubkey-a".to_string(),
+ },
+ )
+ .unwrap_err();
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("follow.public_key")
+ ));
+
+ let err = follow_to_wire_parts_after(
+ &RadrootsFollow { list: Vec::new() },
+ FollowMutation::Follow {
+ public_key: " ".to_string(),
+ relay_url: None,
+ contact_name: None,
+ },
+ )
+ .unwrap_err();
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("follow.public_key")
+ ));
+}
+
+#[test]
fn follow_build_tags_normalizes_empty_optional_values() {
let follow = RadrootsFollow {
list: vec![RadrootsFollowProfile {
diff --git a/crates/events-codec/tests/geochat.rs b/crates/events-codec/tests/geochat.rs
@@ -67,6 +67,18 @@ fn geochat_to_wire_parts_requires_content() {
err,
EventEncodeError::EmptyRequiredField("content")
));
+
+ let geochat = RadrootsGeoChat {
+ geohash: " ".to_string(),
+ content: "hello".to_string(),
+ nickname: None,
+ teleported: false,
+ };
+ let err = to_wire_parts(&geochat).unwrap_err();
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("geohash")
+ ));
}
#[test]
@@ -171,6 +183,34 @@ fn geochat_from_tags_rejects_invalid_optional_tags() {
}
#[test]
+fn geochat_from_tags_rejects_missing_tag_values() {
+ let err = geochat_from_tags(KIND_GEOCHAT, &[vec!["g".to_string()]], "hello").unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("g")));
+
+ let err = geochat_from_tags(
+ KIND_GEOCHAT,
+ &[
+ vec!["g".to_string(), "dr5rsj7".to_string()],
+ vec!["n".to_string()],
+ ],
+ "hello",
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("n")));
+
+ let err = geochat_from_tags(
+ KIND_GEOCHAT,
+ &[
+ vec!["g".to_string(), "dr5rsj7".to_string()],
+ vec!["t".to_string()],
+ ],
+ "hello",
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("t")));
+}
+
+#[test]
fn geochat_metadata_and_index_from_event_roundtrip() {
let tags = vec![
vec!["g".to_string(), "dr5rsj7".to_string()],
diff --git a/crates/events-codec/tests/gift_wrap.rs b/crates/events-codec/tests/gift_wrap.rs
@@ -71,6 +71,9 @@ fn gift_wrap_from_tags_rejects_wrong_kind() {
fn gift_wrap_from_tags_requires_p_tag() {
let err = gift_wrap_from_tags(KIND_GIFT_WRAP, &[], "payload").unwrap_err();
assert!(matches!(err, EventParseError::MissingTag("p")));
+
+ let err = gift_wrap_from_tags(KIND_GIFT_WRAP, &[vec!["p".to_string()]], "payload").unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("p")));
}
#[test]
@@ -201,6 +204,14 @@ fn gift_wrap_to_wire_parts_requires_content_and_accepts_default_kind() {
let parts = to_wire_parts_with_kind(&sample_gift_wrap(), KIND_GIFT_WRAP).unwrap();
assert_eq!(parts.kind, KIND_GIFT_WRAP);
assert_eq!(parts.content, "encrypted");
+
+ let mut gift_wrap = sample_gift_wrap();
+ gift_wrap.recipient.public_key = " ".to_string();
+ let err = to_wire_parts(&gift_wrap).unwrap_err();
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("recipient.public_key")
+ ));
}
#[test]
diff --git a/crates/events-codec/tests/job_feedback.rs b/crates/events-codec/tests/job_feedback.rs
@@ -67,6 +67,10 @@ fn job_feedback_requires_status_tag() {
let tags = vec![vec!["e".to_string(), "req".to_string()]];
let err = job_feedback_from_tags(KIND_JOB_FEEDBACK, &tags, "payload").unwrap_err();
assert!(matches!(err, JobParseError::MissingTag("status")));
+
+ let tags = vec![vec!["status".to_string(), "processing".to_string()]];
+ let err = job_feedback_from_tags(KIND_JOB_FEEDBACK, &tags, "payload").unwrap_err();
+ assert!(matches!(err, JobParseError::MissingTag("e")));
}
#[test]
@@ -77,6 +81,58 @@ fn job_feedback_rejects_unknown_status() {
];
let err = job_feedback_from_tags(KIND_JOB_FEEDBACK, &tags, "payload").unwrap_err();
assert!(matches!(err, JobParseError::InvalidTag("status")));
+
+ let tags = vec![
+ vec!["status".to_string(), "processing".to_string()],
+ vec!["e".to_string()],
+ ];
+ let err = job_feedback_from_tags(KIND_JOB_FEEDBACK, &tags, "payload").unwrap_err();
+ assert!(matches!(err, JobParseError::InvalidTag("e")));
+
+ let tags = vec![
+ vec!["status".to_string(), "processing".to_string()],
+ vec!["e".to_string(), "req".to_string()],
+ vec!["amount".to_string(), "not-a-number".to_string()],
+ ];
+ let err = job_feedback_from_tags(KIND_JOB_FEEDBACK, &tags, "payload").unwrap_err();
+ assert!(matches!(err, JobParseError::InvalidNumber("amount", _)));
+}
+
+#[test]
+fn job_feedback_data_from_event_success_path() {
+ let tags = vec![
+ vec!["status".to_string(), "processing".to_string()],
+ vec!["e".to_string(), "req".to_string()],
+ vec!["amount".to_string(), "12000".to_string()],
+ ];
+ let data = radroots_events_codec::job::feedback::decode::data_from_event(
+ "id".to_string(),
+ "author".to_string(),
+ 1,
+ KIND_JOB_FEEDBACK,
+ "payload".to_string(),
+ tags,
+ )
+ .expect("job feedback data");
+ assert_eq!(data.id, "id");
+ assert_eq!(data.author, "author");
+ assert_eq!(data.kind, KIND_JOB_FEEDBACK);
+ assert_eq!(data.data.request_event.id, "req");
+ assert_eq!(data.data.payment.as_ref().map(|p| p.amount_sat), Some(12));
+}
+
+#[test]
+fn job_feedback_data_from_event_propagates_decode_errors_with_valid_kind() {
+ let err = radroots_events_codec::job::feedback::decode::data_from_event(
+ "id".to_string(),
+ "author".to_string(),
+ 1,
+ KIND_JOB_FEEDBACK,
+ "payload".to_string(),
+ Vec::new(),
+ )
+ .unwrap_err();
+ assert!(matches!(err, JobParseError::MissingTag("e")));
}
#[test]
diff --git a/crates/events-codec/tests/job_request.rs b/crates/events-codec/tests/job_request.rs
@@ -91,6 +91,16 @@ fn job_request_to_wire_parts_allows_encrypted_when_provider_present() {
}
#[test]
+fn job_request_from_tags_rejects_invalid_bid_tag() {
+ let err = job_request_from_tags(
+ KIND_JOB_REQUEST_MIN + 1,
+ &[vec!["bid".to_string(), "not-a-number".to_string()]],
+ )
+ .unwrap_err();
+ assert!(matches!(err, JobParseError::InvalidNumber("bid", _)));
+}
+
+#[test]
fn job_request_metadata_rejects_wrong_kind() {
let err = radroots_events_codec::job::request::decode::data_from_event(
"id".to_string(),
@@ -108,6 +118,37 @@ fn job_request_metadata_rejects_wrong_kind() {
}
#[test]
+fn job_request_data_from_event_success_path() {
+ let request = sample_request();
+ let parts = to_wire_parts(&request, "payload").expect("wire parts");
+ let data = radroots_events_codec::job::request::decode::data_from_event(
+ "id".to_string(),
+ "author".to_string(),
+ 1,
+ parts.kind,
+ parts.tags,
+ )
+ .expect("job request data");
+ assert_eq!(data.id, "id");
+ assert_eq!(data.author, "author");
+ assert_eq!(data.kind, KIND_JOB_REQUEST_MIN + 1);
+ assert_eq!(data.data.providers, vec!["provider".to_string()]);
+}
+
+#[test]
+fn job_request_data_from_event_propagates_decode_errors_with_valid_kind() {
+ let err = radroots_events_codec::job::request::decode::data_from_event(
+ "id".to_string(),
+ "author".to_string(),
+ 1,
+ KIND_JOB_REQUEST_MIN + 1,
+ vec![vec!["bid".to_string(), "not-a-number".to_string()]],
+ )
+ .unwrap_err();
+ assert!(matches!(err, JobParseError::InvalidNumber("bid", _)));
+}
+
+#[test]
fn job_request_index_from_event_propagates_parse_errors() {
let err = parsed_from_event(
"id".to_string(),
diff --git a/crates/events-codec/tests/job_result.rs b/crates/events-codec/tests/job_result.rs
@@ -162,6 +162,20 @@ fn job_result_requires_request_event_tag() {
let tags = vec![vec!["p".to_string(), "customer".to_string()]];
let err = job_result_from_tags(KIND_JOB_RESULT_MIN + 1, &tags, "payload").unwrap_err();
assert!(matches!(err, JobParseError::MissingTag("e")));
+
+ let tags = vec![
+ vec!["e".to_string()],
+ vec!["amount".to_string(), "not-a-number".to_string()],
+ ];
+ let err = job_result_from_tags(KIND_JOB_RESULT_MIN + 1, &tags, "payload").unwrap_err();
+ assert!(matches!(err, JobParseError::InvalidTag("e")));
+
+ let tags = vec![
+ vec!["e".to_string(), "req".to_string()],
+ vec!["amount".to_string(), "not-a-number".to_string()],
+ ];
+ let err = job_result_from_tags(KIND_JOB_RESULT_MIN + 1, &tags, "payload").unwrap_err();
+ assert!(matches!(err, JobParseError::InvalidNumber("amount", _)));
}
#[test]
@@ -183,6 +197,40 @@ fn job_result_metadata_rejects_wrong_kind() {
}
#[test]
+fn job_result_data_from_event_success_path() {
+ let result = sample_result();
+ let content = result.content.clone().unwrap();
+ let parts = to_wire_parts(&result, &content).expect("wire parts");
+ let data = radroots_events_codec::job::result::decode::data_from_event(
+ "id".to_string(),
+ "author".to_string(),
+ 1,
+ parts.kind,
+ content,
+ parts.tags,
+ )
+ .expect("job result data");
+ assert_eq!(data.id, "id");
+ assert_eq!(data.author, "author");
+ assert_eq!(data.kind, KIND_JOB_RESULT_MIN + 1);
+ assert_eq!(data.data.request_event.id, "req");
+}
+
+#[test]
+fn job_result_data_from_event_propagates_decode_errors_with_valid_kind() {
+ let err = radroots_events_codec::job::result::decode::data_from_event(
+ "id".to_string(),
+ "author".to_string(),
+ 1,
+ KIND_JOB_RESULT_MIN + 1,
+ "payload".to_string(),
+ Vec::new(),
+ )
+ .unwrap_err();
+ assert!(matches!(err, JobParseError::MissingTag("e")));
+}
+
+#[test]
fn job_result_index_from_event_propagates_parse_errors() {
let err = parsed_from_event(
"id".to_string(),
diff --git a/crates/events-codec/tests/list.rs b/crates/events-codec/tests/list.rs
@@ -65,6 +65,25 @@ fn list_encode_and_decode_reject_invalid_inputs() {
EventEncodeError::EmptyRequiredField("entry.values")
));
+ let invalid = RadrootsList {
+ content: "".to_string(),
+ entries: vec![RadrootsListEntry {
+ tag: "p".to_string(),
+ values: Vec::new(),
+ }],
+ };
+ let err = list_build_tags(&invalid).unwrap_err();
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("entry.values")
+ ));
+
+ let err = to_wire_parts_with_kind(&invalid, KIND_LIST_MUTE).unwrap_err();
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("entry.values")
+ ));
+
let err = to_wire_parts_with_kind(&sample_list(), KIND_POST).unwrap_err();
assert!(matches!(err, EventEncodeError::InvalidKind(KIND_POST)));
@@ -85,6 +104,14 @@ fn list_entries_from_tags_rejects_empty_entry_fields() {
let err = list_entries_from_tags(&[vec!["p".to_string(), " ".to_string()]]).unwrap_err();
assert!(matches!(err, EventParseError::InvalidTag("tag")));
+
+ let err = list_from_tags(
+ KIND_LIST_MUTE,
+ "private".to_string(),
+ &[vec!["".to_string(), "x".to_string()]],
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("tag")));
}
#[test]
diff --git a/crates/events-codec/tests/list_set.rs b/crates/events-codec/tests/list_set.rs
@@ -58,6 +58,8 @@ fn list_set_encode_and_decode_reject_invalid_inputs() {
};
let err = list_set_build_tags(&invalid).unwrap_err();
assert!(matches!(err, EventEncodeError::EmptyRequiredField("d_tag")));
+ let err = to_wire_parts_with_kind(&invalid, KIND_LIST_SET_FOLLOW).unwrap_err();
+ assert!(matches!(err, EventEncodeError::EmptyRequiredField("d_tag")));
let invalid = RadrootsListSet {
d_tag: "farm:invalid:owners".to_string(),
@@ -138,6 +140,17 @@ fn list_set_decode_rejects_invalid_tag_shapes() {
)
.unwrap_err();
assert!(matches!(err, EventParseError::InvalidTag("tag")));
+
+ let err = list_set_from_tags(
+ KIND_LIST_SET_FOLLOW,
+ "".to_string(),
+ &[
+ vec!["d".to_string(), "farm:invalid:members".to_string()],
+ vec!["p".to_string(), "owner".to_string()],
+ ],
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("d")));
}
#[test]
diff --git a/crates/events-codec/tests/listing.rs b/crates/events-codec/tests/listing.rs
@@ -18,7 +18,9 @@ use radroots_events::{
use radroots_events_codec::error::{EventEncodeError, EventParseError};
use radroots_events_codec::listing::decode::listing_from_event;
use radroots_events_codec::listing::encode::{listing_build_tags, to_wire_parts};
-use radroots_events_codec::listing::tags::listing_tags_full;
+use radroots_events_codec::listing::tags::{
+ ListingTagOptions, listing_tags_full, listing_tags_with_options,
+};
use std::str::FromStr;
fn sample_listing(d_tag: &str) -> RadrootsListing {
@@ -410,3 +412,76 @@ fn listing_build_tags_ignores_null_strings() {
.any(|tag| tag.iter().any(|value| value == "null"))
);
}
+
+#[test]
+fn listing_tags_with_options_cover_location_fallback_paths() {
+ let mut geohash_only = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
+ geohash_only.location = Some(RadrootsListingLocation {
+ primary: "Moyobamba".to_string(),
+ city: None,
+ region: None,
+ country: None,
+ lat: None,
+ lng: None,
+ geohash: Some("6gkzwgjzn".to_string()),
+ });
+ let tags = listing_tags_with_options(&geohash_only, ListingTagOptions::default()).unwrap();
+ assert!(
+ tags.iter()
+ .any(|tag| tag.get(0).map(|value| value.as_str()) == Some("g"))
+ );
+ assert!(tags.iter().any(|tag| {
+ tag.get(0).map(|value| value.as_str()) == Some("l")
+ && tag.get(2).map(|value| value.as_str()) == Some("dd")
+ }));
+
+ let mut no_coordinates = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ");
+ no_coordinates.location = Some(RadrootsListingLocation {
+ primary: "Moyobamba".to_string(),
+ city: None,
+ region: None,
+ country: None,
+ lat: None,
+ lng: None,
+ geohash: None,
+ });
+ let tags = listing_tags_with_options(&no_coordinates, ListingTagOptions::default()).unwrap();
+ assert!(
+ !tags
+ .iter()
+ .any(|tag| tag.get(0).map(|value| value.as_str()) == Some("L"))
+ );
+ assert!(
+ !tags
+ .iter()
+ .any(|tag| tag.get(0).map(|value| value.as_str()) == Some("g"))
+ );
+
+ let mut no_gps = sample_listing("AAAAAAAAAAAAAAAAAAAAAw");
+ no_gps.location = Some(RadrootsListingLocation {
+ primary: "Moyobamba".to_string(),
+ city: None,
+ region: None,
+ country: None,
+ lat: Some(-6.0346),
+ lng: Some(-76.9714),
+ geohash: None,
+ });
+ let tags = listing_tags_with_options(
+ &no_gps,
+ ListingTagOptions {
+ include_gps: false,
+ ..ListingTagOptions::default()
+ },
+ )
+ .unwrap();
+ assert!(
+ tags.iter()
+ .any(|tag| tag.get(0).map(|value| value.as_str()) == Some("g"))
+ );
+ assert!(
+ !tags
+ .iter()
+ .any(|tag| tag.get(0).map(|value| value.as_str()) == Some("L"))
+ );
+}
diff --git a/crates/events-codec/tests/message.rs b/crates/events-codec/tests/message.rs
@@ -61,6 +61,21 @@ fn message_to_wire_parts_requires_content() {
err,
EventEncodeError::EmptyRequiredField("content")
));
+
+ let message = RadrootsMessage {
+ recipients: vec![RadrootsMessageRecipient {
+ public_key: " ".to_string(),
+ relay_url: None,
+ }],
+ content: "hello".to_string(),
+ reply_to: None,
+ subject: None,
+ };
+ let err = to_wire_parts(&message).unwrap_err();
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("recipients.public_key")
+ ));
}
#[test]
@@ -123,6 +138,31 @@ fn message_to_wire_parts_handles_absent_optional_fields() {
}
#[test]
+fn message_to_wire_parts_supports_reply_without_relay() {
+ let message = RadrootsMessage {
+ recipients: vec![RadrootsMessageRecipient {
+ public_key: "pub1".to_string(),
+ relay_url: None,
+ }],
+ content: "hello".to_string(),
+ reply_to: Some(RadrootsNostrEventPtr {
+ id: "reply".to_string(),
+ relays: None,
+ }),
+ subject: None,
+ };
+
+ let parts = to_wire_parts(&message).unwrap();
+ assert_eq!(
+ parts.tags,
+ vec![
+ vec!["p".to_string(), "pub1".to_string()],
+ vec!["e".to_string(), "reply".to_string()],
+ ]
+ );
+}
+
+#[test]
fn message_from_tags_requires_kind_content_and_recipients() {
let tags = vec![vec!["p".to_string(), "pub".to_string()]];
let err = message_from_tags(KIND_POST, &tags, "hello").unwrap_err();
@@ -178,6 +218,20 @@ fn message_roundtrip_from_tags() {
Some("wss://reply.example")
);
assert_eq!(message.subject.as_deref(), Some("topic"));
+
+ let tags_without_reply_relay = vec![
+ vec!["p".to_string(), "pub1".to_string()],
+ vec!["e".to_string(), "reply".to_string()],
+ ];
+ let no_relay_message = message_from_tags(KIND_MESSAGE, &tags_without_reply_relay, "hello")
+ .expect("message without reply relay");
+ assert_eq!(
+ no_relay_message
+ .reply_to
+ .as_ref()
+ .and_then(|reply| reply.relays.as_deref()),
+ None
+ );
}
#[test]
@@ -323,6 +377,17 @@ fn message_from_tags_rejects_invalid_optional_tags() {
let err = message_from_tags(
KIND_MESSAGE,
&[
+ vec!["p".to_string()],
+ vec!["e".to_string(), "reply".to_string()],
+ ],
+ "hello",
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("p")));
+
+ let err = message_from_tags(
+ KIND_MESSAGE,
+ &[
vec!["p".to_string(), "pub".to_string(), " ".to_string()],
vec!["e".to_string(), "reply".to_string()],
],
@@ -335,6 +400,17 @@ fn message_from_tags_rejects_invalid_optional_tags() {
KIND_MESSAGE,
&[
vec!["p".to_string(), "pub".to_string()],
+ vec!["e".to_string()],
+ ],
+ "hello",
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("e")));
+
+ let err = message_from_tags(
+ KIND_MESSAGE,
+ &[
+ vec!["p".to_string(), "pub".to_string()],
vec!["e".to_string(), " ".to_string()],
],
"hello",
diff --git a/crates/events-codec/tests/message_file.rs b/crates/events-codec/tests/message_file.rs
@@ -46,6 +46,17 @@ fn sample_message_file() -> RadrootsMessageFile {
}
}
+fn minimal_message_file_tags() -> Vec<Vec<String>> {
+ vec![
+ vec!["p".to_string(), "pub1".to_string()],
+ vec!["file-type".to_string(), "image/jpeg".to_string()],
+ vec!["encryption-algorithm".to_string(), "aes-gcm".to_string()],
+ vec!["decryption-key".to_string(), "key".to_string()],
+ vec!["decryption-nonce".to_string(), "nonce".to_string()],
+ vec!["x".to_string(), "hash".to_string()],
+ ]
+}
+
#[test]
fn message_file_build_tags_requires_recipients() {
let mut message = sample_message_file();
@@ -71,6 +82,17 @@ fn message_file_to_wire_parts_requires_file_url() {
}
#[test]
+fn message_file_to_wire_parts_propagates_tag_build_errors() {
+ let mut message = sample_message_file();
+ message.file_type = " ".to_string();
+ let err = to_wire_parts(&message).unwrap_err();
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("file_type")
+ ));
+}
+
+#[test]
fn message_file_build_tags_requires_file_type() {
let mut message = sample_message_file();
message.file_type = " ".to_string();
@@ -107,6 +129,44 @@ fn message_file_build_tags_requires_crypto_fields() {
err,
EventEncodeError::EmptyRequiredField("decryption_nonce")
));
+
+ let mut message = sample_message_file();
+ message.encrypted_hash = " ".to_string();
+ let err = message_file_build_tags(&message).unwrap_err();
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("encrypted_hash")
+ ));
+}
+
+#[test]
+fn message_file_build_tags_rejects_invalid_reply_subject_and_fallbacks() {
+ let mut message = sample_message_file();
+ message.reply_to = Some(RadrootsNostrEventPtr {
+ id: " ".to_string(),
+ relays: None,
+ });
+ let err = message_file_build_tags(&message).unwrap_err();
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("reply_to.id")
+ ));
+
+ let mut message = sample_message_file();
+ message.subject = Some(" ".to_string());
+ let err = message_file_build_tags(&message).unwrap_err();
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("subject")
+ ));
+
+ let mut message = sample_message_file();
+ message.fallbacks = vec![" ".to_string()];
+ let err = message_file_build_tags(&message).unwrap_err();
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("fallback")
+ ));
}
#[test]
@@ -400,3 +460,135 @@ fn message_file_from_tags_rejects_empty_content() {
.unwrap_err();
assert!(matches!(err, EventParseError::InvalidTag("content")));
}
+
+#[test]
+fn message_file_from_tags_rejects_more_invalid_tag_shapes() {
+ let mut tags = minimal_message_file_tags();
+ tags[1].truncate(1);
+ let err = message_file_from_tags(
+ KIND_MESSAGE_FILE,
+ &tags,
+ "https://files.example/encrypted.bin",
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::MissingTag("file-type")));
+
+ let mut tags = minimal_message_file_tags();
+ tags[0][1] = " ".to_string();
+ let err = message_file_from_tags(
+ KIND_MESSAGE_FILE,
+ &tags,
+ "https://files.example/encrypted.bin",
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("p")));
+
+ let mut tags = minimal_message_file_tags();
+ tags.push(vec!["e".to_string(), " ".to_string()]);
+ let err = message_file_from_tags(
+ KIND_MESSAGE_FILE,
+ &tags,
+ "https://files.example/encrypted.bin",
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("e")));
+
+ let mut tags = minimal_message_file_tags();
+ tags.push(vec!["subject".to_string(), " ".to_string()]);
+ let err = message_file_from_tags(
+ KIND_MESSAGE_FILE,
+ &tags,
+ "https://files.example/encrypted.bin",
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("subject")));
+
+ let mut tags = minimal_message_file_tags();
+ tags[2][1] = " ".to_string();
+ let err = message_file_from_tags(
+ KIND_MESSAGE_FILE,
+ &tags,
+ "https://files.example/encrypted.bin",
+ )
+ .unwrap_err();
+ assert!(matches!(
+ err,
+ EventParseError::InvalidTag("encryption-algorithm")
+ ));
+
+ let mut tags = minimal_message_file_tags();
+ tags[3][1] = " ".to_string();
+ let err = message_file_from_tags(
+ KIND_MESSAGE_FILE,
+ &tags,
+ "https://files.example/encrypted.bin",
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("decryption-key")));
+
+ let mut tags = minimal_message_file_tags();
+ tags[4][1] = " ".to_string();
+ let err = message_file_from_tags(
+ KIND_MESSAGE_FILE,
+ &tags,
+ "https://files.example/encrypted.bin",
+ )
+ .unwrap_err();
+ assert!(matches!(
+ err,
+ EventParseError::InvalidTag("decryption-nonce")
+ ));
+
+ let mut tags = minimal_message_file_tags();
+ tags[5][1] = " ".to_string();
+ let err = message_file_from_tags(
+ KIND_MESSAGE_FILE,
+ &tags,
+ "https://files.example/encrypted.bin",
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("x")));
+
+ let mut tags = minimal_message_file_tags();
+ tags.push(vec!["ox".to_string(), " ".to_string()]);
+ let err = message_file_from_tags(
+ KIND_MESSAGE_FILE,
+ &tags,
+ "https://files.example/encrypted.bin",
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("ox")));
+
+ let mut tags = minimal_message_file_tags();
+ tags.push(vec!["blurhash".to_string(), " ".to_string()]);
+ let err = message_file_from_tags(
+ KIND_MESSAGE_FILE,
+ &tags,
+ "https://files.example/encrypted.bin",
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("blurhash")));
+}
+
+#[test]
+fn message_file_from_tags_rejects_invalid_dimension_components() {
+ let mut tags = minimal_message_file_tags();
+ tags.push(vec!["dim".to_string(), "badx10".to_string()]);
+ let err = message_file_from_tags(
+ KIND_MESSAGE_FILE,
+ &tags,
+ "https://files.example/encrypted.bin",
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("dim")));
+
+ let mut tags = minimal_message_file_tags();
+ tags.push(vec!["dim".to_string(), "10xbad".to_string()]);
+ let err = message_file_from_tags(
+ KIND_MESSAGE_FILE,
+ &tags,
+ "https://files.example/encrypted.bin",
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("dim")));
+}
diff --git a/crates/events-codec/tests/reaction.rs b/crates/events-codec/tests/reaction.rs
@@ -55,6 +55,16 @@ fn reaction_to_wire_parts_requires_content() {
err,
EventEncodeError::EmptyRequiredField("content")
));
+
+ let reaction = RadrootsReaction {
+ root: common::event_ref("", "author", KIND_POST),
+ content: "+".to_string(),
+ };
+ let err = to_wire_parts(&reaction).unwrap_err();
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("root.id")
+ ));
}
#[test]
@@ -80,6 +90,29 @@ fn reaction_from_tags_requires_root_tag() {
}
#[test]
+fn reaction_from_tags_propagates_reference_parse_errors() {
+ let err = reaction_from_tags(
+ KIND_REACTION,
+ &[
+ vec!["e".to_string()],
+ vec!["p".to_string(), "author".to_string()],
+ vec!["k".to_string(), KIND_POST.to_string()],
+ ],
+ "+",
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("e")));
+
+ let err = reaction_from_tags(
+ KIND_REACTION,
+ &[vec![TAG_E_ROOT.to_string(), "root".to_string()]],
+ "+",
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag("e_root")));
+}
+
+#[test]
fn reaction_from_tags_rejects_invalid_kind_and_content() {
let root = common::event_ref("root", "author", KIND_POST);
let mut tags = Vec::new();
diff --git a/crates/events-codec/tests/structured_encode_default.rs b/crates/events-codec/tests/structured_encode_default.rs
@@ -703,6 +703,11 @@ fn structured_list_sets_cover_success_and_error_paths() {
err,
EventEncodeError::EmptyRequiredField("coop_id")
));
+ let err = coop_members_list_set(coop_id, [" "]).unwrap_err();
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("entry.values")
+ ));
let err = coop_members_farms_list_set(
coop_id,
[RadrootsFarmRef {
@@ -715,6 +720,15 @@ fn structured_list_sets_cover_success_and_error_paths() {
err,
EventEncodeError::EmptyRequiredField("farm.pubkey")
));
+ let err = coop_members_farms_list_set(
+ "invalid",
+ [RadrootsFarmRef {
+ pubkey: TEST_PUBKEY_HEX.to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
+ }],
+ )
+ .unwrap_err();
+ assert!(matches!(err, EventEncodeError::InvalidField("coop_id")));
let area_id = "AAAAAAAAAAAAAAAAAAAAAw";
let resource_farms = resource_area_members_farms_list_set(
@@ -745,6 +759,11 @@ fn structured_list_sets_cover_success_and_error_paths() {
err,
EventEncodeError::EmptyRequiredField("area_id")
));
+ let err = resource_area_stewards_list_set(area_id, [" "]).unwrap_err();
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("entry.values")
+ ));
let err = resource_area_members_plots_list_set(
area_id,
[RadrootsPlotRef {
diff --git a/crates/events-codec/tests/tag_builders.rs b/crates/events-codec/tests/tag_builders.rs
@@ -1,6 +1,7 @@
use radroots_core::{
- RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
- RadrootsCoreQuantityPrice, RadrootsCoreUnit,
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountScope,
+ RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreMoney,
+ RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit,
};
use radroots_events::RadrootsNostrEventPtr;
use radroots_events::RadrootsNostrEventRef;
@@ -25,11 +26,13 @@ use radroots_events::kinds::{
use radroots_events::list::{RadrootsList, RadrootsListEntry};
use radroots_events::list_set::RadrootsListSet;
use radroots_events::listing::{
- RadrootsListing, RadrootsListingBin, RadrootsListingFarmRef, RadrootsListingProduct,
+ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, RadrootsListingFarmRef,
+ RadrootsListingImage, RadrootsListingImageSize, RadrootsListingLocation,
+ RadrootsListingProduct, RadrootsListingStatus,
};
use radroots_events::message::{RadrootsMessage, RadrootsMessageRecipient};
use radroots_events::message_file::RadrootsMessageFile;
-use radroots_events::plot::RadrootsPlot;
+use radroots_events::plot::{RadrootsPlot, RadrootsPlotRef};
use radroots_events::post::RadrootsPost;
use radroots_events::profile::RadrootsProfile;
use radroots_events::reaction::RadrootsReaction;
@@ -38,8 +41,10 @@ use radroots_events::resource_area::{
};
use radroots_events::resource_cap::{RadrootsResourceHarvestCap, RadrootsResourceHarvestProduct};
use radroots_events::seal::RadrootsSeal;
+use radroots_events_codec::error::EventEncodeError;
use radroots_events_codec::job::encode::JobEncodeError;
use radroots_events_codec::listing::encode::listing_build_tags;
+use radroots_events_codec::listing::tags::{ListingTagOptions, listing_tags_with_options};
use radroots_events_codec::tag_builders::RadrootsEventTagBuilder;
const TEST_PUBKEY_HEX: &str = "58e318557257f2ab58a415d21bb57082b4824cf667a1d64e72bcbc5acc018c62";
@@ -427,6 +432,245 @@ fn event_tag_builder_impls_build_tags_for_all_supported_types() {
}
#[test]
+fn listing_and_message_builders_cover_optional_shapes() {
+ let mut listing = sample_listing();
+ listing.resource_area = Some(RadrootsResourceAreaRef {
+ pubkey: TEST_PUBKEY_HEX.to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(),
+ });
+ listing.plot = Some(RadrootsPlotRef {
+ pubkey: TEST_PUBKEY_HEX.to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(),
+ });
+ listing.product.summary = Some("summary".to_string());
+ listing.product.process = Some("washed".to_string());
+ listing.product.lot = Some("lot-1".to_string());
+ listing.product.location = Some("Moyobamba".to_string());
+ listing.product.profile = Some("fruity".to_string());
+ listing.product.year = Some("2024".to_string());
+ listing.location = Some(RadrootsListingLocation {
+ primary: "Moyobamba".to_string(),
+ city: Some("Moyobamba".to_string()),
+ region: Some("San Martin".to_string()),
+ country: Some("PE".to_string()),
+ lat: Some(-6.03),
+ lng: Some(-76.97),
+ geohash: None,
+ });
+ listing.images = Some(vec![RadrootsListingImage {
+ url: "https://example.com/a.jpg".to_string(),
+ size: Some(RadrootsListingImageSize { w: 1200, h: 800 }),
+ }]);
+ assert!(!listing_build_tags(&listing).unwrap().is_empty());
+
+ let mut listing_with_trade = listing.clone();
+ listing_with_trade.inventory_available = Some(RadrootsCoreDecimal::from(12u32));
+ let with_trade_fields: fn() -> ListingTagOptions = ListingTagOptions::with_trade_fields;
+ let trade_options = with_trade_fields();
+ listing_with_trade.availability = Some(RadrootsListingAvailability::Status {
+ status: RadrootsListingStatus::Active,
+ });
+ let listing_tags_full_fn: fn(&RadrootsListing) -> Result<Vec<Vec<String>>, EventEncodeError> =
+ radroots_events_codec::listing::tags::listing_tags_full;
+ let full_tags = listing_tags_full_fn(&listing_with_trade).unwrap();
+ assert!(full_tags.iter().any(|tag| {
+ tag.first().map(|v| v.as_str()) == Some("inventory")
+ && tag.get(1).map(|v| v.as_str()) == Some("12")
+ }));
+
+ let trade_tags = listing_tags_with_options(&listing_with_trade, trade_options).unwrap();
+ assert!(trade_tags.iter().any(|tag| {
+ tag.first().map(|v| v.as_str()) == Some("inventory")
+ && tag.get(1).map(|v| v.as_str()) == Some("12")
+ }));
+ assert!(trade_tags.iter().any(|tag| {
+ tag.first().map(|v| v.as_str()) == Some("status")
+ && tag.get(1).map(|v| v.as_str()) == Some("active")
+ }));
+
+ let mut listing_status_sold = listing_with_trade.clone();
+ listing_status_sold.availability = Some(RadrootsListingAvailability::Status {
+ status: RadrootsListingStatus::Sold,
+ });
+ let sold_tags = listing_tags_with_options(&listing_status_sold, trade_options).unwrap();
+ assert!(sold_tags.iter().any(|tag| {
+ tag.first().map(|v| v.as_str()) == Some("status")
+ && tag.get(1).map(|v| v.as_str()) == Some("sold")
+ }));
+
+ let mut listing_status_other = listing_with_trade.clone();
+ listing_status_other.availability = Some(RadrootsListingAvailability::Status {
+ status: RadrootsListingStatus::Other {
+ value: "paused".to_string(),
+ },
+ });
+ let other_tags = listing_tags_with_options(&listing_status_other, trade_options).unwrap();
+ assert!(other_tags.iter().any(|tag| {
+ tag.first().map(|v| v.as_str()) == Some("status")
+ && tag.get(1).map(|v| v.as_str()) == Some("paused")
+ }));
+
+ let mut listing_geohash_only = listing_with_trade.clone();
+ listing_geohash_only.location = Some(RadrootsListingLocation {
+ primary: "Moyobamba".to_string(),
+ city: None,
+ region: None,
+ country: None,
+ lat: None,
+ lng: None,
+ geohash: Some("6gkzwgjzn".to_string()),
+ });
+ let geohash_tags =
+ listing_tags_with_options(&listing_geohash_only, ListingTagOptions::default()).unwrap();
+ assert!(geohash_tags.iter().any(|tag| {
+ tag.first().map(|v| v.as_str()) == Some("g")
+ && tag.get(1).map(|v| v.as_str()) == Some("6gkzwgjzn")
+ }));
+
+ let mut listing_no_coordinates = listing_with_trade.clone();
+ listing_no_coordinates.location = Some(RadrootsListingLocation {
+ primary: "Moyobamba".to_string(),
+ city: None,
+ region: None,
+ country: None,
+ lat: None,
+ lng: None,
+ geohash: None,
+ });
+ let no_coordinates_tags =
+ listing_tags_with_options(&listing_no_coordinates, ListingTagOptions::default()).unwrap();
+ assert!(
+ !no_coordinates_tags
+ .iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("L"))
+ );
+
+ let no_gps_tags = listing_tags_with_options(
+ &listing_with_trade,
+ ListingTagOptions {
+ include_gps: false,
+ ..ListingTagOptions::default()
+ },
+ )
+ .unwrap();
+ assert!(
+ !no_gps_tags
+ .iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("L"))
+ );
+
+ let mut listing_with_empty_primary_location = listing_with_trade.clone();
+ listing_with_empty_primary_location.location = Some(RadrootsListingLocation {
+ primary: " null ".to_string(),
+ city: None,
+ region: None,
+ country: None,
+ lat: Some(-6.03),
+ lng: Some(-76.97),
+ geohash: None,
+ });
+ let no_primary_location_tags =
+ listing_tags_with_options(&listing_with_empty_primary_location, trade_options).unwrap();
+ assert!(
+ !no_primary_location_tags
+ .iter()
+ .any(|tag| tag.first().map(|v| v.as_str()) == Some("location") && tag.len() > 2)
+ );
+
+ let mut listing_with_discount_payload = listing_with_trade.clone();
+ listing_with_discount_payload.discounts = Some(vec![RadrootsCoreDiscount {
+ scope: RadrootsCoreDiscountScope::Bin,
+ threshold: RadrootsCoreDiscountThreshold::BinCount {
+ bin_id: "bin-1".to_string(),
+ min: 2,
+ },
+ value: RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new(
+ RadrootsCoreDecimal::from(1u32),
+ RadrootsCoreCurrency::USD,
+ )),
+ }]);
+ let err = listing_tags_with_options(&listing_with_discount_payload, trade_options)
+ .expect_err("discounts require serde_json in non-serde lane");
+ assert!(matches!(err, EventEncodeError::Json));
+
+ let message_without_relays = RadrootsMessage {
+ recipients: vec![RadrootsMessageRecipient {
+ public_key: TEST_PUBKEY_HEX.to_string(),
+ relay_url: None,
+ }],
+ content: "hello".to_string(),
+ reply_to: Some(RadrootsNostrEventPtr {
+ id: "reply".to_string(),
+ relays: None,
+ }),
+ subject: None,
+ };
+ assert!(!message_without_relays.build_tags().unwrap().is_empty());
+
+ let message_invalid_reply = RadrootsMessage {
+ recipients: vec![RadrootsMessageRecipient {
+ public_key: TEST_PUBKEY_HEX.to_string(),
+ relay_url: None,
+ }],
+ content: "hello".to_string(),
+ reply_to: Some(RadrootsNostrEventPtr {
+ id: " ".to_string(),
+ relays: None,
+ }),
+ subject: None,
+ };
+ let err = message_invalid_reply
+ .build_tags()
+ .expect_err("empty reply id should fail");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("reply_to.id")
+ ));
+}
+
+#[test]
+fn listing_builder_rejects_required_field_errors() {
+ let mut listing = sample_listing();
+ listing.d_tag = " ".to_string();
+ let err = listing_build_tags(&listing).expect_err("empty listing d_tag");
+ assert!(matches!(err, EventEncodeError::EmptyRequiredField("d")));
+
+ let mut listing = sample_listing();
+ listing.d_tag = "invalid".to_string();
+ let err = listing_build_tags(&listing).expect_err("invalid listing d_tag");
+ assert!(matches!(err, EventEncodeError::InvalidField("d")));
+
+ let mut listing = sample_listing();
+ listing.primary_bin_id = " ".to_string();
+ let err = listing_build_tags(&listing).expect_err("empty primary bin id");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("primary_bin_id")
+ ));
+
+ let mut listing = sample_listing();
+ listing.bins.clear();
+ let err = listing_build_tags(&listing).expect_err("empty bins");
+ assert!(matches!(err, EventEncodeError::EmptyRequiredField("bins")));
+
+ let mut listing = sample_listing();
+ listing.farm.pubkey = " ".to_string();
+ let err = listing_build_tags(&listing).expect_err("empty farm pubkey");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("farm.pubkey")
+ ));
+
+ let mut listing = sample_listing();
+ listing.farm.d_tag = " ".to_string();
+ let err = listing_build_tags(&listing).expect_err("empty farm d_tag");
+ assert!(matches!(
+ err,
+ EventEncodeError::EmptyRequiredField("farm.d_tag")
+ ));
+}
+
+#[test]
fn job_request_tag_builder_rejects_encrypted_without_provider() {
let request = RadrootsJobRequest {
kind: (KIND_JOB_REQUEST_MIN + 1) as u16,