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