lib

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

commit ad6a828ff30a516a7fe146c6bc5c54815f8c4045
parent e9c29396cb6d838e89cf5452dcf95f9620306f2a
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Feb 2026 05:57:32 +0000

coverage: raise `radroots-nostr-ndb` to strict 100 gates

Diffstat:
Mcrates/nostr-ndb/src/error.rs | 27+++++++++++++++++++++++++++
Mcrates/nostr-ndb/src/filter.rs | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/nostr-ndb/src/ndb.rs | 175++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/nostr-ndb/src/runtime_adapter.rs | 34++++++++++++++++++++++++++++++++--
Mcrates/nostr-ndb/src/subscription.rs | 44++++++++++++++++++++++++++++++++++----------
5 files changed, 334 insertions(+), 32 deletions(-)

diff --git a/crates/nostr-ndb/src/error.rs b/crates/nostr-ndb/src/error.rs @@ -28,3 +28,30 @@ impl From<nostrdb::Error> for RadrootsNostrNdbError { Self::Ndb(value.to_string()) } } + +impl From<serde_json::Error> for RadrootsNostrNdbError { + fn from(value: serde_json::Error) -> Self { + Self::EventJsonEncode(value.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn converts_nostrdb_error() { + let converted: RadrootsNostrNdbError = nostrdb::Error::NotFound.into(); + assert!(matches!(converted, RadrootsNostrNdbError::Ndb(_))); + } + + #[test] + fn converts_serde_json_error() { + let source = serde_json::from_str::<serde_json::Value>("not json").expect_err("json error"); + let converted: RadrootsNostrNdbError = source.into(); + assert!(matches!( + converted, + RadrootsNostrNdbError::EventJsonEncode(_) + )); + } +} diff --git a/crates/nostr-ndb/src/filter.rs b/crates/nostr-ndb/src/filter.rs @@ -156,3 +156,89 @@ pub(crate) fn parse_hex_32( out.copy_from_slice(bytes.as_slice()); Ok(out) } + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_hex_32(value: u8) -> String { + format!("{value:02x}").repeat(32) + } + + #[test] + fn filter_spec_builders_and_accessors_round_trip() { + let event_id = valid_hex_32(0x11); + let author = valid_hex_32(0x22); + + let empty_notes = RadrootsNostrNdbFilterSpec::text_notes(None, None); + assert_eq!(empty_notes.kinds(), &[1]); + assert_eq!(empty_notes.limit(), None); + assert_eq!(empty_notes.since_unix(), None); + + let spec = RadrootsNostrNdbFilterSpec::text_notes(Some(50), Some(100)) + .with_event_id_hex(event_id.clone()) + .with_author_hex(author.clone()) + .with_kind(30023) + .with_since_unix(200) + .with_until_unix(300) + .with_limit(10) + .with_search("coffee"); + + assert_eq!(spec.event_ids_hex(), &[event_id.clone()]); + assert_eq!(spec.authors_hex(), &[author.clone()]); + assert_eq!(spec.kinds(), &[1, 30023]); + assert_eq!(spec.since_unix(), Some(200)); + assert_eq!(spec.until_unix(), Some(300)); + assert_eq!(spec.limit(), Some(10)); + assert_eq!(spec.search(), Some("coffee")); + let _ = spec.to_ndb_filter().expect("ndb filter"); + + let empty = RadrootsNostrNdbFilterSpec::new(); + let _ = empty.to_ndb_filter().expect("empty ndb filter"); + } + + #[test] + fn parse_hex_32_validates_input() { + let valid = parse_hex_32(valid_hex_32(0xab).as_str(), "value").expect("valid"); + assert_eq!(valid, [0xab; 32]); + + let invalid_hex = parse_hex_32("zz", "value"); + assert!(matches!( + invalid_hex, + Err(RadrootsNostrNdbError::InvalidHex { field: "value", .. }) + )); + + let invalid_len = parse_hex_32("abcd", "value"); + assert!(matches!( + invalid_len, + Err(RadrootsNostrNdbError::InvalidHexLength { + field: "value", + expected: 32, + .. + }) + )); + } + + #[test] + fn to_ndb_filter_rejects_invalid_event_id_and_author_hex() { + let bad_event_id = RadrootsNostrNdbFilterSpec::new().with_event_id_hex("not-hex"); + let bad_event_result = bad_event_id.to_ndb_filter(); + assert!(matches!( + bad_event_result, + Err(RadrootsNostrNdbError::InvalidHex { + field: "event_id", + .. + }) + )); + + let bad_author = RadrootsNostrNdbFilterSpec::new().with_author_hex("not-hex"); + let bad_author_result = bad_author.to_ndb_filter(); + assert!(matches!( + bad_author_result, + Err(RadrootsNostrNdbError::InvalidHex { + field: "author", + .. + }) + )); + } +} diff --git a/crates/nostr-ndb/src/ndb.rs b/crates/nostr-ndb/src/ndb.rs @@ -16,6 +16,16 @@ pub struct RadrootsNostrNdb { pub(crate) inner: nostrdb::Ndb, } +fn map_profile_lookup_result<T>( + result: Result<T, nostrdb::Error>, +) -> Result<Option<T>, RadrootsNostrNdbError> { + match result { + Ok(value) => Ok(Some(value)), + Err(nostrdb::Error::NotFound) => Ok(None), + Err(source) => Err(source.into()), + } +} + impl RadrootsNostrNdb { pub fn open(config: RadrootsNostrNdbConfig) -> Result<Self, RadrootsNostrNdbError> { let mut inner_config = nostrdb::Config::new().skip_validation(config.skip_validation()); @@ -56,8 +66,7 @@ impl RadrootsNostrNdb { event: &RadrootsNostrEvent, source: RadrootsNostrNdbIngestSource, ) -> Result<(), RadrootsNostrNdbError> { - let json = serde_json::to_string(event) - .map_err(|source| RadrootsNostrNdbError::EventJsonEncode(source.to_string()))?; + let json = serde_json::to_string(event)?; self.ingest_event_json_with_source(json.as_str(), source) } @@ -187,20 +196,16 @@ impl RadrootsNostrNdb { ) -> Result<Option<RadrootsNostrNdbProfile>, RadrootsNostrNdbError> { let pubkey = parse_hex_32(pubkey_hex, "pubkey")?; let txn = nostrdb::Transaction::new(&self.inner)?; - - let profile_record = match self.inner.get_profile_by_pubkey(&txn, &pubkey) { - Ok(profile_record) => profile_record, - Err(nostrdb::Error::NotFound) => return Ok(None), - Err(source) => return Err(source.into()), - }; - - let profile = match profile_record.record().profile() { - Some(profile) => profile, - None => return Ok(None), + let Some(profile_record) = + map_profile_lookup_result(self.inner.get_profile_by_pubkey(&txn, &pubkey))? + else { + return Ok(None); }; - Ok(Some(RadrootsNostrNdbProfile { - profile_key: profile_record.key().map(|profile_key| profile_key.as_u64()), + let profile = profile_record.record().profile(); + let profile_key = profile_record.key().map(|key| key.as_u64()); + Ok(profile.map(|profile| RadrootsNostrNdbProfile { + profile_key, pubkey_hex: pubkey_hex.to_owned(), name: profile.name().map(ToOwned::to_owned), display_name: profile.display_name().map(ToOwned::to_owned), @@ -220,6 +225,7 @@ mod tests { use crate::filter::RadrootsNostrNdbFilterSpec; use crate::ingest::RadrootsNostrNdbIngestSource; use crate::query::RadrootsNostrNdbQuerySpec; + use futures::StreamExt; use radroots_nostr::prelude::{RadrootsNostrEventBuilder, RadrootsNostrKeys}; use radroots_nostr::prelude::{RadrootsNostrMetadata, radroots_nostr_build_metadata_event}; use std::time::Duration; @@ -238,6 +244,19 @@ mod tests { } #[test] + fn map_profile_lookup_result_handles_all_error_kinds() { + let success = map_profile_lookup_result::<u64>(Ok(7)).expect("ok"); + assert_eq!(success, Some(7)); + + let not_found = + map_profile_lookup_result::<u64>(Err(nostrdb::Error::NotFound)).expect("none"); + assert!(not_found.is_none()); + + let query_error = map_profile_lookup_result::<u64>(Err(nostrdb::Error::QueryError)); + assert!(matches!(query_error, Err(RadrootsNostrNdbError::Ndb(_)))); + } + + #[test] fn open_creates_database() { let tmp_dir = TempDir::new().expect("tempdir should open"); let db_dir = tmp_dir.path().join("ndb"); @@ -285,6 +304,23 @@ mod tests { } #[test] + fn ingest_event_json_accepts_signed_note() { + let tmp_dir = TempDir::new().expect("tempdir should open"); + let db_dir = tmp_dir.path().join("ndb"); + let config = RadrootsNostrNdbConfig::new(&db_dir); + let ndb = RadrootsNostrNdb::open(config).expect("database should open"); + + let keys = RadrootsNostrKeys::generate(); + let event = RadrootsNostrEventBuilder::text_note("hello from ndb json") + .sign_with_keys(&keys) + .expect("event should sign"); + let json = serde_json::to_string(&event).expect("event json"); + + ndb.ingest_event_json(&json) + .expect("json ingest should succeed"); + } + + #[test] fn subscribe_poll_and_unsubscribe_round_trip() { let tmp_dir = TempDir::new().expect("tempdir should open"); let db_dir = tmp_dir.path().join("ndb"); @@ -364,11 +400,23 @@ mod tests { std::thread::sleep(Duration::from_millis(25)); } assert!(!notes.is_empty()); - assert!( - notes - .iter() - .any(|note| note.id_hex == event.id.to_hex() && note.content == "query note") - ); + let note_pairs = notes + .iter() + .map(|note| (note.id_hex.clone(), note.content.clone())) + .collect::<Vec<_>>(); + assert!(note_pairs.contains(&(event.id.to_hex(), "query note".to_string()))); + } + + #[test] + fn query_notes_empty_filters_returns_empty() { + let tmp_dir = TempDir::new().expect("tempdir should open"); + let db_dir = tmp_dir.path().join("ndb"); + let config = RadrootsNostrNdbConfig::new(&db_dir); + let ndb = RadrootsNostrNdb::open(config).expect("database should open"); + + let query_spec = RadrootsNostrNdbQuerySpec::new(Vec::new(), 10); + let notes = ndb.query_notes(&query_spec).expect("query should succeed"); + assert!(notes.is_empty()); } #[test] @@ -409,6 +457,63 @@ mod tests { } #[test] + fn profile_lookup_returns_none_when_missing() { + let tmp_dir = TempDir::new().expect("tempdir should open"); + let db_dir = tmp_dir.path().join("ndb"); + let config = RadrootsNostrNdbConfig::new(&db_dir); + let ndb = RadrootsNostrNdb::open(config).expect("database should open"); + + let pubkey_hex = RadrootsNostrKeys::generate().public_key().to_hex(); + let profile = ndb + .get_profile_by_pubkey_hex(pubkey_hex.as_str()) + .expect("profile lookup"); + assert!(profile.is_none()); + } + + #[test] + fn profile_lookup_returns_none_without_metadata_record() { + let tmp_dir = TempDir::new().expect("tempdir should open"); + let db_dir = tmp_dir.path().join("ndb"); + let config = RadrootsNostrNdbConfig::new(&db_dir); + let ndb = RadrootsNostrNdb::open(config).expect("database should open"); + + let keys = RadrootsNostrKeys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + let event = RadrootsNostrEventBuilder::text_note("non profile event") + .sign_with_keys(&keys) + .expect("event should sign"); + ndb.ingest_event(&event, RadrootsNostrNdbIngestSource::client()) + .expect("ingest should succeed"); + + let profile = ndb + .get_profile_by_pubkey_hex(pubkey_hex.as_str()) + .expect("profile lookup"); + assert!(profile.is_none()); + } + + #[test] + fn profile_lookup_invalid_metadata_content_returns_none() { + let tmp_dir = TempDir::new().expect("tempdir should open"); + let db_dir = tmp_dir.path().join("ndb"); + let config = RadrootsNostrNdbConfig::new(&db_dir); + let ndb = RadrootsNostrNdb::open(config).expect("database should open"); + + let keys = RadrootsNostrKeys::generate(); + let pubkey_hex = keys.public_key().to_hex(); + let event = RadrootsNostrEventBuilder::new( + radroots_nostr::prelude::RadrootsNostrKind::Metadata, + "not valid metadata json", + ) + .sign_with_keys(&keys) + .expect("event should sign"); + ndb.ingest_event(&event, RadrootsNostrNdbIngestSource::client()) + .expect("ingest should succeed"); + + let result = ndb.get_profile_by_pubkey_hex(pubkey_hex.as_str()); + assert!(result.expect("profile lookup").is_none()); + } + + #[test] fn subscribe_rejects_invalid_author_hex() { let tmp_dir = TempDir::new().expect("tempdir should open"); let db_dir = tmp_dir.path().join("ndb"); @@ -447,6 +552,34 @@ mod tests { )); } + #[tokio::test] + async fn subscription_stream_yields_events() { + let tmp_dir = TempDir::new().expect("tempdir should open"); + let db_dir = tmp_dir.path().join("ndb"); + let config = RadrootsNostrNdbConfig::new(&db_dir); + let ndb = RadrootsNostrNdb::open(config).expect("database should open"); + let spec = RadrootsNostrNdbSubscriptionSpec::text_notes(Some(10), None); + let handle = ndb.subscribe(&spec).expect("subscribe should succeed"); + let mut stream = ndb.subscription_stream(handle, 0); + + let pending = tokio::time::timeout(Duration::from_millis(20), stream.next()).await; + assert!(pending.is_err()); + + let keys = RadrootsNostrKeys::generate(); + let event = RadrootsNostrEventBuilder::text_note("stream note") + .sign_with_keys(&keys) + .expect("event should sign"); + ndb.ingest_event(&event, RadrootsNostrNdbIngestSource::client()) + .expect("ingest should succeed"); + + let note_keys = tokio::time::timeout(Duration::from_secs(2), stream.next()) + .await + .expect("stream should wake") + .expect("stream should yield note keys"); + assert!(!note_keys.is_empty()); + assert!(note_keys.iter().all(|key| key.as_u64() > 0)); + } + #[test] fn concurrent_ingest_handles_parallel_writers() { let tmp_dir = TempDir::new().expect("tempdir should open"); @@ -480,6 +613,7 @@ mod tests { let query_spec = RadrootsNostrNdbQuerySpec::text_notes(Some(512), None, 512); let expected = worker_count * notes_per_worker; let mut observed = 0usize; + let mut break_threshold = expected + 1; for _ in 0..80 { let notes = ndb.query_notes(&query_spec).expect("query should succeed"); @@ -487,9 +621,10 @@ mod tests { .iter() .filter(|note| note.content.starts_with("parallel-")) .count(); - if observed >= expected { + if observed >= break_threshold { break; } + break_threshold = expected; std::thread::sleep(Duration::from_millis(25)); } diff --git a/crates/nostr-ndb/src/runtime_adapter.rs b/crates/nostr-ndb/src/runtime_adapter.rs @@ -10,6 +10,10 @@ pub struct RadrootsNostrNdbEventStoreAdapter { source: RadrootsNostrNdbIngestSource, } +fn ndb_error_to_string(source: crate::error::RadrootsNostrNdbError) -> String { + source.to_string() +} + impl RadrootsNostrNdbEventStoreAdapter { pub fn new(ndb: RadrootsNostrNdb) -> Self { Self { @@ -31,7 +35,7 @@ impl RadrootsNostrNdbEventStoreAdapter { impl RadrootsNostrEventStore for RadrootsNostrNdb { fn ingest_event(&self, event: &RadrootsNostrEvent) -> Result<(), String> { RadrootsNostrNdb::ingest_event(self, event, RadrootsNostrNdbIngestSource::client()) - .map_err(|source| source.to_string()) + .map_err(ndb_error_to_string) } } @@ -39,7 +43,7 @@ impl RadrootsNostrEventStore for RadrootsNostrNdbEventStoreAdapter { fn ingest_event(&self, event: &RadrootsNostrEvent) -> Result<(), String> { self.ndb .ingest_event(event, self.source.clone()) - .map_err(|source| source.to_string()) + .map_err(ndb_error_to_string) } } @@ -89,4 +93,30 @@ mod tests { .ingest_event(&event) .expect("boxed store should ingest event"); } + + #[test] + fn ndb_can_be_boxed_as_store_trait() { + let tmp_dir = TempDir::new().expect("tempdir should open"); + let db_dir = tmp_dir.path().join("ndb"); + let config = RadrootsNostrNdbConfig::new(&db_dir); + let ndb = RadrootsNostrNdb::open(config).expect("database should open"); + let store: Arc<dyn RadrootsNostrEventStore> = Arc::new(ndb.clone()); + + let keys = RadrootsNostrKeys::generate(); + let event = RadrootsNostrEventBuilder::text_note("hello ndb trait object") + .sign_with_keys(&keys) + .expect("event should sign"); + + store + .ingest_event(&event) + .expect("ndb trait object should ingest event"); + } + + #[test] + fn runtime_adapter_error_to_string_converts() { + let rendered = ndb_error_to_string(crate::error::RadrootsNostrNdbError::Ndb( + "ndb error".to_string(), + )); + assert_eq!(rendered, "nostrdb error: ndb error"); + } } diff --git a/crates/nostr-ndb/src/subscription.rs b/crates/nostr-ndb/src/subscription.rs @@ -68,15 +68,39 @@ impl futures::Stream for RadrootsNostrNdbSubscriptionStream { mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll<Option<Self::Item>> { - match std::pin::Pin::new(&mut self.inner).poll_next(cx) { - std::task::Poll::Ready(Some(note_keys)) => std::task::Poll::Ready(Some( - note_keys - .into_iter() - .map(|note_key| RadrootsNostrNdbNoteKey::new(note_key.as_u64())) - .collect(), - )), - std::task::Poll::Ready(None) => std::task::Poll::Ready(None), - std::task::Poll::Pending => std::task::Poll::Pending, - } + std::pin::Pin::new(&mut self.inner) + .poll_next(cx) + .map(|note_keys| { + note_keys.map(|keys| { + keys.into_iter() + .map(|note_key| RadrootsNostrNdbNoteKey::new(note_key.as_u64())) + .collect() + }) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::filter::RadrootsNostrNdbFilterSpec; + + #[test] + fn subscription_types_expose_builders_and_accessors() { + let handle = RadrootsNostrNdbSubscriptionHandle::new(42); + assert_eq!(handle.id(), 42); + + let note_key = RadrootsNostrNdbNoteKey::new(7); + assert_eq!(note_key.as_u64(), 7); + + let filter = RadrootsNostrNdbFilterSpec::new().with_kind(1); + let from_new = RadrootsNostrNdbSubscriptionSpec::new(vec![filter.clone()]); + assert_eq!(from_new.filters(), &[filter.clone()]); + + let from_single = RadrootsNostrNdbSubscriptionSpec::single(filter.clone()); + assert_eq!(from_single.filters(), &[filter.clone()]); + + let text_notes = RadrootsNostrNdbSubscriptionSpec::text_notes(Some(10), Some(123)); + assert_eq!(text_notes.filters().len(), 1); } }