lib

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

commit b16fd1880772aae5b8b6834c52fdaae60d82cac5
parent eb709b2020a7929b72ec90a5724da511bdf8eb07
Author: triesap <tyson@radroots.org>
Date:   Sat, 21 Feb 2026 23:30:44 +0000

tests: extend structured encode and decode edge coverage


- add required-field error assertions across document, farm, coop, plot, resource area, and resource cap encoders
- add list set encode and decode edge cases for duplicate d tags and invalid entry payloads
- add message file, follow, job feedback, and resource area list set helper coverage for optional and invalid paths
- verify with cargo check, cargo test, and sdk coverage progress reporting for `radroots-events-codec`

Diffstat:
Mcrates/events-codec/src/resource_area/list_sets.rs | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events-codec/tests/follow.rs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events-codec/tests/job_feedback.rs | 44++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events-codec/tests/list_set.rs | 46++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events-codec/tests/message_file.rs | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events-codec/tests/structured_encode_default.rs | 257+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 554 insertions(+), 0 deletions(-)

diff --git a/crates/events-codec/src/resource_area/list_sets.rs b/crates/events-codec/src/resource_area/list_sets.rs @@ -157,3 +157,50 @@ where image: None, }) } + +#[cfg(test)] +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"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("list_set_suffix") + )); + } + + #[test] + fn list_entries_rejects_empty_values() { + let err = list_entries("p", [" "]).expect_err("expected empty entry error"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("entry.values") + )); + } + + #[test] + fn farm_and_plot_address_helpers_reject_empty_d_tags() { + let err = farm_address(&RadrootsFarmRef { + pubkey: "farm_pubkey".to_string(), + d_tag: " ".to_string(), + }) + .expect_err("expected farm d_tag error"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("farm.d_tag") + )); + + let err = plot_address(&RadrootsPlotRef { + pubkey: "plot_pubkey".to_string(), + d_tag: " ".to_string(), + }) + .expect_err("expected plot d_tag error"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("plot.d_tag") + )); + } +} diff --git a/crates/events-codec/tests/follow.rs b/crates/events-codec/tests/follow.rs @@ -343,3 +343,66 @@ fn follow_to_wire_parts_with_kind_and_after_mutation_work() { assert_eq!(toggled.kind, KIND_FOLLOW); assert_eq!(toggled.tags.len(), 2); } + +#[test] +fn follow_apply_normalizes_optional_fields_and_deduplicates_existing_list() { + let follow = RadrootsFollow { + list: vec![ + RadrootsFollowProfile { + published_at: 1, + public_key: " pubkey-a ".to_string(), + relay_url: Some(" ".to_string()), + contact_name: Some(" ".to_string()), + }, + RadrootsFollowProfile { + published_at: 2, + public_key: "pubkey-a".to_string(), + relay_url: Some("wss://duplicate.example.com".to_string()), + contact_name: Some("duplicate".to_string()), + }, + ], + }; + + let updated = follow_apply( + &follow, + FollowMutation::Follow { + public_key: "pubkey-a".to_string(), + relay_url: Some(" ".to_string()), + contact_name: Some(" ".to_string()), + }, + ) + .unwrap(); + + assert_eq!(updated.list.len(), 1); + assert_eq!(updated.list[0].public_key, "pubkey-a"); + assert!(updated.list[0].relay_url.is_none()); + assert!(updated.list[0].contact_name.is_none()); +} + +#[test] +fn follow_apply_follow_with_none_preserves_existing_values() { + let follow = RadrootsFollow { + list: vec![RadrootsFollowProfile { + published_at: 1, + public_key: "pubkey-a".to_string(), + relay_url: Some("wss://relay.example.com".to_string()), + contact_name: Some("alice".to_string()), + }], + }; + + let updated = follow_apply( + &follow, + FollowMutation::Follow { + public_key: "pubkey-a".to_string(), + relay_url: None, + contact_name: None, + }, + ) + .unwrap(); + assert_eq!(updated.list.len(), 1); + assert_eq!( + updated.list[0].relay_url.as_deref(), + Some("wss://relay.example.com") + ); + assert_eq!(updated.list[0].contact_name.as_deref(), Some("alice")); +} diff --git a/crates/events-codec/tests/job_feedback.rs b/crates/events-codec/tests/job_feedback.rs @@ -80,3 +80,47 @@ fn job_feedback_metadata_rejects_wrong_kind() { JobParseError::InvalidTag("kind (expected 7000)") )); } + +#[test] +fn job_feedback_build_tags_cover_optional_paths() { + let mut fb = sample_feedback(); + fb.extra_info = None; + fb.payment = None; + fb.request_event.relays = None; + fb.customer_pubkey = None; + fb.encrypted = true; + let parts = to_wire_parts(&fb, "payload").unwrap(); + + let status = parts + .tags + .iter() + .find(|tag| tag.first().map(|v| v.as_str()) == Some("status")) + .expect("status tag"); + assert_eq!(status.len(), 2); + + let request = parts + .tags + .iter() + .find(|tag| tag.first().map(|v| v.as_str()) == Some("e")) + .expect("request tag"); + assert_eq!(request.len(), 2); + + assert!( + !parts + .tags + .iter() + .any(|tag| tag.first().map(|v| v.as_str()) == Some("amount")) + ); + assert!( + !parts + .tags + .iter() + .any(|tag| tag.first().map(|v| v.as_str()) == Some("p")) + ); + assert!( + parts + .tags + .iter() + .any(|tag| tag.first().map(|v| v.as_str()) == Some("encrypted")) + ); +} diff --git a/crates/events-codec/tests/list_set.rs b/crates/events-codec/tests/list_set.rs @@ -81,6 +81,30 @@ fn list_set_encode_and_decode_reject_invalid_inputs() { got: KIND_POST } )); + + let mut invalid_entry_tag = sample_list_set(); + invalid_entry_tag.entries[0].tag = " ".to_string(); + let err = list_set_build_tags(&invalid_entry_tag).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("entry.tag") + )); + + let mut invalid_entry_values = sample_list_set(); + invalid_entry_values.entries[0].values.clear(); + let err = list_set_build_tags(&invalid_entry_values).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("entry.values") + )); + + let mut invalid_entry_first = sample_list_set(); + invalid_entry_first.entries[0].values = vec![" ".to_string()]; + let err = list_set_build_tags(&invalid_entry_first).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("entry.values") + )); } #[test] @@ -103,6 +127,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(), "members.owners".to_string()], + vec!["p".to_string(), " ".to_string()], + ], + ) + .unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("tag"))); } #[test] @@ -157,3 +192,14 @@ fn list_set_decode_keeps_first_optional_display_tags() { assert_eq!(decoded.description.as_deref(), Some("team")); assert_eq!(decoded.image.as_deref(), Some("https://example.com/a.png")); } + +#[test] +fn list_set_decode_keeps_first_d_tag() { + let tags = vec![ + vec!["d".to_string(), "members.owners".to_string()], + vec!["d".to_string(), "members.ignore".to_string()], + vec!["p".to_string(), "owner".to_string()], + ]; + let decoded = list_set_from_tags(KIND_LIST_SET_FOLLOW, "private".to_string(), &tags).unwrap(); + assert_eq!(decoded.d_tag, "members.owners"); +} diff --git a/crates/events-codec/tests/message_file.rs b/crates/events-codec/tests/message_file.rs @@ -207,6 +207,85 @@ fn message_file_from_tags_rejects_invalid_optional_tags() { ) .unwrap_err(); assert!(matches!(err, EventParseError::InvalidTag("fallback"))); + + let err = message_file_from_tags( + KIND_MESSAGE_FILE, + &[ + vec!["p".to_string(), "pub1".to_string()], + vec!["file-type".to_string(), " ".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()], + ], + "https://files.example/encrypted.bin", + ) + .unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("file-type"))); + + let err = message_file_from_tags( + KIND_MESSAGE_FILE, + &[ + 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()], + vec!["size".to_string(), " ".to_string()], + ], + "https://files.example/encrypted.bin", + ) + .unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("size"))); + + let err = message_file_from_tags( + KIND_MESSAGE_FILE, + &[ + 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()], + vec!["dim".to_string(), " ".to_string()], + ], + "https://files.example/encrypted.bin", + ) + .unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("dim"))); + + let err = message_file_from_tags( + KIND_MESSAGE_FILE, + &[ + 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()], + vec!["thumb".to_string(), " ".to_string()], + ], + "https://files.example/encrypted.bin", + ) + .unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("thumb"))); + + let err = message_file_from_tags( + KIND_MESSAGE_FILE, + &[ + 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()], + vec!["fallback".to_string(), " ".to_string()], + ], + "https://files.example/encrypted.bin", + ) + .unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("fallback"))); } #[test] @@ -243,3 +322,21 @@ fn message_file_metadata_and_index_from_event_roundtrip() { assert_eq!(index.event.sig, "sig"); assert_eq!(index.metadata.message_file.file_type, "image/jpeg"); } + +#[test] +fn message_file_from_tags_rejects_empty_content() { + let err = message_file_from_tags( + KIND_MESSAGE_FILE, + &[ + 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()], + ], + " ", + ) + .unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("content"))); +} diff --git a/crates/events-codec/tests/structured_encode_default.rs b/crates/events-codec/tests/structured_encode_default.rs @@ -332,6 +332,263 @@ fn structured_build_tags_cover_optional_and_error_paths() { } #[test] +fn structured_build_tags_cover_required_field_errors() { + let document = RadrootsDocument { + d_tag: "AAAAAAAAAAAAAAAAAAAAAg".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: TEST_PUBKEY_HEX.to_string(), + address: Some( + "30340:58e318557257f2ab58a415d21bb57082b4824cf667a1d64e72bcbc5acc018c62:AAAAAAAAAAAAAAAAAAAAAA" + .to_string(), + ), + }, + tags: None, + }; + let document_tags = document_build_tags(&document).unwrap(); + assert!(document_tags.iter().any(|tag| tag[0] == "a")); + + let mut invalid_document = document.clone(); + invalid_document.d_tag = " ".to_string(); + let err = document_build_tags(&invalid_document).unwrap_err(); + assert!(matches!(err, EventEncodeError::EmptyRequiredField("d_tag"))); + invalid_document = document.clone(); + invalid_document.doc_type = " ".to_string(); + let err = document_build_tags(&invalid_document).unwrap_err(); + assert!(matches!(err, EventEncodeError::EmptyRequiredField("doc_type"))); + invalid_document = document.clone(); + invalid_document.title = " ".to_string(); + let err = document_build_tags(&invalid_document).unwrap_err(); + assert!(matches!(err, EventEncodeError::EmptyRequiredField("title"))); + invalid_document = document.clone(); + invalid_document.version = " ".to_string(); + let err = document_build_tags(&invalid_document).unwrap_err(); + assert!(matches!(err, EventEncodeError::EmptyRequiredField("version"))); + invalid_document = document.clone(); + invalid_document.subject.pubkey = " ".to_string(); + let err = document_build_tags(&invalid_document).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("subject.pubkey") + )); + + let farm = RadrootsFarm { + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), + name: "Farm".to_string(), + about: None, + website: None, + picture: None, + banner: None, + location: None, + tags: None, + }; + let mut invalid_farm = farm.clone(); + invalid_farm.d_tag = " ".to_string(); + let err = farm_build_tags(&invalid_farm).unwrap_err(); + assert!(matches!(err, EventEncodeError::EmptyRequiredField("d_tag"))); + invalid_farm = farm.clone(); + invalid_farm.name = " ".to_string(); + let err = farm_build_tags(&invalid_farm).unwrap_err(); + assert!(matches!(err, EventEncodeError::EmptyRequiredField("name"))); + let err = farm_ref_tags(&RadrootsFarmRef { + pubkey: " ".to_string(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), + }) + .unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("farm.pubkey") + )); + let err = farm_ref_tags(&RadrootsFarmRef { + pubkey: TEST_PUBKEY_HEX.to_string(), + d_tag: " ".to_string(), + }) + .unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("farm.d_tag") + )); + + let coop = RadrootsCoop { + d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(), + name: "Coop".to_string(), + about: None, + website: None, + picture: None, + banner: None, + location: None, + tags: None, + }; + let mut invalid_coop = coop.clone(); + invalid_coop.d_tag = " ".to_string(); + let err = coop_build_tags(&invalid_coop).unwrap_err(); + assert!(matches!(err, EventEncodeError::EmptyRequiredField("d_tag"))); + invalid_coop = coop.clone(); + invalid_coop.name = " ".to_string(); + let err = coop_build_tags(&invalid_coop).unwrap_err(); + assert!(matches!(err, EventEncodeError::EmptyRequiredField("name"))); + invalid_coop = coop.clone(); + invalid_coop.location = Some(RadrootsCoopLocation { + primary: None, + city: None, + region: None, + country: None, + gcs: RadrootsGcsLocation { + geohash: " ".to_string(), + ..sample_gcs() + }, + }); + let err = coop_build_tags(&invalid_coop).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("location.gcs.geohash") + )); + let err = coop_ref_tags(&RadrootsCoopRef { + pubkey: " ".to_string(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(), + }) + .unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("coop.pubkey") + )); + let err = coop_ref_tags(&RadrootsCoopRef { + pubkey: TEST_PUBKEY_HEX.to_string(), + d_tag: " ".to_string(), + }) + .unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("coop.d_tag") + )); + + let plot = RadrootsPlot { + d_tag: "AAAAAAAAAAAAAAAAAAAABQ".to_string(), + farm: RadrootsFarmRef { + pubkey: TEST_PUBKEY_HEX.to_string(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), + }, + name: "Plot".to_string(), + about: None, + location: None, + tags: None, + }; + let mut invalid_plot = plot.clone(); + invalid_plot.d_tag = " ".to_string(); + let err = plot_build_tags(&invalid_plot).unwrap_err(); + assert!(matches!(err, EventEncodeError::EmptyRequiredField("d_tag"))); + invalid_plot = plot.clone(); + invalid_plot.name = " ".to_string(); + let err = plot_build_tags(&invalid_plot).unwrap_err(); + assert!(matches!(err, EventEncodeError::EmptyRequiredField("name"))); + invalid_plot = plot.clone(); + invalid_plot.farm.pubkey = " ".to_string(); + let err = plot_build_tags(&invalid_plot).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("farm.pubkey") + )); + invalid_plot = plot.clone(); + invalid_plot.farm.d_tag = " ".to_string(); + let err = plot_build_tags(&invalid_plot).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("farm.d_tag") + )); + let err = plot_address(TEST_PUBKEY_HEX, " ").unwrap_err(); + assert!(matches!(err, EventEncodeError::EmptyRequiredField("plot.d_tag"))); + + let area = RadrootsResourceArea { + d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(), + name: "Area".to_string(), + about: None, + location: RadrootsResourceAreaLocation { + primary: None, + city: None, + region: None, + country: None, + gcs: sample_gcs(), + }, + tags: None, + }; + let mut invalid_area = area.clone(); + invalid_area.d_tag = " ".to_string(); + let err = resource_area_build_tags(&invalid_area).unwrap_err(); + assert!(matches!(err, EventEncodeError::EmptyRequiredField("d_tag"))); + invalid_area = area.clone(); + invalid_area.name = " ".to_string(); + let err = resource_area_build_tags(&invalid_area).unwrap_err(); + assert!(matches!(err, EventEncodeError::EmptyRequiredField("name"))); + let err = resource_area_ref_tags(&RadrootsResourceAreaRef { + pubkey: " ".to_string(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(), + }) + .unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("resource_area.pubkey") + )); + let err = resource_area_ref_tags(&RadrootsResourceAreaRef { + pubkey: TEST_PUBKEY_HEX.to_string(), + d_tag: " ".to_string(), + }) + .unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("resource_area.d_tag") + )); + + let cap = RadrootsResourceHarvestCap { + d_tag: "AAAAAAAAAAAAAAAAAAAABA".to_string(), + resource_area: RadrootsResourceAreaRef { + pubkey: TEST_PUBKEY_HEX.to_string(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(), + }, + product: RadrootsResourceHarvestProduct { + key: "nutmeg".to_string(), + category: Some("spice".to_string()), + }, + start: 1, + end: 2, + cap_quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1000u32), + RadrootsCoreUnit::MassG, + ), + display_amount: None, + display_unit: None, + display_label: None, + tags: None, + }; + let mut invalid_cap = cap.clone(); + invalid_cap.d_tag = " ".to_string(); + let err = resource_harvest_cap_build_tags(&invalid_cap).unwrap_err(); + assert!(matches!(err, EventEncodeError::EmptyRequiredField("d_tag"))); + invalid_cap = cap.clone(); + invalid_cap.resource_area.pubkey = " ".to_string(); + let err = resource_harvest_cap_build_tags(&invalid_cap).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("resource_area.pubkey") + )); + invalid_cap = cap.clone(); + invalid_cap.resource_area.d_tag = " ".to_string(); + let err = resource_harvest_cap_build_tags(&invalid_cap).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("resource_area.d_tag") + )); + let mut no_category = cap.clone(); + no_category.product.category = Some(" ".to_string()); + let tags = resource_harvest_cap_build_tags(&no_category).unwrap(); + assert!(!tags.iter().any(|tag| tag[0] == "category")); +} + +#[test] fn structured_list_sets_cover_success_and_error_paths() { let farm_id = "AAAAAAAAAAAAAAAAAAAAAA"; let members = farm_members_list_set(farm_id, [TEST_PUBKEY_HEX]).unwrap();