commit a92da1b808d0f7455296cd62a1eeb17b41ff3dcc
parent f957017778faa41e47274b03b51d4b6b07232b37
Author: triesap <tyson@radroots.org>
Date: Sun, 21 Jun 2026 19:32:15 +0000
events: expand event model coverage
- Cover draft construction, signed-event conversion, exact-match validation, and ordered mismatch errors.
- Exercise ID wrapper trait, serde, coordinate, relay, and parse-error paths.
- Add contract tag-helper and event-head selection coverage for branch gates.
- Validate with radroots_events test/check/diff and policy coverage gate.
Diffstat:
4 files changed, 417 insertions(+), 49 deletions(-)
diff --git a/crates/events/src/contract.rs b/crates/events/src/contract.rs
@@ -2655,6 +2655,7 @@ mod tests {
event_contract("radroots.list_set.member_of.farms.v1").map(|contract| contract.kind),
Some(KIND_LIST_SET_GENERIC)
);
+ assert!(event_contracts_for_kind(999_999).is_empty());
}
#[test]
@@ -2718,6 +2719,40 @@ mod tests {
}
#[test]
+ fn tag_helpers_cover_missing_names_and_cardinality_mismatches() {
+ let tags = vec![
+ vec!["p".to_owned(), "counterparty".to_owned()],
+ vec!["d".to_owned()],
+ ];
+
+ assert_eq!(tag_value(&tags, "d"), None);
+ assert_eq!(tag_value(&tags, "p"), Some("counterparty"));
+
+ let malformed = [
+ tag(
+ "d",
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::Identifier,
+ RadrootsTagValueType::DTag,
+ true,
+ ),
+ tag(
+ "p",
+ RadrootsTagCardinality::RequiredOne,
+ RadrootsTagSemantic::Counterparty,
+ RadrootsTagValueType::PublicKey,
+ true,
+ ),
+ ];
+
+ assert!(
+ !malformed.iter().any(
+ |tag| tag.name == "d" && tag.cardinality == RadrootsTagCardinality::RequiredOne
+ )
+ );
+ }
+
+ #[test]
fn relay_indexed_tags_are_single_letter() {
for contract in all_event_contracts()
.iter()
diff --git a/crates/events/src/draft.rs b/crates/events/src/draft.rs
@@ -385,6 +385,32 @@ mod tests {
core::iter::repeat_n(character, 64).collect()
}
+ fn signed_event_for_draft(draft: &RadrootsFrozenEventDraft) -> RadrootsSignedNostrEvent {
+ RadrootsSignedNostrEvent::new(RadrootsSignedNostrEventParts {
+ id: draft.expected_event_id.clone(),
+ pubkey: draft.expected_pubkey.clone(),
+ created_at: draft.created_at,
+ kind: draft.kind,
+ tags: draft.tags.clone(),
+ content: draft.content.clone(),
+ sig: "b".repeat(128),
+ raw_json: "{}".to_owned(),
+ })
+ .expect("signed event")
+ }
+
+ fn post_draft() -> RadrootsFrozenEventDraft {
+ RadrootsFrozenEventDraft::new(
+ "radroots.social.post.v1",
+ KIND_POST,
+ 1_700_000_000,
+ vec![vec!["t".to_owned(), "soil".to_owned()]],
+ "hello",
+ "a".repeat(64),
+ )
+ .expect("draft")
+ }
+
#[test]
fn frozen_draft_computes_expected_event_id() {
let draft = RadrootsFrozenEventDraft::new(
@@ -477,6 +503,17 @@ mod tests {
mismatch,
RadrootsDraftError::ContractKindMismatch { .. }
));
+
+ let invalid_pubkey = RadrootsFrozenEventDraft::new(
+ "radroots.social.post.v1",
+ KIND_POST,
+ 1,
+ Vec::new(),
+ "",
+ "not-hex",
+ )
+ .expect_err("invalid pubkey");
+ assert!(matches!(invalid_pubkey, RadrootsDraftError::IdParse(_)));
}
#[test]
@@ -500,33 +537,198 @@ mod tests {
}
#[test]
- fn signed_event_validation_rejects_draft_mismatches() {
- let draft = RadrootsFrozenEventDraft::new(
- "radroots.social.post.v1",
- KIND_POST,
- 1_700_000_000,
- vec![vec!["t".to_owned(), "soil".to_owned()]],
- "hello",
- "a".repeat(64),
- )
- .expect("draft");
- let signed = RadrootsSignedNostrEvent::new(RadrootsSignedNostrEventParts {
- id: draft.expected_event_id.clone(),
- pubkey: draft.expected_pubkey.clone(),
- created_at: draft.created_at,
- kind: draft.kind,
- tags: draft.tags.clone(),
- content: "changed".to_owned(),
- sig: "b".repeat(128),
+ fn signed_event_from_nostr_event_validates_parts() {
+ let event = RadrootsNostrEvent {
+ id: hex_64('1'),
+ author: hex_64('2'),
+ created_at: 42,
+ kind: KIND_POST,
+ tags: vec![vec!["t".to_owned(), "soil".to_owned()]],
+ content: "hello".to_owned(),
+ sig: "3".repeat(128),
+ };
+ let signed = RadrootsSignedNostrEvent::from_event(event, "{\"id\":\"fixture\"}")
+ .expect("signed event");
+
+ assert_eq!(signed.id, hex_64('1'));
+ assert_eq!(signed.pubkey, hex_64('2'));
+ assert_eq!(signed.sig, "3".repeat(128));
+ assert_eq!(signed.raw_json, "{\"id\":\"fixture\"}");
+
+ let invalid = RadrootsSignedNostrEvent::new(RadrootsSignedNostrEventParts {
+ id: "not-hex".to_owned(),
+ pubkey: hex_64('e'),
+ created_at: 10,
+ kind: KIND_POST,
+ tags: Vec::new(),
+ content: String::new(),
+ sig: "f".repeat(128),
+ raw_json: "{}".to_owned(),
+ })
+ .expect_err("invalid id");
+ assert!(matches!(invalid, RadrootsDraftError::IdParse(_)));
+
+ let invalid = RadrootsSignedNostrEvent::new(RadrootsSignedNostrEventParts {
+ id: hex_64('d'),
+ pubkey: "not-hex".to_owned(),
+ created_at: 10,
+ kind: KIND_POST,
+ tags: Vec::new(),
+ content: String::new(),
+ sig: "f".repeat(128),
+ raw_json: "{}".to_owned(),
+ })
+ .expect_err("invalid pubkey");
+ assert!(matches!(invalid, RadrootsDraftError::IdParse(_)));
+
+ let invalid = RadrootsSignedNostrEvent::new(RadrootsSignedNostrEventParts {
+ id: hex_64('d'),
+ pubkey: hex_64('e'),
+ created_at: 10,
+ kind: KIND_POST,
+ tags: Vec::new(),
+ content: String::new(),
+ sig: "not-hex".to_owned(),
raw_json: "{}".to_owned(),
})
- .expect("signed");
+ .expect_err("invalid sig");
+ assert!(matches!(invalid, RadrootsDraftError::IdParse(_)));
+ }
+
+ #[test]
+ fn signed_event_validation_accepts_exact_draft_match() {
+ let draft = post_draft();
+ let signed = signed_event_for_draft(&draft);
+
+ validate_signed_nostr_event_matches_draft(&signed, &draft).expect("valid signed event");
+ }
+
+ #[test]
+ fn signed_event_validation_rejects_draft_mismatches() {
+ let draft = post_draft();
+
+ let mut signed = signed_event_for_draft(&draft);
+ signed.pubkey = hex_64('c');
+ let error =
+ validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch");
+ assert!(matches!(
+ error,
+ RadrootsDraftError::SignedEventPubkeyMismatch { .. }
+ ));
+
+ let mut signed = signed_event_for_draft(&draft);
+ signed.id = hex_64('d');
+ let error =
+ validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch");
+ assert!(matches!(
+ error,
+ RadrootsDraftError::SignedEventIdMismatch { .. }
+ ));
+
+ let mut signed = signed_event_for_draft(&draft);
+ signed.created_at += 1;
+ let error =
+ validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch");
+ assert!(matches!(
+ error,
+ RadrootsDraftError::SignedEventCreatedAtMismatch { .. }
+ ));
+
+ let mut signed = signed_event_for_draft(&draft);
+ signed.kind = KIND_PROFILE;
+ let error =
+ validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch");
+ assert!(matches!(
+ error,
+ RadrootsDraftError::SignedEventKindMismatch { .. }
+ ));
+ let mut signed = signed_event_for_draft(&draft);
+ signed.tags.push(vec!["p".to_owned(), hex_64('e')]);
+ let error =
+ validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch");
+ assert!(matches!(
+ error,
+ RadrootsDraftError::SignedEventTagsMismatch { .. }
+ ));
+
+ let mut signed = signed_event_for_draft(&draft);
+ signed.content = "changed".to_owned();
let error =
validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch");
assert!(matches!(
error,
RadrootsDraftError::SignedEventContentMismatch { .. }
));
+
+ let mut draft = post_draft();
+ draft.expected_event_id = hex_64('f');
+ let signed = signed_event_for_draft(&draft);
+ let error =
+ validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch");
+ assert!(matches!(
+ error,
+ RadrootsDraftError::SignedEventComputedIdMismatch { .. }
+ ));
+ }
+
+ #[test]
+ fn draft_errors_format_all_variants() {
+ let errors = [
+ RadrootsDraftError::UnknownContract("missing".to_owned()),
+ RadrootsDraftError::ContractKindMismatch {
+ contract_id: "radroots.social.post.v1".to_owned(),
+ expected_kind: KIND_POST,
+ actual_kind: KIND_PROFILE,
+ },
+ RadrootsDraftError::SignedEventPubkeyMismatch {
+ expected_pubkey: hex_64('a'),
+ actual_pubkey: hex_64('b'),
+ },
+ RadrootsDraftError::SignedEventIdMismatch {
+ expected_event_id: hex_64('c'),
+ actual_event_id: hex_64('d'),
+ },
+ RadrootsDraftError::SignedEventCreatedAtMismatch {
+ expected_created_at: 1,
+ actual_created_at: 2,
+ },
+ RadrootsDraftError::SignedEventKindMismatch {
+ expected_kind: KIND_POST,
+ actual_kind: KIND_PROFILE,
+ },
+ RadrootsDraftError::SignedEventTagsMismatch {
+ expected_len: 1,
+ actual_len: 2,
+ },
+ RadrootsDraftError::SignedEventContentMismatch {
+ expected_len: 5,
+ actual_len: 7,
+ },
+ RadrootsDraftError::SignedEventComputedIdMismatch {
+ expected_event_id: hex_64('e'),
+ computed_event_id: hex_64('f'),
+ },
+ RadrootsDraftError::from(RadrootsIdParseError::Empty),
+ ];
+
+ for error in errors {
+ assert!(!error.to_string().is_empty());
+ }
+
+ let json_error = serde_json::from_str::<String>("{").expect_err("json error");
+ let error = RadrootsDraftError::from(json_error);
+ assert!(
+ error
+ .to_string()
+ .contains("json string serialization failed")
+ );
+ }
+
+ #[test]
+ fn event_id_computation_rejects_invalid_pubkeys() {
+ let error =
+ compute_nip01_event_id("not-hex", 1, KIND_POST, &[], "").expect_err("invalid pubkey");
+ assert!(matches!(error, RadrootsDraftError::IdParse(_)));
}
}
diff --git a/crates/events/src/event_head.rs b/crates/events/src/event_head.rs
@@ -223,10 +223,14 @@ mod tests {
}
fn candidate(id: char, created_at: u32) -> RadrootsEventHeadCandidate {
- match event_head_candidate_for_class(
+ expect_candidate(event_head_candidate_for_class(
&event(10002, &hex_64(id), &hex_64('a'), created_at, Vec::new()),
RadrootsEventClass::Replaceable,
- ) {
+ ))
+ }
+
+ fn expect_candidate(result: RadrootsEventHeadCandidateResult) -> RadrootsEventHeadCandidate {
+ match result {
RadrootsEventHeadCandidateResult::Candidate(candidate) => candidate,
other => panic!("expected candidate: {other:?}"),
}
@@ -248,11 +252,10 @@ mod tests {
#[test]
fn replaceable_events_use_kind_and_pubkey_coordinates() {
let event = event(10002, &hex_64('1'), &hex_64('a'), 5, Vec::new());
- let RadrootsEventHeadCandidateResult::Candidate(candidate) =
- event_head_candidate_for_class(&event, RadrootsEventClass::Replaceable)
- else {
- panic!("expected candidate")
- };
+ let candidate = expect_candidate(event_head_candidate_for_class(
+ &event,
+ RadrootsEventClass::Replaceable,
+ ));
assert_eq!(
candidate.coordinate,
RadrootsEventHeadCoordinate::Replaceable {
@@ -272,11 +275,10 @@ mod tests {
7,
vec![vec![TAG_D.to_string(), "article-1".to_string()]],
);
- let RadrootsEventHeadCandidateResult::Candidate(candidate) =
- event_head_candidate_for_class(&event, RadrootsEventClass::Addressable)
- else {
- panic!("expected candidate")
- };
+ let candidate = expect_candidate(event_head_candidate_for_class(
+ &event,
+ RadrootsEventClass::Addressable,
+ ));
assert_eq!(
candidate.coordinate,
RadrootsEventHeadCoordinate::Addressable {
@@ -332,6 +334,10 @@ mod tests {
let current: RadrootsCurrentEventHead = candidate('3', 10).into();
assert!(matches!(
+ select_event_head(candidate('1', 1), None),
+ RadrootsEventHeadDecision::Applied(_)
+ ));
+ assert!(matches!(
select_event_head(candidate('4', 11), Some(¤t)),
RadrootsEventHeadDecision::Applied(_)
));
@@ -366,9 +372,7 @@ mod tests {
),
RadrootsEventClass::Addressable,
);
- let RadrootsEventHeadCandidateResult::Candidate(other) = other else {
- panic!("expected candidate")
- };
+ let other = expect_candidate(other);
assert_eq!(
select_event_head(other, Some(¤t)),
RadrootsEventHeadDecision::CoordinateMismatch
@@ -378,11 +382,7 @@ mod tests {
#[test]
fn contract_bridge_uses_replaceable_event_classes() {
let event = event(KIND_FOLLOW, &hex_64('1'), &hex_64('a'), 1, Vec::new());
- let RadrootsEventHeadCandidateResult::Candidate(candidate) =
- event_head_candidate_for_event(&event).expect("contract")
- else {
- panic!("expected candidate")
- };
+ let candidate = expect_candidate(event_head_candidate_for_event(&event).expect("contract"));
assert_eq!(
candidate.coordinate,
RadrootsEventHeadCoordinate::Replaceable {
@@ -401,11 +401,7 @@ mod tests {
1,
vec![vec![TAG_D.to_string(), "member_of.farms".to_string()]],
);
- let RadrootsEventHeadCandidateResult::Candidate(candidate) =
- event_head_candidate_for_event(&event).expect("contract")
- else {
- panic!("expected candidate")
- };
+ let candidate = expect_candidate(event_head_candidate_for_event(&event).expect("contract"));
assert_eq!(
candidate.coordinate,
RadrootsEventHeadCoordinate::Addressable {
@@ -426,11 +422,8 @@ mod tests {
Vec::new(),
r#"{"name":"Alice"}"#,
);
- let RadrootsEventHeadCandidateResult::Candidate(candidate) =
- event_head_candidate_for_event(&profile).expect("profile contract")
- else {
- panic!("expected candidate")
- };
+ let candidate =
+ expect_candidate(event_head_candidate_for_event(&profile).expect("profile contract"));
assert_eq!(
candidate.coordinate,
RadrootsEventHeadCoordinate::Replaceable {
diff --git a/crates/events/src/ids.rs b/crates/events/src/ids.rs
@@ -324,6 +324,49 @@ fn validate_visible_token(value: &str, max_len: usize) -> Result<String, Radroot
mod tests {
use super::*;
+ macro_rules! assert_identifier_impls {
+ ($ty:ty, $value:expr) => {{
+ let value = $value.to_owned();
+ let value = value.as_str();
+ let id = <$ty>::parse(value).expect("parse");
+
+ assert_eq!(id.as_str(), value);
+ assert_eq!(id.as_ref(), value);
+ assert_eq!(&*id, value);
+ assert_eq!(<$ty as core::borrow::Borrow<str>>::borrow(&id), value);
+ assert_eq!(id.to_string(), value);
+ assert_eq!(
+ <$ty as core::str::FromStr>::from_str(value).expect("from str"),
+ id
+ );
+ assert_eq!(
+ <$ty as TryFrom<&str>>::try_from(value).expect("try from str"),
+ id
+ );
+ assert_eq!(
+ <$ty as TryFrom<String>>::try_from(value.to_owned()).expect("try from string"),
+ id
+ );
+ assert_eq!(id, value);
+ assert_eq!(value, id);
+ assert_eq!(id, value.to_owned());
+ assert_eq!(value.to_owned(), id);
+
+ let id = <$ty>::parse(value).expect("parse");
+ let converted: String = String::from(id.clone());
+ assert_eq!(converted, value);
+ assert_eq!(id.into_string(), value.to_owned());
+
+ #[cfg(feature = "serde")]
+ {
+ let id = <$ty>::parse(value).expect("parse");
+ let encoded = serde_json::to_string(&id).expect("serialize");
+ let decoded: $ty = serde_json::from_str(&encoded).expect("deserialize");
+ assert_eq!(decoded.as_str(), value);
+ }
+ }};
+ }
+
fn hex_64(character: char) -> String {
core::iter::repeat_n(character, 64).collect()
}
@@ -354,6 +397,27 @@ mod tests {
}
#[test]
+ fn id_parse_errors_have_stable_display_messages() {
+ let errors = [
+ RadrootsIdParseError::Empty,
+ RadrootsIdParseError::InvalidFormat,
+ RadrootsIdParseError::InvalidLength {
+ expected: 64,
+ actual: 7,
+ },
+ RadrootsIdParseError::InvalidCharacter,
+ RadrootsIdParseError::TooLong {
+ max: 128,
+ actual: 129,
+ },
+ ];
+
+ for error in errors {
+ assert!(!error.to_string().is_empty());
+ }
+ }
+
+ #[test]
fn signatures_require_128_hex_chars() {
let signature = RadrootsEventSignature::parse(hex_128('B')).expect("signature");
assert_eq!(signature.as_str(), "b".repeat(128));
@@ -404,6 +468,20 @@ mod tests {
actual: 7
}
);
+ assert_eq!(
+ RadrootsAddressableCoordinate::parse("30402").unwrap_err(),
+ RadrootsIdParseError::InvalidFormat
+ );
+ assert_eq!(
+ RadrootsAddressableCoordinate::parse(format!("bad:{}:listing-1", hex_64('a')))
+ .unwrap_err(),
+ RadrootsIdParseError::InvalidFormat
+ );
+ assert_eq!(
+ RadrootsAddressableCoordinate::parse(format!("30402:{}:bad d", hex_64('0')))
+ .unwrap_err(),
+ RadrootsIdParseError::InvalidCharacter
+ );
}
#[test]
@@ -451,6 +529,13 @@ mod tests {
.as_str(),
"digest-1"
);
+ assert_eq!(
+ RadrootsEconomicsDigest::parse("sha256:not-hex").unwrap_err(),
+ RadrootsIdParseError::InvalidLength {
+ expected: 64,
+ actual: 7
+ }
+ );
}
#[test]
@@ -461,6 +546,59 @@ mod tests {
assert_eq!(parsed.as_str(), hex_64('d'));
}
+ #[test]
+ fn validated_identifier_wrappers_expose_consistent_traits() {
+ let addressable = format!("30402:{}:listing-1", hex_64('0'));
+
+ assert_identifier_impls!(RadrootsPublicKey, hex_64('a').as_str());
+ assert_identifier_impls!(RadrootsEventId, hex_64('b').as_str());
+ assert_identifier_impls!(RadrootsEventSignature, hex_128('c').as_str());
+ assert_identifier_impls!(RadrootsDTag, "listing-1");
+ assert_identifier_impls!(RadrootsAddressableCoordinate, addressable.as_str());
+ assert_identifier_impls!(RadrootsListingAddress, addressable.as_str());
+ assert_identifier_impls!(RadrootsOrderId, "order-1");
+ assert_identifier_impls!(RadrootsOrderRevisionId, "revision-1");
+ assert_identifier_impls!(RadrootsOrderQuoteId, "quote-1");
+ assert_identifier_impls!(RadrootsInventoryBinId, "bin-1");
+ assert_identifier_impls!(RadrootsEconomicsDigest, "digest-1");
+ assert_identifier_impls!(RadrootsEventPointer, hex_64('d').as_str());
+ }
+
+ #[test]
+ fn nostr_event_pointers_validate_relay_values() {
+ let event_id = RadrootsEventId::parse(hex_64('e')).expect("event id");
+ let pointer = RadrootsNostrEventPointer::new(
+ event_id.clone(),
+ ["wss://relay.one.example", "wss://relay.two.example"],
+ )
+ .expect("pointer");
+
+ assert_eq!(pointer.event_id, event_id);
+ assert_eq!(
+ pointer.relays,
+ vec![
+ "wss://relay.one.example".to_owned(),
+ "wss://relay.two.example".to_owned()
+ ]
+ );
+
+ for relay in [
+ "",
+ " wss://relay.example",
+ "wss://relay.example\n",
+ "wss://relay.example/\u{7}",
+ ] {
+ assert_eq!(
+ RadrootsNostrEventPointer::new(
+ RadrootsEventId::parse(hex_64('e')).expect("event id"),
+ [relay],
+ )
+ .unwrap_err(),
+ RadrootsIdParseError::InvalidCharacter
+ );
+ }
+ }
+
#[cfg(feature = "serde")]
#[test]
fn serde_deserialization_validates_identifiers() {