lib

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

commit eb709b2020a7929b72ec90a5724da511bdf8eb07
parent 095300a45c594bc8ad1d8bae99f1de5ad5b73cd0
Author: triesap <tyson@radroots.org>
Date:   Sat, 21 Feb 2026 23:25:07 +0000

tests: expand branch-path coverage across `radroots-events-codec` helpers


- add targeted branch cases for event_ref parsing and message optional tag validation
- extend job util and job result tests to cover enum variants, relay markers, and encrypted invariants
- broaden listing tag coverage with feature-aware discount assertions and additional required field paths
- verify with cargo check, cargo test, and sdk coverage progress reporting for `radroots-events-codec`

Diffstat:
Mcrates/events-codec/src/job/result/encode.rs | 11++++-------
Mcrates/events-codec/src/listing/tags.rs | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/events-codec/tests/event_ref.rs | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events-codec/tests/job_result.rs | 44++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events-codec/tests/job_util.rs | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/events-codec/tests/message.rs | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 351 insertions(+), 30 deletions(-)

diff --git a/crates/events-codec/src/job/result/encode.rs b/crates/events-codec/src/job/result/encode.rs @@ -1,8 +1,6 @@ use radroots_events::{job_result::RadrootsJobResult, kinds::is_result_kind}; -use crate::job::encode::{ - JobEncodeError, WireEventParts, assert_no_inputs_when_encrypted, canonicalize_tags, -}; +use crate::job::encode::{JobEncodeError, WireEventParts, canonicalize_tags}; use crate::job::util::{job_input_type_tag, push_amount_tag_msat}; #[cfg(not(feature = "std"))] @@ -71,13 +69,12 @@ pub fn to_wire_parts( if !is_result_kind(kind) { return Err(JobEncodeError::InvalidKind(kind)); } - - let mut tags = job_result_build_tags(res); - - if res.encrypted && !assert_no_inputs_when_encrypted(&tags) { + if res.encrypted && !res.inputs.is_empty() { return Err(JobEncodeError::EmptyRequiredField("inputs-when-encrypted")); } + let mut tags = job_result_build_tags(res); + canonicalize_tags(&mut tags); Ok(WireEventParts { diff --git a/crates/events-codec/src/listing/tags.rs b/crates/events-codec/src/listing/tags.rs @@ -849,10 +849,28 @@ mod tests { ..ListingTagOptions::default() }, ); - assert!(!invalid_tags.iter().any(|tag| { - tag.first().map(|v| v.as_str()) == Some("l") - && tag.get(2).map(|v| v.as_str()) == Some("dd") - })); + assert!(find_tag(&invalid_tags, "l").is_none()); + + let mut geohash_only_tags = Vec::new(); + let geohash_only_location = RadrootsListingLocation { + primary: "Test".to_string(), + city: None, + region: None, + country: None, + lat: None, + lng: None, + geohash: Some("6gkzwgjzn".to_string()), + }; + push_location_geotags( + &mut geohash_only_tags, + &geohash_only_location, + ListingTagOptions { + include_geohash: true, + include_gps: false, + ..ListingTagOptions::default() + }, + ); + assert!(find_tag(&geohash_only_tags, "g").is_some()); } #[test] @@ -901,8 +919,16 @@ mod tests { RadrootsCoreCurrency::USD, )), }; - let err = discount_tag_payload(&discount).expect_err("missing serde_json"); - assert!(matches!(err, EventEncodeError::Json)); + #[cfg(feature = "serde_json")] + { + let payload = discount_tag_payload(&discount).expect("serde_json payload"); + assert!(payload.contains("\"scope\":\"bin\"")); + } + #[cfg(not(feature = "serde_json"))] + { + let err = discount_tag_payload(&discount).expect_err("missing serde_json"); + assert!(matches!(err, EventEncodeError::Json)); + } } #[test] @@ -988,6 +1014,11 @@ mod tests { err, EventEncodeError::EmptyRequiredField("bin_id") )); + let err = tag_listing_price(&bad_bin).expect_err("empty bin_id"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("bin_id") + )); let mut non_canonical = base_bin(); non_canonical.quantity = RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassKg); @@ -1077,9 +1108,18 @@ mod tests { RadrootsCoreCurrency::USD, )), }]); - let err = listing_tags_with_options(&listing_with_discount, ListingTagOptions::default()) - .expect_err("discount serialization requires serde_json"); - assert!(matches!(err, EventEncodeError::Json)); + #[cfg(feature = "serde_json")] + { + let tags = listing_tags_with_options(&listing_with_discount, ListingTagOptions::default()) + .expect("discount serialization works"); + assert!(find_tag(&tags, "radroots:discount").is_some()); + } + #[cfg(not(feature = "serde_json"))] + { + 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(); listing.discounts = None; @@ -1126,6 +1166,34 @@ mod tests { let method_tags = listing_tags_full(&delivery_listing).expect("delivery tags"); assert!(find_tag(&method_tags, "delivery").is_some()); } + + let mut no_availability = base_listing(); + no_availability.discounts = None; + no_availability.availability = None; + let no_availability_tags = listing_tags_with_options( + &no_availability, + ListingTagOptions { + include_availability: true, + ..ListingTagOptions::default() + }, + ) + .expect("availability option without value"); + assert!(find_tag(&no_availability_tags, "status").is_none()); + assert!(find_tag(&no_availability_tags, "published_at").is_none()); + assert!(find_tag(&no_availability_tags, "expires_at").is_none()); + + let mut no_delivery = base_listing(); + no_delivery.discounts = None; + no_delivery.delivery_method = None; + let no_delivery_tags = listing_tags_with_options( + &no_delivery, + ListingTagOptions { + include_delivery: true, + ..ListingTagOptions::default() + }, + ) + .expect("delivery option without value"); + assert!(find_tag(&no_delivery_tags, "delivery").is_none()); } #[test] @@ -1174,6 +1242,39 @@ mod tests { )); listing = base_listing(); + listing.resource_area = Some(RadrootsResourceAreaRef { + pubkey: TEST_PUBKEY_HEX.to_string(), + d_tag: "".to_string(), + }); + let err = listing_tags(&listing).expect_err("missing resource_area d_tag"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("resource_area.d_tag") + )); + + listing = base_listing(); + listing.plot = Some(RadrootsPlotRef { + pubkey: "".to_string(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(), + }); + let err = listing_tags(&listing).expect_err("missing plot pubkey"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("plot.pubkey") + )); + + listing = base_listing(); + listing.plot = Some(RadrootsPlotRef { + pubkey: TEST_PUBKEY_HEX.to_string(), + d_tag: "".to_string(), + }); + let err = listing_tags(&listing).expect_err("missing plot d_tag"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("plot.d_tag") + )); + + listing = base_listing(); listing.plot = Some(RadrootsPlotRef { pubkey: TEST_PUBKEY_HEX.to_string(), d_tag: "plot:invalid".to_string(), @@ -1203,14 +1304,30 @@ mod tests { }]); let tags = listing_tags_full(&listing).expect("cleaning path tags"); assert!(find_tag(&tags, "location").is_none()); - assert!(!tags.iter().any(|tag| { - tag.first().map(|v| v.as_str()) == Some("profile") - && tag.get(1).map(|v| v.as_str()) == Some("null") - })); - assert!(!tags.iter().any(|tag| { - tag.first().map(|v| v.as_str()) == Some("location") - && tag.get(1).map(|v| v.as_str()) == Some("null") - })); + assert!(find_tag(&tags, "profile").is_none()); + assert!(find_tag(&tags, "image").is_none()); + } + + #[test] + fn listing_tags_preserve_location_fields_when_present() { + let mut listing = base_listing(); + listing.discounts = None; + listing.images = None; + 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: None, + lng: None, + geohash: Some("6gkzwgjzn".to_string()), + }); + let tags = listing_tags_full(&listing).expect("location tags"); + let location = find_tag(&tags, "location").expect("location tag"); + assert_eq!(location.get(1).map(|v| v.as_str()), Some("Moyobamba")); + assert_eq!(location.get(2).map(|v| v.as_str()), Some("Moyobamba")); + assert_eq!(location.get(3).map(|v| v.as_str()), Some("San Martin")); + assert_eq!(location.get(4).map(|v| v.as_str()), Some("PE")); assert!(find_tag(&tags, "image").is_none()); } diff --git a/crates/events-codec/tests/event_ref.rs b/crates/events-codec/tests/event_ref.rs @@ -72,6 +72,23 @@ fn parse_event_ref_tag_rejects_invalid_kind() { } #[test] +fn parse_event_ref_tag_rejects_wrong_tag_name_and_missing_fields() { + let tag = vec!["p".to_string(), "id".to_string()]; + let err = parse_event_ref_tag(&tag, "e").unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("e"))); + + let tag = vec![ + "e".to_string(), + "id".to_string(), + "author".to_string(), + KIND_POST.to_string(), + ]; + let parsed = parse_event_ref_tag(&tag, "e").unwrap(); + assert!(parsed.d_tag.is_none()); + assert!(parsed.relays.is_none()); +} + +#[test] fn find_event_ref_tag_locates_first_match() { let event = common::event_ref("id", "author", KIND_POST); let tags = vec![ @@ -181,3 +198,35 @@ fn parse_nip10_ref_tags_prefers_e_relays_and_can_fall_back_to_a_relays() { Some(vec!["wss://relay.e.example.com".to_string()]) ); } + +#[test] +fn parse_nip10_ref_tags_skips_invalid_a_tags_until_match() { + let tags = vec![ + vec!["e".to_string(), "id".to_string()], + vec!["p".to_string(), "author".to_string()], + vec!["k".to_string(), KIND_POST.to_string()], + vec!["a".to_string()], + vec![ + "a".to_string(), + format!("{}:{}:{}", KIND_POST + 1, "author", "AAAAAAAAAAAAAAAAAAAAAA"), + "wss://relay.bad-kind.example.com".to_string(), + ], + vec![ + "a".to_string(), + format!("{}:{}:{}", KIND_POST, "other-author", "AAAAAAAAAAAAAAAAAAAAAA"), + "wss://relay.bad-author.example.com".to_string(), + ], + vec![ + "a".to_string(), + format!("{}:{}:", KIND_POST, "author"), + "wss://relay.empty-d.example.com".to_string(), + ], + ]; + + let parsed = parse_nip10_ref_tags(&tags, "e", "p", "k", "a").unwrap(); + assert!(parsed.d_tag.is_none()); + assert_eq!( + parsed.relays, + Some(vec!["wss://relay.empty-d.example.com".to_string()]) + ); +} diff --git a/crates/events-codec/tests/job_result.rs b/crates/events-codec/tests/job_result.rs @@ -41,6 +41,21 @@ fn job_result_roundtrip_from_tags() { } #[test] +fn job_result_roundtrip_preserves_input_relay_and_marker() { + let mut res = sample_result(); + res.inputs = vec![RadrootsJobInput { + data: "note1payload".to_string(), + input_type: JobInputType::Event, + relay: Some("wss://relay.input.example.com".to_string()), + marker: Some("root".to_string()), + }]; + let content = res.content.clone().unwrap(); + let parts = to_wire_parts(&res, &content).unwrap(); + let decoded = job_result_from_tags(parts.kind, &parts.tags, &content).unwrap(); + assert_eq!(decoded, res); +} + +#[test] fn job_result_requires_valid_kind() { let mut res = sample_result(); res.kind = KIND_JOB_REQUEST_MIN as u16; @@ -53,6 +68,35 @@ fn job_result_requires_valid_kind() { } #[test] +fn job_result_encrypted_adds_flag_and_rejects_inputs() { + let mut encrypted = sample_result(); + encrypted.encrypted = true; + encrypted.inputs.clear(); + let content = encrypted.content.clone().unwrap(); + let parts = to_wire_parts(&encrypted, &content).unwrap(); + assert!( + parts + .tags + .iter() + .any(|tag| tag.first().map(|v| v.as_str()) == Some("encrypted")) + ); + assert!( + !parts + .tags + .iter() + .any(|tag| tag.first().map(|v| v.as_str()) == Some("i")) + ); + + let mut invalid = sample_result(); + invalid.encrypted = true; + let err = to_wire_parts(&invalid, "payload").unwrap_err(); + assert!(matches!( + err, + JobEncodeError::EmptyRequiredField("inputs-when-encrypted") + )); +} + +#[test] 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(); diff --git a/crates/events-codec/tests/job_util.rs b/crates/events-codec/tests/job_util.rs @@ -21,6 +21,16 @@ fn input_type_tag_roundtrip() { } #[test] +fn input_type_tag_covers_all_variants() { + assert_eq!(job_input_type_tag(JobInputType::Event), "event"); + assert_eq!(job_input_type_tag(JobInputType::Job), "job"); + assert_eq!(job_input_type_tag(JobInputType::Text), "text"); + assert_eq!(job_input_type_from_tag("event"), Some(JobInputType::Event)); + assert_eq!(job_input_type_from_tag("job"), Some(JobInputType::Job)); + assert_eq!(job_input_type_from_tag("text"), Some(JobInputType::Text)); +} + +#[test] fn feedback_status_tag_roundtrip() { let t = feedback_status_tag(JobFeedbackStatus::Processing); assert_eq!( @@ -31,12 +41,40 @@ fn feedback_status_tag_roundtrip() { } #[test] +fn feedback_status_tag_covers_all_variants() { + assert_eq!( + feedback_status_tag(JobFeedbackStatus::PaymentRequired), + "payment-required" + ); + assert_eq!(feedback_status_tag(JobFeedbackStatus::Error), "error"); + assert_eq!(feedback_status_tag(JobFeedbackStatus::Success), "success"); + assert_eq!(feedback_status_tag(JobFeedbackStatus::Partial), "partial"); + assert_eq!( + feedback_status_from_tag("payment-required"), + Some(JobFeedbackStatus::PaymentRequired) + ); + assert_eq!(feedback_status_from_tag("error"), Some(JobFeedbackStatus::Error)); + assert_eq!( + feedback_status_from_tag("success"), + Some(JobFeedbackStatus::Success) + ); + assert_eq!( + feedback_status_from_tag("partial"), + Some(JobFeedbackStatus::Partial) + ); +} + +#[test] fn parse_i_tags_handles_multiple_shapes() { let tags = vec![ vec!["i".to_string(), "https://example.com".to_string()], vec!["i".to_string(), "note1abcdef".to_string()], vec![ "i".to_string(), + "0123456789abcdef0123456789abcdef".to_string(), + ], + vec![ + "i".to_string(), "job-id".to_string(), "job".to_string(), "wss://relay".to_string(), @@ -45,7 +83,7 @@ fn parse_i_tags_handles_multiple_shapes() { ]; let inputs = parse_i_tags(&tags); - assert_eq!(inputs.len(), 3); + assert_eq!(inputs.len(), 4); assert_eq!(inputs[0].data, "https://example.com"); assert_eq!(inputs[0].input_type, JobInputType::Url); @@ -55,10 +93,15 @@ fn parse_i_tags_handles_multiple_shapes() { assert_eq!(inputs[1].data, "note1abcdef"); assert_eq!(inputs[1].input_type, JobInputType::Event); - assert_eq!(inputs[2].data, "job-id"); - assert_eq!(inputs[2].input_type, JobInputType::Job); - assert_eq!(inputs[2].relay.as_deref(), Some("wss://relay")); - assert_eq!(inputs[2].marker.as_deref(), Some("marker")); + assert_eq!(inputs[2].data, "0123456789abcdef0123456789abcdef"); + assert_eq!(inputs[2].input_type, JobInputType::Event); + assert!(inputs[2].relay.is_none()); + assert!(inputs[2].marker.is_none()); + + assert_eq!(inputs[3].data, "job-id"); + assert_eq!(inputs[3].input_type, JobInputType::Job); + assert_eq!(inputs[3].relay.as_deref(), Some("wss://relay")); + assert_eq!(inputs[3].marker.as_deref(), Some("marker")); } #[test] @@ -97,10 +140,20 @@ fn parse_i_tags_covers_marker_and_fallback_shapes() { "wss://relay.example.com".to_string(), "final-marker".to_string(), ], + vec!["i".to_string(), "nostr:note1abcdef".to_string()], + vec!["i".to_string(), "nevent1abcdef".to_string()], + vec!["i".to_string(), "naddr1abcdef".to_string()], + vec![ + "i".to_string(), + "text-input".to_string(), + "text".to_string(), + "ws://relay.example.com".to_string(), + "marker-text".to_string(), + ], ]; let inputs = parse_i_tags(&tags); - assert_eq!(inputs.len(), 6); + assert_eq!(inputs.len(), 10); assert_eq!(inputs[0].marker.as_deref(), Some("marker-only")); assert_eq!(inputs[0].data, ""); assert_eq!(inputs[1].marker.as_deref(), Some("marker")); @@ -113,6 +166,12 @@ fn parse_i_tags_covers_marker_and_fallback_shapes() { assert_eq!(inputs[4].relay, None); assert_eq!(inputs[5].relay.as_deref(), Some("wss://relay.example.com")); assert_eq!(inputs[5].marker.as_deref(), Some("final-marker")); + assert_eq!(inputs[6].input_type, JobInputType::Event); + assert_eq!(inputs[7].input_type, JobInputType::Event); + assert_eq!(inputs[8].input_type, JobInputType::Event); + assert_eq!(inputs[9].input_type, JobInputType::Text); + assert_eq!(inputs[9].relay.as_deref(), Some("ws://relay.example.com")); + assert_eq!(inputs[9].marker.as_deref(), Some("marker-text")); } #[test] diff --git a/crates/events-codec/tests/message.rs b/crates/events-codec/tests/message.rs @@ -253,6 +253,24 @@ fn message_build_tags_rejects_invalid_optional_fields() { relay_url: None, }], content: "hello".to_string(), + reply_to: Some(RadrootsNostrEventPtr { + id: "reply".to_string(), + relays: Some(" ".to_string()), + }), + subject: None, + }; + let err = message_build_tags(&message).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("reply_to.relays") + )); + + let message = RadrootsMessage { + recipients: vec![RadrootsMessageRecipient { + public_key: "pub".to_string(), + relay_url: None, + }], + content: "hello".to_string(), reply_to: None, subject: Some(" ".to_string()), }; @@ -291,10 +309,47 @@ fn message_from_tags_rejects_invalid_optional_tags() { KIND_MESSAGE, &[ vec!["p".to_string(), "pub".to_string()], + vec![ + "e".to_string(), + "reply".to_string(), + " ".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!["subject".to_string(), " ".to_string()], ], "hello", ) .unwrap_err(); assert!(matches!(err, EventParseError::InvalidTag("subject"))); + + let err = message_from_tags( + KIND_MESSAGE, + &[ + vec!["p".to_string(), "".to_string()], + vec!["subject".to_string(), "topic".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()], + vec!["subject".to_string()], + ], + "hello", + ) + .unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("subject"))); }