lib

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

commit 9cd55df7d2f96be5e29199b594fee5c484339ce9
parent 8b9c6547686f77d88e62dce121c0c89af8d9b6b3
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Feb 2026 00:23:53 +0000

tests: close `radroots-events` codec strict coverage gaps



- add edge-path tests across codec decode and encode suites to reach strict function coverage
- extend comment and reaction tag coverage for d-tag address branches
- remove dead follow and list_set decode branches and tighten helper assertions
- compute branch coverage from brda records with brf/brh fallback for deterministic gating

Diffstat:
Mcrates/events-codec/src/d_tag.rs | 19+++++++++++++++++++
Mcrates/events-codec/src/document/encode.rs | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events-codec/src/event_ref.rs | 5+----
Mcrates/events-codec/src/farm/list_sets.rs | 21+++++++++++++++++++++
Mcrates/events-codec/src/follow/decode.rs | 3---
Mcrates/events-codec/src/list_set/decode.rs | 3---
Mcrates/events-codec/src/listing/tags.rs | 93++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mcrates/events-codec/src/message/tags.rs | 38++++++++++++++++++++++++++++++++++++++
Mcrates/events-codec/src/resource_cap/encode.rs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events-codec/tests/app_data.rs | 37+++++++++++++++++++++++++++++++++++--
Mcrates/events-codec/tests/codec_error_job.rs | 24++++++++++++++----------
Mcrates/events-codec/tests/comment.rs | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events-codec/tests/event_ref.rs | 25+++++++++++++++++++++++--
Mcrates/events-codec/tests/follow.rs | 42++++++++++++++++++++++++++++++++++++++++--
Mcrates/events-codec/tests/geochat.rs | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events-codec/tests/gift_wrap.rs | 5+++++
Mcrates/events-codec/tests/job_feedback.rs | 37++++++++++++++++++++++++++++++++++++-
Mcrates/events-codec/tests/job_request.rs | 47++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/events-codec/tests/job_result.rs | 41++++++++++++++++++++++++++++++++++++++++-
Mcrates/events-codec/tests/job_traits.rs | 2+-
Mcrates/events-codec/tests/job_util.rs | 14+++++++++++++-
Mcrates/events-codec/tests/list.rs | 21+++++++++++++++++++++
Mcrates/events-codec/tests/list_set.rs | 21+++++++++++++++++++++
Mcrates/events-codec/tests/message.rs | 45+++++++++++++++++++++++++++++++++++++++------
Mcrates/events-codec/tests/message_file.rs | 21+++++++++++++++++++++
Mcrates/events-codec/tests/post.rs | 21+++++++++++++++++++++
Mcrates/events-codec/tests/reaction.rs | 17+++++++++++++++++
Mcrates/events-codec/tests/seal.rs | 25+++++++++++++++++++++++++
Mcrates/events-codec/tests/structured_encode_default.rs | 71+++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mcrates/events-codec/tests/tag_builders.rs | 29+++++++++++++++++++++++++++--
Mcrates/xtask/src/coverage.rs | 93++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
31 files changed, 933 insertions(+), 103 deletions(-)

diff --git a/crates/events-codec/src/d_tag.rs b/crates/events-codec/src/d_tag.rs @@ -38,3 +38,22 @@ pub(crate) fn validate_d_tag_tag(value: &str, tag: &'static str) -> Result<(), E Err(EventParseError::InvalidTag(tag)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn d_tag_base64url_validation_covers_allowed_and_rejected_shapes() { + assert!(is_d_tag_base64url("AAAAAAAAAAAAAAAAAAAAAA")); + assert!(!is_d_tag_base64url("AAAAAAAAAAAAAAAAAAAAA!")); + assert!(!is_d_tag_base64url("AAAAAAAAAAAAAAAAAAAAAB")); + assert!(!is_d_tag_base64url("short")); + } + + #[test] + fn validate_d_tag_returns_error_for_invalid_values() { + let err = validate_d_tag("AAAAAAAAAAAAAAAAAAAAA!", "d_tag").expect_err("invalid d_tag"); + assert!(matches!(err, EventEncodeError::InvalidField("d_tag"))); + } +} diff --git a/crates/events-codec/src/document/encode.rs b/crates/events-codec/src/document/encode.rs @@ -86,3 +86,51 @@ pub fn to_wire_parts_with_kind( tags, }) } + +#[cfg(test)] +mod tests { + use super::*; + use radroots_events::document::RadrootsDocumentSubject; + + fn sample_document() -> RadrootsDocument { + 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: "58e318557257f2ab58a415d21bb57082b4824cf667a1d64e72bcbc5acc018c62" + .to_string(), + address: Some( + "30340:58e318557257f2ab58a415d21bb57082b4824cf667a1d64e72bcbc5acc018c62:AAAAAAAAAAAAAAAAAAAAAA" + .to_string(), + ), + }, + tags: None, + } + } + + #[test] + fn document_build_tags_includes_subject_address_when_present() { + let tags = document_build_tags(&sample_document()).expect("document tags"); + assert!( + tags.iter() + .any(|tag| tag.first().map(|v| v.as_str()) == Some("a")) + ); + } + + #[test] + fn document_build_tags_omits_subject_address_when_absent() { + let mut document = sample_document(); + document.subject.address = None; + let tags = document_build_tags(&document).expect("document tags"); + assert!( + !tags + .iter() + .any(|tag| tag.first().map(|v| v.as_str()) == Some("a")) + ); + } +} diff --git a/crates/events-codec/src/event_ref.rs b/crates/events-codec/src/event_ref.rs @@ -175,10 +175,7 @@ pub fn parse_nip10_ref_tags( break; } - let relays = match relays { - Some(v) if !v.is_empty() => Some(v), - _ => addr_relays, - }; + let relays = relays.or(addr_relays); Ok(RadrootsNostrEventRef { id: id.clone(), diff --git a/crates/events-codec/src/farm/list_sets.rs b/crates/events-codec/src/farm/list_sets.rs @@ -214,3 +214,24 @@ where image: None, }) } + +#[cfg(test)] +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"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("list_set_suffix") + )); + + let err = farm_list_set_id(" ", "members").expect_err("expected farm_id error"); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("farm_id") + )); + } +} diff --git a/crates/events-codec/src/follow/decode.rs b/crates/events-codec/src/follow/decode.rs @@ -22,9 +22,6 @@ fn parse_follow_tag( tag: &[String], published_at: u32, ) -> Result<RadrootsFollowProfile, 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"))?; let (relay_url, contact_name) = match tag.get(2).filter(|s| !s.is_empty()) { Some(value) if looks_like_ws_relay(value) => ( diff --git a/crates/events-codec/src/list_set/decode.rs b/crates/events-codec/src/list_set/decode.rs @@ -19,9 +19,6 @@ const TAG_IMAGE: &str = "image"; fn entry_from_tag(tag: &[String]) -> Result<RadrootsListEntry, EventParseError> { let name = tag.get(0).ok_or(EventParseError::InvalidTag("tag"))?; - if name.trim().is_empty() { - return Err(EventParseError::InvalidTag("tag")); - } let value = tag.get(1).ok_or(EventParseError::InvalidTag("tag"))?; if value.trim().is_empty() { return Err(EventParseError::InvalidTag("tag")); diff --git a/crates/events-codec/src/listing/tags.rs b/crates/events-codec/src/listing/tags.rs @@ -486,11 +486,7 @@ fn calculate_resolution(value: f64, max: u32) -> u32 { 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 - } + if bounded == 0 { 1 } else { bounded } } fn truncate_to_resolution(value: f64, resolution: u32) -> f64 { @@ -803,9 +799,10 @@ mod tests { geohash: None, }; push_location_geotags(&mut tags, &location, ListingTagOptions::default()); - 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("g")) + ); assert!(tags.iter().any(|tag| { tag.first().map(|v| v.as_str()) == Some("L") && tag.get(1).map(|v| v.as_str()) == Some("dd.lat") @@ -834,10 +831,13 @@ mod tests { ..ListingTagOptions::default() }, ); - 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 decoded_l_scopes: Vec<&str> = decoded_tags + .iter() + .filter(|tag| tag.first().map(|v| v.as_str()) == Some("l")) + .filter_map(|tag| tag.get(2).map(|v| v.as_str())) + .collect(); + assert!(decoded_l_scopes.contains(&"dd")); + assert!(decoded_l_scopes.contains(&"dd.lat")); let mut invalid_tags = Vec::new(); let invalid_geohash = RadrootsListingLocation { @@ -892,9 +892,11 @@ mod tests { }, ); assert!(find_tag(&invalid_with_geohash_enabled, "g").is_some()); - assert!(!invalid_with_geohash_enabled - .iter() - .any(|tag| tag.first().map(|v| v.as_str()) == Some("l"))); + assert!( + !invalid_with_geohash_enabled + .iter() + .any(|tag| tag.first().map(|v| v.as_str()) == Some("l")) + ); let mut no_coordinate_tags = Vec::new(); let no_coordinate_location = RadrootsListingLocation { @@ -916,6 +918,32 @@ mod tests { }, ); assert!(find_tag(&no_coordinate_tags, "l").is_none()); + + let mut partial_coordinate_tags = Vec::new(); + let partial_coordinate_location = RadrootsListingLocation { + primary: "Test".to_string(), + city: None, + region: None, + country: None, + lat: Some(-6.03), + lng: None, + geohash: Some("6gkzwgjzn".to_string()), + }; + push_location_geotags( + &mut partial_coordinate_tags, + &partial_coordinate_location, + ListingTagOptions { + include_geohash: false, + include_gps: true, + ..ListingTagOptions::default() + }, + ); + let partial_l_scopes: Vec<&str> = partial_coordinate_tags + .iter() + .filter(|tag| tag.first().map(|v| v.as_str()) == Some("l")) + .filter_map(|tag| tag.get(2).map(|v| v.as_str())) + .collect(); + assert!(partial_l_scopes.contains(&"dd")); } #[test] @@ -935,11 +963,13 @@ mod tests { .expect("image tag"); assert_eq!(without_size.len(), 2); - assert!(tag_listing_image(&RadrootsListingImage { - url: "null".to_string(), - size: None, - }) - .is_none()); + assert!( + tag_listing_image(&RadrootsListingImage { + url: "null".to_string(), + size: None, + }) + .is_none() + ); assert_eq!(status_as_str(&RadrootsListingStatus::Active), "active"); assert_eq!(status_as_str(&RadrootsListingStatus::Sold), "sold"); @@ -1286,6 +1316,29 @@ mod tests { assert!(find_tag(&no_geo_tags, "location").is_some()); assert!(find_tag(&no_geo_tags, "g").is_none()); assert!(find_tag(&no_geo_tags, "l").is_none()); + + let geohash_only_tags = listing_tags_with_options( + &no_geo, + ListingTagOptions { + include_geohash: true, + include_gps: false, + ..ListingTagOptions::default() + }, + ) + .expect("location with geohash only"); + assert!(find_tag(&geohash_only_tags, "g").is_some()); + assert!(find_tag(&geohash_only_tags, "l").is_none()); + + let gps_only_tags = listing_tags_with_options( + &no_geo, + ListingTagOptions { + include_geohash: false, + include_gps: true, + ..ListingTagOptions::default() + }, + ) + .expect("location with gps only"); + assert!(find_tag(&gps_only_tags, "l").is_some()); } #[test] diff --git a/crates/events-codec/src/message/tags.rs b/crates/events-codec/src/message/tags.rs @@ -158,6 +158,7 @@ pub(crate) fn parse_subject_tag(tags: &[Vec<String>]) -> Result<Option<String>, #[cfg(test)] mod tests { use super::*; + use radroots_events::RadrootsNostrEventPtr; #[test] fn parse_recipient_tag_rejects_non_p_tag() { @@ -165,4 +166,41 @@ mod tests { .expect_err("expected invalid tag"); assert!(matches!(err, EventParseError::InvalidTag("p"))); } + + #[test] + fn build_and_parse_reply_tags_cover_optional_relay_paths() { + let tag = build_reply_tag(&Some(RadrootsNostrEventPtr { + id: "reply".to_string(), + relays: Some("wss://relay.example.com".to_string()), + })) + .expect("build reply tag") + .expect("reply tag"); + assert_eq!(tag.len(), 3); + let parsed = parse_reply_tag(&[tag]).expect("parse reply"); + assert_eq!( + parsed.and_then(|value| value.relays), + Some("wss://relay.example.com".to_string()) + ); + + let tag = build_reply_tag(&Some(RadrootsNostrEventPtr { + id: "reply".to_string(), + relays: None, + })) + .expect("build reply tag") + .expect("reply tag"); + assert_eq!(tag.len(), 2); + } + + #[test] + fn parse_reply_tag_handles_absent_relay() { + let parsed = parse_reply_tag(&[vec!["e".to_string(), "reply".to_string()]]) + .expect("parse reply tag without relay"); + assert_eq!( + parsed, + Some(RadrootsNostrEventPtr { + id: "reply".to_string(), + relays: None, + }) + ); + } } diff --git a/crates/events-codec/src/resource_cap/encode.rs b/crates/events-codec/src/resource_cap/encode.rs @@ -101,3 +101,62 @@ pub fn to_wire_parts_with_kind( tags, }) } + +#[cfg(test)] +mod tests { + use super::*; + use radroots_core::{RadrootsCoreDecimal, RadrootsCoreQuantity, RadrootsCoreUnit}; + use radroots_events::resource_area::RadrootsResourceAreaRef; + use radroots_events::resource_cap::RadrootsResourceHarvestProduct; + + fn sample_cap_with_category(category: Option<&str>) -> RadrootsResourceHarvestCap { + RadrootsResourceHarvestCap { + d_tag: "AAAAAAAAAAAAAAAAAAAABA".to_string(), + resource_area: RadrootsResourceAreaRef { + pubkey: "58e318557257f2ab58a415d21bb57082b4824cf667a1d64e72bcbc5acc018c62" + .to_string(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(), + }, + product: RadrootsResourceHarvestProduct { + key: "nutmeg".to_string(), + category: category.map(|value| value.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, + } + } + + #[test] + fn resource_harvest_cap_build_tags_omits_blank_category() { + let tags = resource_harvest_cap_build_tags(&sample_cap_with_category(Some(" "))) + .expect("resource harvest cap tags"); + assert!( + !tags + .iter() + .any(|tag| tag.first().map(|v| v.as_str()) == Some("category")) + ); + + let tags = resource_harvest_cap_build_tags(&sample_cap_with_category(None)) + .expect("resource harvest cap tags"); + assert!( + !tags + .iter() + .any(|tag| tag.first().map(|v| v.as_str()) == Some("category")) + ); + + let tags = resource_harvest_cap_build_tags(&sample_cap_with_category(Some("spice"))) + .expect("resource harvest cap tags"); + assert!( + tags.iter() + .any(|tag| tag.first().map(|v| v.as_str()) == Some("category")) + ); + } +} diff --git a/crates/events-codec/tests/app_data.rs b/crates/events-codec/tests/app_data.rs @@ -1,11 +1,13 @@ use radroots_events::{ - app_data::{RadrootsAppData, KIND_APP_DATA}, + app_data::{KIND_APP_DATA, RadrootsAppData}, kinds::KIND_POST, }; use radroots_events_codec::app_data::decode::{ app_data_from_tags, index_from_event, metadata_from_event, }; -use radroots_events_codec::app_data::encode::{app_data_build_tags, to_wire_parts}; +use radroots_events_codec::app_data::encode::{ + app_data_build_tags, to_wire_parts, to_wire_parts_with_kind, +}; use radroots_events_codec::error::{EventEncodeError, EventParseError}; #[test] @@ -36,6 +38,16 @@ fn app_data_to_wire_parts_sets_kind_tags_content() { } #[test] +fn app_data_to_wire_parts_with_kind_rejects_wrong_kind() { + let app_data = RadrootsAppData { + d_tag: "radroots.app".to_string(), + content: "payload".to_string(), + }; + let err = to_wire_parts_with_kind(&app_data, KIND_POST).unwrap_err(); + assert!(matches!(err, EventEncodeError::InvalidKind(KIND_POST))); +} + +#[test] fn app_data_from_tags_requires_kind() { let tags = vec![vec!["d".to_string(), "radroots.app".to_string()]]; let err = app_data_from_tags(KIND_POST, &tags, "payload").unwrap_err(); @@ -114,3 +126,24 @@ fn app_data_metadata_and_index_from_event_roundtrip() { assert_eq!(index.event.sig, "sig"); assert_eq!(index.metadata.app_data.d_tag, "radroots.app"); } + +#[test] +fn app_data_index_from_event_propagates_parse_errors() { + let err = index_from_event( + "id".to_string(), + "author".to_string(), + 42, + KIND_POST, + "payload".to_string(), + Vec::new(), + "sig".to_string(), + ) + .unwrap_err(); + assert!(matches!( + err, + EventParseError::InvalidKind { + expected: "30078", + got: KIND_POST + } + )); +} diff --git a/crates/events-codec/tests/codec_error_job.rs b/crates/events-codec/tests/codec_error_job.rs @@ -2,15 +2,15 @@ use std::error::Error as _; use radroots_events_codec::error::{EventEncodeError, EventParseError}; use radroots_events_codec::job::encode::{ - assert_no_inputs_when_encrypted, push_provider_tag, push_relay_tag, push_status_tag, - JobEncodeError, + JobEncodeError, assert_no_inputs_when_encrypted, push_provider_tag, push_relay_tag, + push_status_tag, }; use radroots_events_codec::job::error::JobParseError; use radroots_events_codec::profile::error::ProfileEncodeError; #[cfg(feature = "serde_json")] -use serde::ser::{Error as _, Serializer}; -#[cfg(feature = "serde_json")] use serde::Serialize; +#[cfg(feature = "serde_json")] +use serde::ser::{Error as _, Serializer}; #[test] fn parse_error_display_and_source_cover_variants() { @@ -31,9 +31,11 @@ fn parse_error_display_and_source_cover_variants() { let parse_int = "x".parse::<u32>().expect_err("parse int error"); let invalid_number = EventParseError::InvalidNumber("count", parse_int); - assert!(invalid_number - .to_string() - .contains("invalid number in 'count'")); + assert!( + invalid_number + .to_string() + .contains("invalid number in 'count'") + ); assert!(invalid_number.source().is_some()); let invalid_json = EventParseError::InvalidJson("content"); @@ -147,9 +149,11 @@ fn job_parse_error_display_and_source_covers_variants() { assert!(invalid.source().is_none()); let invalid_number = JobParseError::InvalidNumber("amount", "x".parse::<u32>().unwrap_err()); - assert!(invalid_number - .to_string() - .contains("invalid number in 'amount'")); + assert!( + invalid_number + .to_string() + .contains("invalid number in 'amount'") + ); assert!(invalid_number.source().is_some()); let non_whole = JobParseError::NonWholeSats("amount"); diff --git a/crates/events-codec/tests/comment.rs b/crates/events-codec/tests/comment.rs @@ -89,6 +89,31 @@ fn comment_to_wire_parts_sets_kind_content_and_tags() { } #[test] +fn comment_build_tags_includes_address_tags_when_refs_have_d_tag() { + let comment = RadrootsComment { + root: common::event_ref_with_d( + "root", + "author", + KIND_POST, + "root-d", + Some(vec!["wss://relay".to_string()]), + ), + parent: common::event_ref_with_d( + "parent", + "author", + KIND_POST, + "parent-d", + Some(vec!["wss://relay-2".to_string()]), + ), + content: "hello".to_string(), + }; + let tags = comment_build_tags(&comment).unwrap(); + assert_eq!(tags.len(), 8); + assert!(tags.iter().any(|tag| tag[0] == "A")); + assert!(tags.iter().any(|tag| tag[0] == "a")); +} + +#[test] fn comment_roundtrip_from_tags_with_parent() { let root = common::event_ref_with_d( "root", @@ -153,6 +178,16 @@ fn comment_from_tags_requires_root_tag() { } #[test] +fn comment_from_tags_rejects_empty_content() { + let root = common::event_ref("root", "author", KIND_POST); + let mut tags = Vec::new(); + push_nip10_ref_tags(&mut tags, &root, "E", "P", "K", "A"); + + let err = comment_from_tags(KIND_COMMENT, &tags, " ").unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("content"))); +} + +#[test] fn comment_from_tags_rejects_wrong_kind() { let tags = vec![vec!["e".to_string(), "x".to_string()]]; let err = comment_from_tags(KIND_POST, &tags, "hello").unwrap_err(); @@ -217,3 +252,24 @@ fn comment_metadata_and_index_from_event_roundtrip() { assert_eq!(index.event.sig, "sig"); assert_eq!(index.metadata.comment.content, "hello"); } + +#[test] +fn comment_index_from_event_propagates_parse_errors() { + let err = index_from_event( + "id".to_string(), + "author".to_string(), + 77, + KIND_POST, + "hello".to_string(), + Vec::new(), + "sig".to_string(), + ) + .unwrap_err(); + assert!(matches!( + err, + EventParseError::InvalidKind { + expected: "1111", + got: KIND_POST + } + )); +} diff --git a/crates/events-codec/tests/event_ref.rs b/crates/events-codec/tests/event_ref.rs @@ -138,6 +138,19 @@ fn push_and_parse_nip10_ref_tags_roundtrip_with_and_without_a_tag() { assert_eq!(parsed.author, event.author); assert_eq!(parsed.kind, event.kind); assert!(parsed.d_tag.is_none()); + + let event = + common::event_ref_with_d("id3", "author3", KIND_POST, "AAAAAAAAAAAAAAAAAAAAAA", None); + let mut tags = Vec::new(); + push_nip10_ref_tags(&mut tags, &event, "e", "p", "k", "a"); + let a_tag = tags + .iter() + .find(|tag| tag.first().map(|v| v.as_str()) == Some("a")) + .expect("a tag"); + assert_eq!(a_tag.len(), 2); + let parsed = parse_nip10_ref_tags(&tags, "e", "p", "k", "a").unwrap(); + assert_eq!(parsed.d_tag, event.d_tag); + assert!(parsed.relays.is_none()); } #[test] @@ -219,12 +232,20 @@ fn parse_nip10_ref_tags_skips_invalid_a_tags_until_match() { vec!["a".to_string()], vec![ "a".to_string(), - format!("{}:{}:{}", KIND_POST + 1, "author", "AAAAAAAAAAAAAAAAAAAAAA"), + format!( + "{}:{}:{}", + KIND_POST + 1, + "author", + "AAAAAAAAAAAAAAAAAAAAAA" + ), "wss://relay.bad-kind.example.com".to_string(), ], vec![ "a".to_string(), - format!("{}:{}:{}", KIND_POST, "other-author", "AAAAAAAAAAAAAAAAAAAAAA"), + format!( + "{}:{}:{}", + KIND_POST, "other-author", "AAAAAAAAAAAAAAAAAAAAAA" + ), "wss://relay.bad-author.example.com".to_string(), ], vec![ diff --git a/crates/events-codec/tests/follow.rs b/crates/events-codec/tests/follow.rs @@ -8,8 +8,8 @@ use radroots_events_codec::follow::decode::{ follow_from_tags, index_from_event, metadata_from_event, }; use radroots_events_codec::follow::encode::{ - follow_apply, follow_to_wire_parts_after, to_wire_parts, to_wire_parts_with_kind, - FollowMutation, + FollowMutation, follow_apply, follow_to_wire_parts_after, to_wire_parts, + to_wire_parts_with_kind, }; #[test] @@ -81,6 +81,23 @@ fn follow_from_tags_accepts_contact_without_relay() { } #[test] +fn follow_from_tags_accepts_ws_relay_and_contact_name() { + let tags = vec![vec![ + "p".to_string(), + "pubkey".to_string(), + "ws://relay.example.com".to_string(), + "alice".to_string(), + ]]; + + let follow = follow_from_tags(KIND_FOLLOW, &tags, 123).unwrap(); + assert_eq!( + follow.list[0].relay_url.as_deref(), + Some("ws://relay.example.com") + ); + assert_eq!(follow.list[0].contact_name.as_deref(), Some("alice")); +} + +#[test] fn follow_from_tags_uses_tag_published_at() { let tags = vec![vec![ "p".to_string(), @@ -170,6 +187,27 @@ fn follow_metadata_and_index_from_event_roundtrip() { } #[test] +fn follow_index_from_event_propagates_parse_errors() { + let err = index_from_event( + "id".to_string(), + "author".to_string(), + 50, + KIND_POST, + "".to_string(), + Vec::new(), + "sig".to_string(), + ) + .unwrap_err(); + assert!(matches!( + err, + EventParseError::InvalidKind { + expected: "3", + got: KIND_POST + } + )); +} + +#[test] fn follow_apply_adds_and_updates_entries() { let follow = RadrootsFollow { list: vec![ diff --git a/crates/events-codec/tests/geochat.rs b/crates/events-codec/tests/geochat.rs @@ -41,6 +41,19 @@ fn geochat_build_tags_requires_nickname_if_present() { } #[test] +fn geochat_build_tags_omits_optional_nickname_and_teleport_when_disabled() { + let geochat = RadrootsGeoChat { + geohash: "dr5rsj7".to_string(), + content: "hello".to_string(), + nickname: None, + teleported: false, + }; + + let tags = geochat_build_tags(&geochat).unwrap(); + assert_eq!(tags, vec![vec!["g".to_string(), "dr5rsj7".to_string()]]); +} + +#[test] fn geochat_to_wire_parts_requires_content() { let geochat = RadrootsGeoChat { geohash: "dr5rsj7".to_string(), @@ -117,6 +130,14 @@ fn geochat_roundtrip_from_tags() { fn geochat_from_tags_rejects_invalid_optional_tags() { let err = geochat_from_tags( KIND_GEOCHAT, + &[vec!["g".to_string(), " ".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(), " ".to_string()], @@ -136,6 +157,17 @@ fn geochat_from_tags_rejects_invalid_optional_tags() { ) .unwrap_err(); assert!(matches!(err, EventParseError::InvalidTag("t"))); + + let geochat = geochat_from_tags( + KIND_GEOCHAT, + &[ + vec!["g".to_string(), "dr5rsj7".to_string()], + vec!["t".to_string(), "moving".to_string()], + ], + "hello", + ) + .unwrap(); + assert!(!geochat.teleported); } #[test] @@ -175,3 +207,24 @@ fn geochat_metadata_and_index_from_event_roundtrip() { assert_eq!(index.event.sig, "sig"); assert_eq!(index.metadata.geochat.geohash, "dr5rsj7"); } + +#[test] +fn geochat_index_from_event_propagates_parse_errors() { + let err = index_from_event( + "id".to_string(), + "author".to_string(), + 77, + KIND_POST, + "hello".to_string(), + vec![vec!["g".to_string(), "dr5rsj7".to_string()]], + "sig".to_string(), + ) + .unwrap_err(); + assert!(matches!( + err, + EventParseError::InvalidKind { + expected: "20000", + got: KIND_POST + } + )); +} diff --git a/crates/events-codec/tests/gift_wrap.rs b/crates/events-codec/tests/gift_wrap.rs @@ -181,6 +181,11 @@ fn gift_wrap_build_tags_handles_optional_expiration_and_invalid_relay() { err, EventEncodeError::EmptyRequiredField("recipient.relay_url") )); + + let mut gift_wrap = sample_gift_wrap(); + gift_wrap.recipient.relay_url = None; + let tags = gift_wrap_build_tags(&gift_wrap).unwrap(); + assert_eq!(tags[0], vec!["p".to_string(), "pubkey".to_string()]); } #[test] diff --git a/crates/events-codec/tests/job_feedback.rs b/crates/events-codec/tests/job_feedback.rs @@ -5,7 +5,7 @@ use radroots_events::job_feedback::RadrootsJobFeedback; use radroots_events::kinds::{KIND_JOB_FEEDBACK, KIND_JOB_REQUEST_MIN, KIND_JOB_RESULT_MIN}; use radroots_events_codec::job::encode::JobEncodeError; use radroots_events_codec::job::error::JobParseError; -use radroots_events_codec::job::feedback::decode::job_feedback_from_tags; +use radroots_events_codec::job::feedback::decode::{index_from_event, job_feedback_from_tags}; use radroots_events_codec::job::feedback::encode::to_wire_parts; fn sample_feedback() -> RadrootsJobFeedback { @@ -35,6 +35,22 @@ fn job_feedback_roundtrip_from_tags() { } #[test] +fn job_feedback_from_tags_accepts_e_ref_and_empty_content() { + let tags = vec![ + vec![ + "e_ref".to_string(), + "req".to_string(), + "wss://relay".to_string(), + ], + vec!["status".to_string(), "processing".to_string()], + ]; + let decoded = job_feedback_from_tags(KIND_JOB_FEEDBACK, &tags, "").unwrap(); + assert_eq!(decoded.request_event.id, "req"); + assert_eq!(decoded.request_event.relays.as_deref(), Some("wss://relay")); + assert!(decoded.content.is_none()); +} + +#[test] fn job_feedback_requires_valid_kind() { let mut fb = sample_feedback(); fb.kind = KIND_JOB_RESULT_MIN as u16; @@ -82,6 +98,25 @@ fn job_feedback_metadata_rejects_wrong_kind() { } #[test] +fn job_feedback_index_from_event_propagates_parse_errors() { + let err = index_from_event( + "id".to_string(), + "author".to_string(), + 1, + KIND_JOB_REQUEST_MIN, + "payload".to_string(), + Vec::new(), + "sig".to_string(), + ) + .unwrap_err(); + + assert!(matches!( + err, + JobParseError::InvalidTag("kind (expected 7000)") + )); +} + +#[test] fn job_feedback_build_tags_cover_optional_paths() { let mut fb = sample_feedback(); fb.extra_info = None; diff --git a/crates/events-codec/tests/job_request.rs b/crates/events-codec/tests/job_request.rs @@ -3,7 +3,7 @@ use radroots_events::job_request::{RadrootsJobInput, RadrootsJobParam, RadrootsJ use radroots_events::kinds::{KIND_JOB_FEEDBACK, KIND_JOB_REQUEST_MIN, KIND_JOB_RESULT_MIN}; use radroots_events_codec::job::encode::JobEncodeError; use radroots_events_codec::job::error::JobParseError; -use radroots_events_codec::job::request::decode::job_request_from_tags; +use radroots_events_codec::job::request::decode::{index_from_event, job_request_from_tags}; use radroots_events_codec::job::request::encode::to_wire_parts; fn sample_request() -> RadrootsJobRequest { @@ -64,6 +64,33 @@ fn job_request_requires_providers_when_encrypted() { } #[test] +fn job_request_from_tags_accepts_encrypted_with_provider() { + let request = job_request_from_tags( + KIND_JOB_REQUEST_MIN + 1, + &[ + vec!["encrypted".to_string()], + vec!["p".to_string(), "provider".to_string()], + ], + ) + .unwrap(); + assert!(request.encrypted); + assert_eq!(request.providers, vec!["provider".to_string()]); +} + +#[test] +fn job_request_to_wire_parts_allows_encrypted_when_provider_present() { + let mut req = sample_request(); + req.encrypted = true; + let parts = to_wire_parts(&req, "payload").unwrap(); + assert!( + parts + .tags + .iter() + .any(|tag| tag.first().map(|v| v.as_str()) == Some("encrypted")) + ); +} + +#[test] fn job_request_metadata_rejects_wrong_kind() { let err = radroots_events_codec::job::request::decode::metadata_from_event( "id".to_string(), @@ -79,3 +106,21 @@ fn job_request_metadata_rejects_wrong_kind() { JobParseError::InvalidTag("kind (expected 5000-5999)") )); } + +#[test] +fn job_request_index_from_event_propagates_parse_errors() { + let err = index_from_event( + "id".to_string(), + "author".to_string(), + 1, + KIND_JOB_RESULT_MIN, + "payload".to_string(), + Vec::new(), + "sig".to_string(), + ) + .unwrap_err(); + assert!(matches!( + err, + JobParseError::InvalidTag("kind (expected 5000-5999)") + )); +} diff --git a/crates/events-codec/tests/job_result.rs b/crates/events-codec/tests/job_result.rs @@ -6,7 +6,7 @@ use radroots_events::job_result::RadrootsJobResult; use radroots_events::kinds::{KIND_JOB_REQUEST_MIN, KIND_JOB_RESULT_MIN}; use radroots_events_codec::job::encode::JobEncodeError; use radroots_events_codec::job::error::JobParseError; -use radroots_events_codec::job::result::decode::job_result_from_tags; +use radroots_events_codec::job::result::decode::{index_from_event, job_result_from_tags}; use radroots_events_codec::job::result::encode::to_wire_parts; fn sample_result() -> RadrootsJobResult { @@ -41,6 +41,14 @@ fn job_result_roundtrip_from_tags() { } #[test] +fn job_result_roundtrip_with_empty_content_sets_none() { + let res = sample_result(); + let parts = to_wire_parts(&res, "").unwrap(); + let decoded = job_result_from_tags(parts.kind, &parts.tags, "").unwrap(); + assert!(decoded.content.is_none()); +} + +#[test] fn job_result_roundtrip_preserves_input_relay_and_marker() { let mut res = sample_result(); res.inputs = vec![RadrootsJobInput { @@ -137,6 +145,19 @@ fn job_result_build_tags_supports_minimal_optional_fields() { } #[test] +fn job_result_build_tags_omits_request_relay_when_absent() { + let mut res = sample_result(); + res.request_event.relays = None; + let parts = to_wire_parts(&res, "payload").unwrap(); + let request = parts + .tags + .iter() + .find(|tag| tag.first().map(|v| v.as_str()) == Some("e")) + .expect("request tag"); + assert_eq!(request.len(), 2); +} + +#[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(); @@ -160,3 +181,21 @@ fn job_result_metadata_rejects_wrong_kind() { JobParseError::InvalidTag("kind (expected 6000-6999)") )); } + +#[test] +fn job_result_index_from_event_propagates_parse_errors() { + let err = index_from_event( + "id".to_string(), + "author".to_string(), + 1, + KIND_JOB_REQUEST_MIN, + "payload".to_string(), + Vec::new(), + "sig".to_string(), + ) + .unwrap_err(); + assert!(matches!( + err, + JobParseError::InvalidTag("kind (expected 6000-6999)") + )); +} diff --git a/crates/events-codec/tests/job_traits.rs b/crates/events-codec/tests/job_traits.rs @@ -1,9 +1,9 @@ +use radroots_events::RadrootsNostrEvent; use radroots_events::job::{JobFeedbackStatus, JobInputType, JobPaymentRequest}; use radroots_events::job_feedback::RadrootsJobFeedback; use radroots_events::job_request::{RadrootsJobInput, RadrootsJobParam, RadrootsJobRequest}; use radroots_events::job_result::RadrootsJobResult; use radroots_events::kinds::{KIND_JOB_FEEDBACK, KIND_JOB_REQUEST_MIN, KIND_JOB_RESULT_MIN}; -use radroots_events::RadrootsNostrEvent; use radroots_events_codec::job::feedback::encode::to_wire_parts as to_feedback_wire_parts; use radroots_events_codec::job::request::encode::to_wire_parts as to_request_wire_parts; use radroots_events_codec::job::result::encode::to_wire_parts as to_result_wire_parts; diff --git a/crates/events-codec/tests/job_util.rs b/crates/events-codec/tests/job_util.rs @@ -53,7 +53,10 @@ fn feedback_status_tag_covers_all_variants() { 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("error"), + Some(JobFeedbackStatus::Error) + ); assert_eq!( feedback_status_from_tag("success"), Some(JobFeedbackStatus::Success) @@ -117,6 +120,15 @@ fn parse_i_tags_handles_multiple_shapes() { } #[test] +fn parse_i_tags_http_url_uses_url_type() { + let tags = vec![vec!["i".to_string(), "http://example.com".to_string()]]; + let inputs = parse_i_tags(&tags); + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0].input_type, JobInputType::Url); + assert_eq!(inputs[0].data, "http://example.com"); +} + +#[test] fn parse_i_tags_covers_marker_and_fallback_shapes() { let tags = vec![ vec!["i".to_string()], diff --git a/crates/events-codec/tests/list.rs b/crates/events-codec/tests/list.rs @@ -124,3 +124,24 @@ fn list_metadata_and_index_from_event_roundtrip() { assert_eq!(index.event.sig, "sig"); assert_eq!(index.metadata.list.entries.len(), 2); } + +#[test] +fn list_index_from_event_propagates_parse_errors() { + let err = index_from_event( + "id".to_string(), + "author".to_string(), + 44, + KIND_POST, + "private".to_string(), + Vec::new(), + "sig".to_string(), + ) + .unwrap_err(); + assert!(matches!( + err, + EventParseError::InvalidKind { + expected: "nip51 standard list kind", + got: KIND_POST + } + )); +} diff --git a/crates/events-codec/tests/list_set.rs b/crates/events-codec/tests/list_set.rs @@ -176,6 +176,27 @@ fn list_set_metadata_and_index_from_event_roundtrip() { } #[test] +fn list_set_index_from_event_propagates_parse_errors() { + let err = index_from_event( + "id".to_string(), + "author".to_string(), + 44, + KIND_POST, + "private".to_string(), + Vec::new(), + "sig".to_string(), + ) + .unwrap_err(); + assert!(matches!( + err, + EventParseError::InvalidKind { + expected: "nip51 list set kind", + got: KIND_POST + } + )); +} + +#[test] fn list_set_decode_keeps_first_optional_display_tags() { let tags = vec![ vec!["d".to_string(), "members.owners".to_string()], diff --git a/crates/events-codec/tests/message.rs b/crates/events-codec/tests/message.rs @@ -1,7 +1,7 @@ use radroots_events::{ + RadrootsNostrEventPtr, kinds::{KIND_MESSAGE, KIND_POST}, message::{RadrootsMessage, RadrootsMessageRecipient}, - RadrootsNostrEventPtr, }; use radroots_events_codec::error::{EventEncodeError, EventParseError}; use radroots_events_codec::message::decode::{ @@ -107,6 +107,22 @@ fn message_to_wire_parts_sets_tags() { } #[test] +fn message_to_wire_parts_handles_absent_optional_fields() { + let message = RadrootsMessage { + recipients: vec![RadrootsMessageRecipient { + public_key: "pub1".to_string(), + relay_url: None, + }], + content: "hello".to_string(), + reply_to: None, + subject: None, + }; + + let parts = to_wire_parts(&message).unwrap(); + assert_eq!(parts.tags, vec![vec!["p".to_string(), "pub1".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(); @@ -213,6 +229,27 @@ fn message_metadata_and_index_from_event_roundtrip() { } #[test] +fn message_index_from_event_propagates_parse_errors() { + let err = index_from_event( + "id".to_string(), + "author".to_string(), + 77, + KIND_POST, + "hello".to_string(), + Vec::new(), + "sig".to_string(), + ) + .unwrap_err(); + assert!(matches!( + err, + EventParseError::InvalidKind { + expected: "14", + got: KIND_POST + } + )); +} + +#[test] fn message_build_tags_rejects_invalid_optional_fields() { let message = RadrootsMessage { recipients: vec![RadrootsMessageRecipient { @@ -309,11 +346,7 @@ 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(), - ], + vec!["e".to_string(), "reply".to_string(), " ".to_string()], ], "hello", ) diff --git a/crates/events-codec/tests/message_file.rs b/crates/events-codec/tests/message_file.rs @@ -363,6 +363,27 @@ fn message_file_metadata_and_index_from_event_roundtrip() { } #[test] +fn message_file_index_from_event_propagates_parse_errors() { + let err = index_from_event( + "id".to_string(), + "author".to_string(), + 77, + KIND_MESSAGE, + "payload".to_string(), + Vec::new(), + "sig".to_string(), + ) + .unwrap_err(); + assert!(matches!( + err, + EventParseError::InvalidKind { + expected: "15", + got: KIND_MESSAGE + } + )); +} + +#[test] fn message_file_from_tags_rejects_empty_content() { let err = message_file_from_tags( KIND_MESSAGE_FILE, diff --git a/crates/events-codec/tests/post.rs b/crates/events-codec/tests/post.rs @@ -83,3 +83,24 @@ fn post_metadata_and_index_from_event_roundtrip() { assert_eq!(index.event.sig, "sig"); assert_eq!(index.metadata.post.content, "hello"); } + +#[test] +fn post_index_from_event_propagates_parse_errors() { + let err = index_from_event( + "id".to_string(), + "author".to_string(), + 77, + KIND_COMMENT, + "hello".to_string(), + Vec::new(), + "sig".to_string(), + ) + .unwrap_err(); + assert!(matches!( + err, + EventParseError::InvalidKind { + expected: "1", + got: KIND_COMMENT + } + )); +} diff --git a/crates/events-codec/tests/reaction.rs b/crates/events-codec/tests/reaction.rs @@ -121,6 +121,23 @@ fn reaction_roundtrip_from_tags() { } #[test] +fn reaction_build_tags_includes_address_tag_when_root_has_d_tag() { + let reaction = RadrootsReaction { + root: common::event_ref_with_d( + "root", + "author", + KIND_POST, + "note-1", + Some(vec!["wss://relay".to_string()]), + ), + content: "+".to_string(), + }; + let tags = reaction_build_tags(&reaction).unwrap(); + assert_eq!(tags.len(), 4); + assert_eq!(tags[3][0], "a"); +} + +#[test] fn reaction_roundtrip_from_legacy_tags() { let root = common::event_ref("root", "author", KIND_POST); let tags = vec![build_event_ref_tag(TAG_E_ROOT, &root)]; diff --git a/crates/events-codec/tests/seal.rs b/crates/events-codec/tests/seal.rs @@ -94,12 +94,37 @@ fn seal_metadata_and_index_from_event_roundtrip() { } #[test] +fn seal_index_from_event_propagates_parse_errors() { + let err = index_from_event( + "id".to_string(), + "author".to_string(), + 14, + KIND_MESSAGE, + "payload".to_string(), + Vec::new(), + "sig".to_string(), + ) + .unwrap_err(); + assert!(matches!( + err, + EventParseError::InvalidKind { + expected: "13", + got: KIND_MESSAGE + } + )); +} + +#[test] fn seal_build_tags_and_kind_validation_cover_paths() { let seal = RadrootsSeal { content: "payload".to_string(), }; assert!(seal_build_tags(&seal).unwrap().is_empty()); + let parts = to_wire_parts_with_kind(&seal, KIND_SEAL).unwrap(); + assert_eq!(parts.kind, KIND_SEAL); + assert_eq!(parts.content, "payload"); + let err = to_wire_parts_with_kind(&seal, KIND_MESSAGE).unwrap_err(); assert!(matches!(err, EventEncodeError::InvalidKind(KIND_MESSAGE))); } diff --git a/crates/events-codec/tests/structured_encode_default.rs b/crates/events-codec/tests/structured_encode_default.rs @@ -143,9 +143,11 @@ fn structured_build_tags_cover_optional_and_error_paths() { }; let farm_tags = farm_build_tags(&farm).unwrap(); assert!(farm_tags.iter().any(|tag| tag[0] == "d")); - assert!(farm_tags - .iter() - .any(|tag| tag[0] == "t" && tag[1] == "organic")); + assert!( + farm_tags + .iter() + .any(|tag| tag[0] == "t" && tag[1] == "organic") + ); assert!(farm_tags.iter().any(|tag| tag[0] == "g")); let mut invalid_farm = farm.clone(); @@ -181,9 +183,11 @@ fn structured_build_tags_cover_optional_and_error_paths() { }; let coop_tags = coop_build_tags(&coop).unwrap(); assert!(coop_tags.iter().any(|tag| tag[0] == "g")); - assert!(coop_tags - .iter() - .any(|tag| tag[0] == "t" && tag[1] == "co-op")); + assert!( + coop_tags + .iter() + .any(|tag| tag[0] == "t" && tag[1] == "co-op") + ); let coop_ref_tags = coop_ref_tags(&RadrootsCoopRef { pubkey: TEST_PUBKEY_HEX.to_string(), d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(), @@ -208,9 +212,11 @@ fn structured_build_tags_cover_optional_and_error_paths() { let doc_tags = document_build_tags(&document).unwrap(); assert!(doc_tags.iter().any(|tag| tag[0] == "p")); assert!(doc_tags.iter().any(|tag| tag[0] == "a")); - assert!(doc_tags - .iter() - .any(|tag| tag[0] == "t" && tag[1] == "policy")); + assert!( + doc_tags + .iter() + .any(|tag| tag[0] == "t" && tag[1] == "policy") + ); let mut invalid_document = document.clone(); invalid_document.subject.address = Some(" ".to_string()); @@ -241,9 +247,11 @@ fn structured_build_tags_cover_optional_and_error_paths() { assert!(plot_tags.iter().any(|tag| tag[0] == "a")); assert!(plot_tags.iter().any(|tag| tag[0] == "p")); assert!(plot_tags.iter().any(|tag| tag[0] == "g")); - assert!(plot_tags - .iter() - .any(|tag| tag[0] == "t" && tag[1] == "shade-grown")); + assert!( + plot_tags + .iter() + .any(|tag| tag[0] == "t" && tag[1] == "shade-grown") + ); let mut invalid_plot = plot.clone(); invalid_plot.location.as_mut().unwrap().gcs.geohash = " ".to_string(); @@ -275,9 +283,11 @@ fn structured_build_tags_cover_optional_and_error_paths() { let area_tags = resource_area_build_tags(&area).unwrap(); assert!(area_tags.iter().any(|tag| tag[0] == "d")); assert!(area_tags.iter().any(|tag| tag[0] == "g")); - assert!(area_tags - .iter() - .any(|tag| tag[0] == "t" && tag[1] == "orchard")); + assert!( + area_tags + .iter() + .any(|tag| tag[0] == "t" && tag[1] == "orchard") + ); let area_ref_tags = resource_area_ref_tags(&RadrootsResourceAreaRef { pubkey: TEST_PUBKEY_HEX.to_string(), d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(), @@ -315,12 +325,16 @@ fn structured_build_tags_cover_optional_and_error_paths() { tags: Some(vec!["seasonal".to_string(), " ".to_string()]), }; let cap_tags = resource_harvest_cap_build_tags(&cap).unwrap(); - assert!(cap_tags - .iter() - .any(|tag| tag[0] == "category" && tag[1] == "spice")); - assert!(cap_tags - .iter() - .any(|tag| tag[0] == "t" && tag[1] == "seasonal")); + assert!( + cap_tags + .iter() + .any(|tag| tag[0] == "category" && tag[1] == "spice") + ); + assert!( + cap_tags + .iter() + .any(|tag| tag[0] == "t" && tag[1] == "seasonal") + ); let mut invalid_cap = cap.clone(); invalid_cap.product.key = " ".to_string(); @@ -360,7 +374,10 @@ fn structured_build_tags_cover_required_field_errors() { 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"))); + 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(); @@ -368,7 +385,10 @@ fn structured_build_tags_cover_required_field_errors() { invalid_document = document.clone(); invalid_document.version = " ".to_string(); let err = document_build_tags(&invalid_document).unwrap_err(); - assert!(matches!(err, EventEncodeError::EmptyRequiredField("version"))); + 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(); @@ -501,7 +521,10 @@ fn structured_build_tags_cover_required_field_errors() { EventEncodeError::EmptyRequiredField("farm.d_tag") )); let err = plot_address(TEST_PUBKEY_HEX, " ").unwrap_err(); - assert!(matches!(err, EventEncodeError::EmptyRequiredField("plot.d_tag"))); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("plot.d_tag") + )); let area = RadrootsResourceArea { d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(), diff --git a/crates/events-codec/tests/tag_builders.rs b/crates/events-codec/tests/tag_builders.rs @@ -2,6 +2,8 @@ use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, }; +use radroots_events::RadrootsNostrEventPtr; +use radroots_events::RadrootsNostrEventRef; use radroots_events::app_data::RadrootsAppData; use radroots_events::comment::RadrootsComment; use radroots_events::coop::RadrootsCoop; @@ -36,8 +38,6 @@ use radroots_events::resource_area::{ }; use radroots_events::resource_cap::{RadrootsResourceHarvestCap, RadrootsResourceHarvestProduct}; use radroots_events::seal::RadrootsSeal; -use radroots_events::RadrootsNostrEventPtr; -use radroots_events::RadrootsNostrEventRef; use radroots_events_codec::job::encode::JobEncodeError; use radroots_events_codec::listing::encode::listing_build_tags; use radroots_events_codec::tag_builders::RadrootsEventTagBuilder; @@ -447,3 +447,28 @@ fn job_request_tag_builder_rejects_encrypted_without_provider() { let err = request.build_tags().unwrap_err(); assert!(matches!(err, JobEncodeError::MissingProvidersForEncrypted)); } + +#[test] +fn job_request_tag_builder_accepts_encrypted_with_provider() { + let request = RadrootsJobRequest { + kind: (KIND_JOB_REQUEST_MIN + 1) as u16, + inputs: vec![RadrootsJobInput { + data: "hello".to_string(), + input_type: JobInputType::Text, + relay: None, + marker: None, + }], + output: None, + params: Vec::new(), + bid_sat: None, + relays: Vec::new(), + providers: vec![TEST_PUBKEY_HEX.to_string()], + topics: Vec::new(), + encrypted: true, + }; + let tags = request.build_tags().unwrap(); + assert!( + tags.iter() + .any(|tag| tag.first().map(|v| v.as_str()) == Some("encrypted")) + ); +} diff --git a/crates/xtask/src/coverage.rs b/crates/xtask/src/coverage.rs @@ -141,17 +141,19 @@ pub fn read_lcov(path: &Path) -> Result<LcovCoverage, String> { let mut da_covered: u64 = 0; let mut executable_total: u64 = 0; let mut executable_covered: u64 = 0; - let mut branch_total: u64 = 0; - let mut branch_covered: u64 = 0; + let mut branch_total_lcov: u64 = 0; + let mut branch_covered_lcov: u64 = 0; + let mut branch_total_brda: u64 = 0; + let mut branch_covered_brda: u64 = 0; for line in raw.lines() { if let Some(value) = line.strip_prefix("DA:") { let Some((_, hit)) = value.split_once(',') else { return Err(format!("invalid DA record in {}", path.display())); }; - let hit_count: u64 = hit - .parse() - .map_err(|err| format!("invalid DA hit count `{hit}` in {}: {err}", path.display()))?; + let hit_count: u64 = hit.parse().map_err(|err| { + format!("invalid DA hit count `{hit}` in {}: {err}", path.display()) + })?; da_total = da_total.saturating_add(1); if hit_count > 0 { da_covered = da_covered.saturating_add(1); @@ -159,31 +161,63 @@ pub fn read_lcov(path: &Path) -> Result<LcovCoverage, String> { continue; } if let Some(value) = line.strip_prefix("LF:") { - let parsed: u64 = value - .parse() - .map_err(|err| format!("invalid LF value `{value}` in {}: {err}", path.display()))?; + let parsed: u64 = value.parse().map_err(|err| { + format!("invalid LF value `{value}` in {}: {err}", path.display()) + })?; executable_total = executable_total.saturating_add(parsed); continue; } if let Some(value) = line.strip_prefix("LH:") { - let parsed: u64 = value - .parse() - .map_err(|err| format!("invalid LH value `{value}` in {}: {err}", path.display()))?; + let parsed: u64 = value.parse().map_err(|err| { + format!("invalid LH value `{value}` in {}: {err}", path.display()) + })?; executable_covered = executable_covered.saturating_add(parsed); continue; } if let Some(value) = line.strip_prefix("BRF:") { - let parsed: u64 = value - .parse() - .map_err(|err| format!("invalid BRF value `{value}` in {}: {err}", path.display()))?; - branch_total = branch_total.saturating_add(parsed); + let parsed: u64 = value.parse().map_err(|err| { + format!("invalid BRF value `{value}` in {}: {err}", path.display()) + })?; + branch_total_lcov = branch_total_lcov.saturating_add(parsed); continue; } if let Some(value) = line.strip_prefix("BRH:") { - let parsed: u64 = value - .parse() - .map_err(|err| format!("invalid BRH value `{value}` in {}: {err}", path.display()))?; - branch_covered = branch_covered.saturating_add(parsed); + let parsed: u64 = value.parse().map_err(|err| { + format!("invalid BRH value `{value}` in {}: {err}", path.display()) + })?; + branch_covered_lcov = branch_covered_lcov.saturating_add(parsed); + continue; + } + if let Some(value) = line.strip_prefix("BRDA:") { + let mut fields = value.split(','); + let _line_no = fields + .next() + .ok_or_else(|| format!("invalid BRDA record in {}", path.display()))?; + let _block_no = fields + .next() + .ok_or_else(|| format!("invalid BRDA record in {}", path.display()))?; + let _branch_no = fields + .next() + .ok_or_else(|| format!("invalid BRDA record in {}", path.display()))?; + let taken = fields + .next() + .ok_or_else(|| format!("invalid BRDA record in {}", path.display()))?; + if fields.next().is_some() { + return Err(format!("invalid BRDA record in {}", path.display())); + } + if taken == "-" { + continue; + } + let hit_count: u64 = taken.parse().map_err(|err| { + format!( + "invalid BRDA taken count `{taken}` in {}: {err}", + path.display() + ) + })?; + branch_total_brda = branch_total_brda.saturating_add(1); + if hit_count > 0 { + branch_covered_brda = branch_covered_brda.saturating_add(1); + } } } @@ -199,6 +233,11 @@ pub fn read_lcov(path: &Path) -> Result<LcovCoverage, String> { executable_percent = (executable_covered as f64 / executable_total as f64) * 100.0_f64; } + let (branch_total, branch_covered) = if branch_total_brda > 0 { + (branch_total_brda, branch_covered_brda) + } else { + (branch_total_lcov, branch_covered_lcov) + }; let branches_available = branch_total > 0; let branch_percent = if branches_available { Some((branch_covered as f64 / branch_total as f64) * 100.0_f64) @@ -582,7 +621,7 @@ mod tests { let path = temp_file_path("lcov"); fs::write( &path, - "DA:1,1\nDA:2,0\nDA:3,1\nBRF:4\nBRH:3\n", + "DA:1,1\nDA:2,0\nDA:3,1\nBRDA:1,0,0,1\nBRDA:1,0,1,0\nBRDA:2,0,0,3\nBRDA:2,0,1,-\n", ) .expect("write lcov"); @@ -590,6 +629,20 @@ mod tests { assert_eq!(lcov.executable_total, 3); assert_eq!(lcov.executable_covered, 2); assert!(lcov.branches_available); + assert_eq!(lcov.branch_total, 3); + assert_eq!(lcov.branch_covered, 2); + assert_eq!(lcov.branch_percent, Some(66.66666666666666)); + + fs::remove_file(path).expect("remove lcov"); + } + + #[test] + fn reads_lcov_branch_metrics_from_brf_brh_when_brda_missing() { + let path = temp_file_path("lcov_fallback"); + fs::write(&path, "DA:1,1\nDA:2,1\nBRF:4\nBRH:3\n").expect("write lcov"); + + let lcov = read_lcov(&path).expect("parse lcov"); + assert!(lcov.branches_available); assert_eq!(lcov.branch_total, 4); assert_eq!(lcov.branch_covered, 3); assert_eq!(lcov.branch_percent, Some(75.0));