lib

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

commit 8928fa0cadc86a1987ccf19001bb126943ab0187
parent 2477802ea5089e93051521be10fdc870ae6177a3
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Jul 2026 07:32:27 +0000

event_store: close semantic storage coverage

- cover public pool transaction and trade query APIs.
- verify malformed trade mutations are quarantined before projection.
- exercise mutation variants parent resolution and storage bounds.
- restore the event-store policy gate to 100 percent across all metrics.

Diffstat:
Mcrates/event_store/src/error.rs | 16++++++++++++++++
Mcrates/event_store/src/model.rs | 10++++++++++
Mcrates/event_store/src/store.rs | 317++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
3 files changed, 326 insertions(+), 17 deletions(-)

diff --git a/crates/event_store/src/error.rs b/crates/event_store/src/error.rs @@ -55,3 +55,19 @@ impl From<RadrootsTransportError> for RadrootsEventStoreError { Self::Transport(value) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg_attr(coverage_nightly, coverage(off))] + fn transport_errors_preserve_their_typed_source() { + let error = RadrootsEventStoreError::from(RadrootsTransportError::InvalidTargetUri); + + assert!(matches!( + error, + RadrootsEventStoreError::Transport(RadrootsTransportError::InvalidTargetUri) + )); + } +} diff --git a/crates/event_store/src/model.rs b/crates/event_store/src/model.rs @@ -623,6 +623,7 @@ mod tests { observation_type ); } + assert!(RadrootsTransportObservationType::parse("bad").is_err()); let observation = RadrootsTransportObservation::new( RadrootsTransportKind::Nostr, "wss://relay.example.test", @@ -636,6 +637,15 @@ mod tests { observation.endpoint_uri.as_str(), "wss://relay.example.test" ); + assert!( + RadrootsTransportObservation::new( + RadrootsTransportKind::Nostr, + "not a URI", + RadrootsTransportObservationType::Fetch, + 1, + ) + .is_err() + ); } #[test] diff --git a/crates/event_store/src/store.rs b/crates/event_store/src/store.rs @@ -728,10 +728,7 @@ async fn insert_trade_mutation_parents( ) .bind(mutation_id.as_str()) .bind(parent.as_str()) - .bind(i64::try_from(index).map_err(|_| RadrootsEventStoreError::IntegerRange { - field: "parent_index", - value: i64::MAX, - })?) + .bind(i64_from_usize("parent_index", index)?) .execute(&mut **tx) .await?; } @@ -840,10 +837,7 @@ async fn insert_seller_reservation( .bind(line.quantity_mantissa.as_str()) .bind(i64::from(line.quantity_scale)) .bind(line.unit_code.as_str()) - .bind(i64::try_from(index).map_err(|_| RadrootsEventStoreError::IntegerRange { - field: "reservation.line_index", - value: i64::MAX, - })?) + .bind(i64_from_usize("reservation.line_index", index)?) .execute(&mut **tx) .await?; } @@ -1572,6 +1566,13 @@ fn i64_from_u64(field: &'static str, value: u64) -> Result<i64, RadrootsEventSto i64::try_from(value).map_err(|_| RadrootsEventStoreError::UnsignedIntegerRange { field, value }) } +fn i64_from_usize(field: &'static str, value: usize) -> Result<i64, RadrootsEventStoreError> { + i64::try_from(value).map_err(|_| RadrootsEventStoreError::UnsignedIntegerRange { + field, + value: value as u64, + }) +} + fn bool_i64(value: bool) -> i64 { if value { 1 } else { 0 } } @@ -1649,7 +1650,7 @@ mod tests { RadrootsTradeCancellationProfileV1, RadrootsTradeCandidateLineV1, RadrootsTradeCandidateTermsV1, RadrootsTradeCanonicalMutationV1, RadrootsTradeDecisionV1, RadrootsTradeEconomicAdjustmentV1, RadrootsTradeEconomicsProfileV1, - RadrootsTradeMutationBodyV1, RadrootsTradeMutationEnvelopeV1, + RadrootsTradeMutationBodyV1, RadrootsTradeMutationEnvelopeV1, canonical_jcs_value, canonical_trade_mutation_content, }; use radroots_event::wire::{RadrootsNip01EventWire, compute_canonical_nip01_event_id}; @@ -1669,6 +1670,14 @@ mod tests { RadrootsNostrKeys::new(secret_key) } + fn alternate_keys() -> RadrootsNostrKeys { + let secret_key = RadrootsNostrSecretKey::from_hex( + "0000000000000000000000000000000000000000000000000000000000000001", + ) + .expect("alternate secret key"); + RadrootsNostrKeys::new(secret_key) + } + fn event_id(character: char) -> String { core::iter::repeat_n(character, 64).collect() } @@ -1814,6 +1823,14 @@ mod tests { } fn signed_trade_mutation(canonical: &RadrootsTradeCanonicalMutationV1) -> RadrootsSignedEvent { + signed_trade_content_with_keys(canonical, canonical.content.clone(), &fixture_keys()) + } + + fn signed_trade_content_with_keys( + canonical: &RadrootsTradeCanonicalMutationV1, + content: String, + keys: &RadrootsNostrKeys, + ) -> RadrootsSignedEvent { let counterparty = canonical.envelope.counterparty_pubkey.as_str().to_owned(); let mut tags = vec![ vec![ @@ -1826,17 +1843,18 @@ mod tests { for parent in &canonical.envelope.parent_mutation_ids { tags.push(vec!["e".to_owned(), parent.to_string()]); } - let draft = radroots_event::draft::RadrootsEventDraft::new( - canonical.envelope.contract_id.clone(), + let raw_event = radroots_nostr_build_event( canonical.envelope.mutation_kind().nostr_kind(), - canonical.envelope.authored_at_unix_s, + content, tags, - canonical.content.clone(), - FIXTURE_ALICE_PUBLIC_KEY_HEX, ) - .expect("trade draft"); - radroots_nostr::prelude::radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft) - .expect("signed trade mutation") + .expect("trade event builder") + .custom_created_at(RadrootsNostrTimestamp::from_secs( + canonical.envelope.authored_at_unix_s, + )) + .sign_with_keys(keys) + .expect("signed trade event"); + signed_event_from_raw_json(serde_json::to_string(&raw_event).expect("trade raw json")) } fn signed_event( @@ -2264,6 +2282,271 @@ mod tests { } #[tokio::test] + async fn public_pool_transaction_and_trade_query_apis_roundtrip() { + let options = SqliteConnectOptions::from_str("sqlite::memory:").expect("options"); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .expect("pool"); + let store = RadrootsEventStore::open_pool(pool, false) + .await + .expect("store"); + let proposal = canonical_trade_mutation_content(proposal_envelope()).expect("proposal"); + let proposal_event = signed_trade_mutation(&proposal); + let ingest = + RadrootsEventIngest::from_raw_json(proposal_event.raw_json().to_owned(), 2_200) + .expect("raw ingest"); + let mut tx = store.pool().begin().await.expect("transaction"); + let receipt = store + .ingest_event_in_transaction(&mut tx, ingest) + .await + .expect("transactional ingest"); + tx.commit().await.expect("commit"); + assert!(receipt.projection_eligible); + + assert!(matches!( + store.trade_mutations_for_trade(&trade_id(), 0).await, + Err(RadrootsEventStoreError::QueryLimitOutOfRange { .. }) + )); + assert!(matches!( + store + .trade_mutations_for_trade(&trade_id(), RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX + 1,) + .await, + Err(RadrootsEventStoreError::QueryLimitOutOfRange { .. }) + )); + let mutations = store + .trade_mutations_for_trade(&trade_id(), 10) + .await + .expect("mutations"); + assert_eq!(mutations.len(), 1); + assert_eq!(mutations[0].mutation_id, proposal.mutation_id); + + let decision = + canonical_trade_mutation_content(decision_envelope(&proposal)).expect("decision"); + store + .ingest_event(RadrootsEventIngest::new( + signed_trade_mutation(&decision), + 2_300, + )) + .await + .expect("decision ingest"); + assert!( + store + .missing_trade_parents(&trade_id()) + .await + .expect("missing parents") + .is_empty() + ); + } + + #[tokio::test] + async fn malformed_trade_mutations_are_quarantined_and_not_projected() { + let store = RadrootsEventStore::open_memory().await.expect("store"); + let proposal = canonical_trade_mutation_content(proposal_envelope()).expect("proposal"); + + let malformed = signed_trade_content_with_keys( + &proposal, + format!("{} ", proposal.content), + &fixture_keys(), + ); + let malformed_receipt = store + .ingest_event(RadrootsEventIngest::new(malformed, 2_400)) + .await + .expect("malformed ingest"); + assert!(!malformed_receipt.projection_eligible); + assert_eq!( + malformed_receipt.head_decision, + RadrootsEventHeadStoreDecision::Malformed + ); + + let mut missing_id_value: serde_json::Value = + serde_json::from_str(&proposal.content).expect("proposal json"); + missing_id_value + .as_object_mut() + .expect("proposal object") + .remove("mutation_id"); + let missing_id_content = canonical_jcs_value(&missing_id_value).expect("canonical json"); + let missing_id = + signed_trade_content_with_keys(&proposal, missing_id_content, &fixture_keys()); + let missing_id_receipt = store + .ingest_event(RadrootsEventIngest::new(missing_id, 2_500)) + .await + .expect("missing id ingest"); + assert!(!missing_id_receipt.projection_eligible); + + let mismatched_author = + signed_trade_content_with_keys(&proposal, proposal.content.clone(), &alternate_keys()); + let mismatched_author_receipt = store + .ingest_event(RadrootsEventIngest::new(mismatched_author, 2_600)) + .await + .expect("mismatched author ingest"); + assert!(!mismatched_author_receipt.projection_eligible); + + let quarantined: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM trade_projection_quarantine") + .fetch_one(store.pool()) + .await + .expect("quarantine count"); + assert_eq!(quarantined, 3); + } + + #[test] + #[cfg_attr(coverage_nightly, coverage(off))] + fn trade_storage_helpers_cover_every_mutation_variant() { + let proposal = canonical_trade_mutation_content(proposal_envelope()).expect("proposal"); + let decision = + canonical_trade_mutation_content(decision_envelope(&proposal)).expect("decision"); + let RadrootsTradeMutationBodyV1::Proposal { candidate } = &proposal.envelope.body else { + unreachable!("proposal fixture"); + }; + let RadrootsTradeMutationBodyV1::Decision { + proposal_mutation_id, + candidate_id, + decision: decision_value, + } = &decision.envelope.body + else { + unreachable!("decision fixture"); + }; + + let mut revision_proposal = proposal.envelope.clone(); + revision_proposal.body = RadrootsTradeMutationBodyV1::RevisionProposal { + candidate: candidate.clone(), + }; + let mut revision_decision = decision.envelope.clone(); + revision_decision.body = RadrootsTradeMutationBodyV1::RevisionDecision { + proposal_mutation_id: proposal_mutation_id.clone(), + candidate_id: candidate_id.clone(), + decision: decision_value.clone(), + }; + let mut cancellation = proposal.envelope.clone(); + cancellation.body = RadrootsTradeMutationBodyV1::Cancellation { + target_candidate_id: Some(candidate_id.clone()), + target_claim_mutation_id: Some(decision.mutation_id.clone()), + reason: "fixture".to_owned(), + }; + let mut claim_only_cancellation = cancellation.clone(); + claim_only_cancellation.body = RadrootsTradeMutationBodyV1::Cancellation { + target_candidate_id: None, + target_claim_mutation_id: Some(decision.mutation_id.clone()), + reason: "fixture".to_owned(), + }; + + assert_eq!( + candidate_id_for_mutation(&revision_proposal), + Some(candidate_id.clone()) + ); + assert_eq!( + candidate_id_for_mutation(&revision_decision), + Some(candidate_id.clone()) + ); + assert_eq!( + candidate_id_for_mutation(&cancellation), + Some(candidate_id.clone()) + ); + assert_eq!(candidate_id_for_mutation(&claim_only_cancellation), None); + assert_eq!( + proposal_mutation_id_for_mutation(&revision_decision), + Some(proposal_mutation_id.clone()) + ); + assert_eq!( + target_claim_mutation_id_for_mutation(&cancellation), + Some(decision.mutation_id.clone()) + ); + assert!(seller_reservation_for_mutation(&revision_decision).is_some()); + assert!(seller_reservation_for_mutation(&cancellation).is_none()); + assert_eq!(public_key('b').as_str(), event_id('b')); + + for kind in [ + RadrootsTradeMutationKindV1::Proposal, + RadrootsTradeMutationKindV1::Decision, + RadrootsTradeMutationKindV1::RevisionProposal, + RadrootsTradeMutationKindV1::RevisionDecision, + RadrootsTradeMutationKindV1::Cancellation, + ] { + let stored = trade_mutation_kind_storage_value(kind); + assert_eq!( + parse_trade_mutation_kind(stored).expect("stored kind"), + kind + ); + } + assert!(parse_trade_mutation_kind("bad").is_err()); + } + + #[tokio::test] + #[cfg_attr(coverage_nightly, coverage(off))] + async fn seller_reservation_rejects_unrepresentable_storage_times() { + let store = RadrootsEventStore::open_memory().await.expect("store"); + let proposal = canonical_trade_mutation_content(proposal_envelope()).expect("proposal"); + let decision = + canonical_trade_mutation_content(decision_envelope(&proposal)).expect("decision"); + let RadrootsTradeMutationBodyV1::Decision { + decision: + RadrootsTradeDecisionV1::Accepted { + reservation_assertion: Some(reservation), + }, + .. + } = &decision.envelope.body + else { + unreachable!("accepted decision fixture"); + }; + let mut tx = store.pool().begin().await.expect("transaction"); + + let mut invalid_epoch = reservation.clone(); + invalid_epoch.inventory_epoch = u64::MAX; + assert!(matches!( + insert_seller_reservation( + &mut tx, + &decision.envelope, + &decision.mutation_id, + &invalid_epoch, + 1, + ) + .await, + Err(RadrootsEventStoreError::UnsignedIntegerRange { + field: "inventory_epoch", + .. + }) + )); + + let mut invalid_expiry = reservation.clone(); + invalid_expiry.reservation_expires_at_unix_s = u64::MAX; + assert!(matches!( + insert_seller_reservation( + &mut tx, + &decision.envelope, + &decision.mutation_id, + &invalid_expiry, + 1, + ) + .await, + Err(RadrootsEventStoreError::UnsignedIntegerRange { + field: "reservation_expires_at_unix_s", + .. + }) + )); + } + + #[test] + #[cfg_attr(coverage_nightly, coverage(off))] + fn usize_storage_conversion_covers_success_and_overflow() { + assert_eq!(i64_from_usize("index", 1).expect("index"), 1); + assert!(matches!( + i64_from_usize("index", usize::MAX), + Err(RadrootsEventStoreError::UnsignedIntegerRange { field: "index", .. }) + )); + } + + #[test] + #[should_panic(expected = "proposal")] + fn decision_fixture_rejects_a_non_proposal() { + let proposal = canonical_trade_mutation_content(proposal_envelope()).expect("proposal"); + let decision = + canonical_trade_mutation_content(decision_envelope(&proposal)).expect("decision"); + let _ = decision_envelope(&decision); + } + + #[tokio::test] async fn wrapper_json_is_rejected_as_event_authority() { let event = signed_event(KIND_POST, 10, Vec::new(), "hello"); let wrapper_json = serde_json::to_string(&event).expect("wrapper json");