lib

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

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:
Mcrates/events-codec/src/coop/list_sets.rs | 135++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/events-codec/src/coop/mod.rs | 32++++++++++++++++++++++++++++++++
Mcrates/events-codec/src/d_tag.rs | 2++
Mcrates/events-codec/src/document/encode.rs | 8++++++++
Mcrates/events-codec/src/farm/list_sets.rs | 77++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/events-codec/src/farm/mod.rs | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events-codec/src/list/decode.rs | 4++--
Mcrates/events-codec/src/list_set/decode.rs | 8++++----
Mcrates/events-codec/src/list_set/mod.rs | 57++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/events-codec/src/listing/tags.rs | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mcrates/events-codec/src/message/tags.rs | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/events-codec/src/plot/mod.rs | 164++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/events-codec/src/resource_area/list_sets.rs | 128++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/events-codec/src/resource_area/mod.rs | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events-codec/src/resource_cap/mod.rs | 38++++++++++++++++++++++++++++++++++++++
Mcrates/events-codec/src/seal/encode.rs | 6+++---
Mcrates/events-codec/tests/app_data.rs | 10++++++++++
Mcrates/events-codec/tests/comment.rs | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events-codec/tests/domain_encode_non_serde.rs | 1283+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events-codec/tests/event_ref.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events-codec/tests/follow.rs | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events-codec/tests/geochat.rs | 40++++++++++++++++++++++++++++++++++++++++
Mcrates/events-codec/tests/gift_wrap.rs | 11+++++++++++
Mcrates/events-codec/tests/job_feedback.rs | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events-codec/tests/job_request.rs | 41+++++++++++++++++++++++++++++++++++++++++
Mcrates/events-codec/tests/job_result.rs | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events-codec/tests/list.rs | 27+++++++++++++++++++++++++++
Mcrates/events-codec/tests/list_set.rs | 13+++++++++++++
Mcrates/events-codec/tests/listing.rs | 77++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/events-codec/tests/message.rs | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events-codec/tests/message_file.rs | 192+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events-codec/tests/reaction.rs | 33+++++++++++++++++++++++++++++++++
Mcrates/events-codec/tests/structured_encode_default.rs | 19+++++++++++++++++++
Mcrates/events-codec/tests/tag_builders.rs | 252+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
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,