lib

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

commit 7f26ce966c39fbd816ed59dbb0b762b7e15c62bf
parent 91dffaf359ab207df6344a6655636d40b24c0420
Author: triesap <tyson@radroots.org>
Date:   Thu,  5 Mar 2026 22:22:11 +0000

nostr-ndb: cover ndb error paths

- add test hooks to exercise ndb operation failures without altering runtime behavior
- expand ndb tests for open, ingest, subscribe, query, and profile error flows
- replace enum matches with string-based error assertions
- run cargo check -p radroots-nostr-ndb, cargo test -p radroots-nostr-ndb, cargo run -q -p xtask -- sdk coverage run-crate --crate radroots-nostr-ndb --out target/coverage/radroots_nostr_ndb --test-threads 1, cargo run -q -p xtask -- sdk coverage report --scope radroots-nostr-ndb --summary target/coverage/radroots_nostr_ndb/coverage-summary.json --lcov target/coverage/radroots_nostr_ndb/coverage-lcov.info --out target/coverage/radroots_nostr_ndb/gate-report.json --fail-under-exec-lines 100 --fail-under-functions 100 --fail-under-regions 100 --fail-under-branches 100 --require-branches

Diffstat:
Mcrates/nostr-ndb/src/error.rs | 9++++-----
Mcrates/nostr-ndb/src/ndb.rs | 391++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
2 files changed, 354 insertions(+), 46 deletions(-)

diff --git a/crates/nostr-ndb/src/error.rs b/crates/nostr-ndb/src/error.rs @@ -42,16 +42,15 @@ mod tests { #[test] fn converts_nostrdb_error() { let converted: RadrootsNostrNdbError = nostrdb::Error::NotFound.into(); - assert!(matches!(converted, RadrootsNostrNdbError::Ndb(_))); + assert!(converted.to_string().starts_with("nostrdb error:")); } #[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(_) - )); + assert!(converted + .to_string() + .starts_with("event json encode failed:")); } } diff --git a/crates/nostr-ndb/src/ndb.rs b/crates/nostr-ndb/src/ndb.rs @@ -16,6 +16,25 @@ pub struct RadrootsNostrNdb { pub(crate) inner: nostrdb::Ndb, } +#[cfg(test)] +mod test_hooks { + use std::sync::atomic::{AtomicBool, Ordering}; + + pub static FORCE_EVENT_JSON_ERROR: AtomicBool = AtomicBool::new(false); + pub static FORCE_PROCESS_EVENT_ERROR: AtomicBool = AtomicBool::new(false); + pub static FORCE_SUBSCRIBE_ERROR: AtomicBool = AtomicBool::new(false); + pub static FORCE_UNSUBSCRIBE_ERROR: AtomicBool = AtomicBool::new(false); + pub static FORCE_WAIT_ERROR: AtomicBool = AtomicBool::new(false); + pub static FORCE_TRANSACTION_ERROR: AtomicBool = AtomicBool::new(false); + pub static FORCE_QUERY_ERROR: AtomicBool = AtomicBool::new(false); + pub static FORCE_NOTE_JSON_ERROR: AtomicBool = AtomicBool::new(false); + pub static FORCE_PROFILE_QUERY_ERROR: AtomicBool = AtomicBool::new(false); + + pub fn take(flag: &AtomicBool) -> bool { + flag.swap(false, Ordering::SeqCst) + } +} + fn map_profile_lookup_result<T>( result: Result<T, nostrdb::Error>, ) -> Result<Option<T>, RadrootsNostrNdbError> { @@ -27,6 +46,114 @@ fn map_profile_lookup_result<T>( } impl RadrootsNostrNdb { + fn serialize_event(event: &RadrootsNostrEvent) -> Result<String, RadrootsNostrNdbError> { + #[cfg(test)] + if test_hooks::take(&test_hooks::FORCE_EVENT_JSON_ERROR) { + return Err(RadrootsNostrNdbError::EventJsonEncode( + "forced event json error".into(), + )); + } + serde_json::to_string(event).map_err(Into::into) + } + + fn process_event_with_inner( + &self, + json: &str, + metadata: nostrdb::IngestMetadata, + ) -> Result<(), RadrootsNostrNdbError> { + #[cfg(test)] + if test_hooks::take(&test_hooks::FORCE_PROCESS_EVENT_ERROR) { + return Err(RadrootsNostrNdbError::Ndb( + "forced process event error".into(), + )); + } + self.inner + .process_event_with(json, metadata) + .map_err(Into::into) + } + + fn subscribe_inner( + &self, + filters: &[nostrdb::Filter], + ) -> Result<nostrdb::Subscription, RadrootsNostrNdbError> { + #[cfg(test)] + if test_hooks::take(&test_hooks::FORCE_SUBSCRIBE_ERROR) { + return Err(RadrootsNostrNdbError::Ndb("forced subscribe error".into())); + } + self.inner.subscribe(filters).map_err(Into::into) + } + + fn unsubscribe_inner( + &self, + subscription: nostrdb::Subscription, + ) -> Result<(), RadrootsNostrNdbError> { + #[cfg(test)] + if test_hooks::take(&test_hooks::FORCE_UNSUBSCRIBE_ERROR) { + return Err(RadrootsNostrNdbError::Ndb("forced unsubscribe error".into())); + } + let mut inner = self.inner.clone(); + inner.unsubscribe(subscription).map_err(Into::into) + } + + #[cfg(feature = "rt")] + async fn wait_for_notes_inner( + &self, + subscription: nostrdb::Subscription, + max_notes: u32, + ) -> Result<Vec<nostrdb::NoteKey>, RadrootsNostrNdbError> { + #[cfg(test)] + if test_hooks::take(&test_hooks::FORCE_WAIT_ERROR) { + return Err(RadrootsNostrNdbError::Ndb("forced wait error".into())); + } + self.inner + .wait_for_notes(subscription, max_notes) + .await + .map_err(Into::into) + } + + fn open_txn(&self) -> Result<nostrdb::Transaction, RadrootsNostrNdbError> { + #[cfg(test)] + if test_hooks::take(&test_hooks::FORCE_TRANSACTION_ERROR) { + return Err(RadrootsNostrNdbError::Ndb("forced transaction error".into())); + } + nostrdb::Transaction::new(&self.inner).map_err(Into::into) + } + + fn query_inner<'a>( + &self, + txn: &'a nostrdb::Transaction, + filters: &[nostrdb::Filter], + max_results: i32, + ) -> Result<Vec<nostrdb::QueryResult<'a>>, RadrootsNostrNdbError> { + #[cfg(test)] + if test_hooks::take(&test_hooks::FORCE_QUERY_ERROR) { + return Err(RadrootsNostrNdbError::Ndb("forced query error".into())); + } + self.inner + .query(txn, filters, max_results) + .map_err(Into::into) + } + + fn note_json_value(note: &nostrdb::Note) -> Result<String, RadrootsNostrNdbError> { + #[cfg(test)] + if test_hooks::take(&test_hooks::FORCE_NOTE_JSON_ERROR) { + return Err(RadrootsNostrNdbError::Ndb("forced note json error".into())); + } + note.json().map_err(Into::into) + } + + fn get_profile_record<'a>( + &self, + txn: &'a nostrdb::Transaction, + pubkey: &[u8; 32], + ) -> Result<Option<nostrdb::ProfileRecord<'a>>, RadrootsNostrNdbError> { + #[cfg(test)] + if test_hooks::take(&test_hooks::FORCE_PROFILE_QUERY_ERROR) { + return map_profile_lookup_result(Err(nostrdb::Error::QueryError)); + } + map_profile_lookup_result(self.inner.get_profile_by_pubkey(txn, pubkey)) + } + pub fn open(config: RadrootsNostrNdbConfig) -> Result<Self, RadrootsNostrNdbError> { let mut inner_config = nostrdb::Config::new().skip_validation(config.skip_validation()); if let Some(mapsize_bytes) = config.mapsize_bytes() { @@ -53,7 +180,7 @@ impl RadrootsNostrNdb { source: RadrootsNostrNdbIngestSource, ) -> Result<(), RadrootsNostrNdbError> { let metadata = source.to_ndb_metadata(); - self.inner.process_event_with(json, metadata)?; + self.process_event_with_inner(json, metadata)?; Ok(()) } @@ -66,7 +193,7 @@ impl RadrootsNostrNdb { event: &RadrootsNostrEvent, source: RadrootsNostrNdbIngestSource, ) -> Result<(), RadrootsNostrNdbError> { - let json = serde_json::to_string(event)?; + let json = Self::serialize_event(event)?; self.ingest_event_json_with_source(json.as_str(), source) } @@ -100,7 +227,7 @@ impl RadrootsNostrNdb { .iter() .map(|filter_spec| filter_spec.to_ndb_filter()) .collect::<Result<Vec<_>, _>>()?; - let subscription = self.inner.subscribe(filters.as_slice())?; + let subscription = self.subscribe_inner(filters.as_slice())?; Ok(RadrootsNostrNdbSubscriptionHandle::new(subscription.id())) } @@ -108,8 +235,8 @@ impl RadrootsNostrNdb { &self, handle: RadrootsNostrNdbSubscriptionHandle, ) -> Result<(), RadrootsNostrNdbError> { - let mut inner = self.inner.clone(); - inner.unsubscribe(nostrdb::Subscription::new(handle.id()))?; + let subscription = nostrdb::Subscription::new(handle.id()); + self.unsubscribe_inner(subscription)?; Ok(()) } @@ -132,8 +259,7 @@ impl RadrootsNostrNdb { max_notes: u32, ) -> Result<Vec<RadrootsNostrNdbNoteKey>, RadrootsNostrNdbError> { let note_keys = self - .inner - .wait_for_notes(nostrdb::Subscription::new(handle.id()), max_notes) + .wait_for_notes_inner(nostrdb::Subscription::new(handle.id()), max_notes) .await?; Ok(note_keys .into_iter() @@ -166,16 +292,14 @@ impl RadrootsNostrNdb { .iter() .map(|filter_spec| filter_spec.to_ndb_filter()) .collect::<Result<Vec<_>, _>>()?; - let txn = nostrdb::Transaction::new(&self.inner)?; - let query_results = - self.inner - .query(&txn, filters.as_slice(), spec.max_results() as i32)?; + let txn = self.open_txn()?; + let query_results = self.query_inner(&txn, filters.as_slice(), spec.max_results() as i32)?; query_results .into_iter() .map(|query_result| { let note = query_result.note; - let json = note.json()?; + let json = Self::note_json_value(&note)?; Ok(RadrootsNostrNdbNote { note_key: query_result.note_key.as_u64(), id_hex: hex::encode(note.id()), @@ -186,8 +310,7 @@ impl RadrootsNostrNdb { json, }) }) - .collect::<Result<Vec<_>, nostrdb::Error>>() - .map_err(Into::into) + .collect::<Result<Vec<_>, RadrootsNostrNdbError>>() } pub fn get_profile_by_pubkey_hex( @@ -195,10 +318,8 @@ impl RadrootsNostrNdb { pubkey_hex: &str, ) -> Result<Option<RadrootsNostrNdbProfile>, RadrootsNostrNdbError> { let pubkey = parse_hex_32(pubkey_hex, "pubkey")?; - let txn = nostrdb::Transaction::new(&self.inner)?; - let Some(profile_record) = - map_profile_lookup_result(self.inner.get_profile_by_pubkey(&txn, &pubkey))? - else { + let txn = self.open_txn()?; + let Some(profile_record) = self.get_profile_record(&txn, &pubkey)? else { return Ok(None); }; @@ -228,6 +349,7 @@ mod tests { use futures::StreamExt; use radroots_nostr::prelude::{RadrootsNostrEventBuilder, RadrootsNostrKeys}; use radroots_nostr::prelude::{RadrootsNostrMetadata, radroots_nostr_build_metadata_event}; + use std::sync::atomic::Ordering; use std::time::Duration; use tempfile::TempDir; @@ -252,8 +374,9 @@ mod tests { 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(_)))); + let query_error = map_profile_lookup_result::<u64>(Err(nostrdb::Error::QueryError)) + .expect_err("query error"); + assert!(query_error.to_string().starts_with("nostrdb error:")); } #[test] @@ -269,6 +392,27 @@ mod tests { assert!(db_dir.exists()); } + #[cfg(unix)] + #[test] + fn open_rejects_non_utf8_path() { + use std::os::unix::ffi::OsStrExt; + + let path = std::path::PathBuf::from(std::ffi::OsStr::from_bytes(b"ndb-\xFF")); + let config = RadrootsNostrNdbConfig::new(&path); + let err = RadrootsNostrNdb::open(config).expect_err("non utf8 path"); + assert!(err.to_string().contains("utf-8")); + } + + #[test] + fn open_reports_ndb_error_for_file_path() { + let tmp_dir = TempDir::new().expect("tempdir should open"); + let db_dir = tmp_dir.path().join("ndb"); + std::fs::write(&db_dir, "not a directory").expect("write db file"); + let config = RadrootsNostrNdbConfig::new(&db_dir); + let err = RadrootsNostrNdb::open(config).expect_err("file path should fail"); + assert!(err.to_string().starts_with("nostrdb error:")); + } + #[test] fn ingest_source_builders_track_origin() { assert_eq!( @@ -321,6 +465,41 @@ mod tests { } #[test] + fn ingest_event_json_rejects_invalid_json() { + 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"); + + test_hooks::FORCE_PROCESS_EVENT_ERROR.store(true, Ordering::SeqCst); + let err = ndb + .ingest_event_json_with_source("not json", RadrootsNostrNdbIngestSource::client()) + .expect_err("process event error"); + assert!(err.to_string().starts_with("nostrdb error:")); + } + + #[test] + fn ingest_event_reports_event_json_error() { + 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("forced json error") + .sign_with_keys(&keys) + .expect("event should sign"); + test_hooks::FORCE_EVENT_JSON_ERROR.store(true, Ordering::SeqCst); + + let err = ndb + .ingest_event(&event, RadrootsNostrNdbIngestSource::client()) + .expect_err("forced json error"); + assert!(err + .to_string() + .starts_with("event json encode failed:")); + } + + #[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"); @@ -353,6 +532,33 @@ mod tests { ndb.unsubscribe(handle).expect("unsubscribe should succeed"); } + #[test] + fn subscribe_reports_ndb_error() { + 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); + test_hooks::FORCE_SUBSCRIBE_ERROR.store(true, Ordering::SeqCst); + + let err = ndb.subscribe(&spec).expect_err("forced subscribe error"); + assert!(err.to_string().starts_with("nostrdb error:")); + } + + #[test] + fn unsubscribe_reports_ndb_error() { + 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"); + test_hooks::FORCE_UNSUBSCRIBE_ERROR.store(true, Ordering::SeqCst); + + let err = ndb.unsubscribe(handle).expect_err("forced unsubscribe error"); + assert!(err.to_string().starts_with("nostrdb error:")); + } + #[tokio::test] async fn wait_for_note_keys_yields_results() { let tmp_dir = TempDir::new().expect("tempdir should open"); @@ -376,6 +582,23 @@ mod tests { assert!(!notes.is_empty()); } + #[tokio::test] + async fn wait_for_note_keys_reports_ndb_error() { + 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"); + test_hooks::FORCE_WAIT_ERROR.store(true, Ordering::SeqCst); + + let err = ndb + .wait_for_note_keys(handle, 1) + .await + .expect_err("forced wait error"); + assert!(err.to_string().starts_with("nostrdb error:")); + } + #[test] fn query_notes_returns_ingested_results() { let tmp_dir = TempDir::new().expect("tempdir should open"); @@ -420,6 +643,75 @@ mod tests { } #[test] + fn query_notes_rejects_invalid_filters() { + 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 = RadrootsNostrNdbQuerySpec::single( + RadrootsNostrNdbFilterSpec::new().with_author_hex("not-hex"), + 10, + ); + let err = ndb.query_notes(&spec).expect_err("invalid filter"); + assert!(err.to_string().contains("invalid hex")); + } + + #[test] + fn query_notes_reports_transaction_error() { + 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 = RadrootsNostrNdbQuerySpec::text_notes(Some(10), None, 10); + test_hooks::FORCE_TRANSACTION_ERROR.store(true, Ordering::SeqCst); + + let err = ndb.query_notes(&spec).expect_err("forced transaction error"); + assert!(err.to_string().starts_with("nostrdb error:")); + } + + #[test] + fn query_notes_reports_query_error() { + 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 = RadrootsNostrNdbQuerySpec::text_notes(Some(10), None, 10); + test_hooks::FORCE_QUERY_ERROR.store(true, Ordering::SeqCst); + + let err = ndb.query_notes(&spec).expect_err("forced query error"); + assert!(err.to_string().starts_with("nostrdb error:")); + } + + #[test] + fn query_notes_reports_note_json_error() { + 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("note json error") + .sign_with_keys(&keys) + .expect("event should sign"); + ndb.ingest_event(&event, RadrootsNostrNdbIngestSource::client()) + .expect("ingest should succeed"); + + let query_spec = RadrootsNostrNdbQuerySpec::text_notes(Some(50), None, 50); + for _ in 0..40 { + let notes = ndb.query_notes(&query_spec).expect("query should succeed"); + if !notes.is_empty() { + break; + } + std::thread::sleep(Duration::from_millis(25)); + } + test_hooks::FORCE_NOTE_JSON_ERROR.store(true, Ordering::SeqCst); + + let err = ndb.query_notes(&query_spec).expect_err("forced note json error"); + assert!(err.to_string().starts_with("nostrdb error:")); + } + + #[test] fn profile_lookup_returns_metadata_fields() { let tmp_dir = TempDir::new().expect("tempdir should open"); let db_dir = tmp_dir.path().join("ndb"); @@ -471,6 +763,36 @@ mod tests { } #[test] + fn profile_lookup_reports_query_error() { + 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(); + test_hooks::FORCE_PROFILE_QUERY_ERROR.store(true, Ordering::SeqCst); + + let err = ndb + .get_profile_by_pubkey_hex(pubkey_hex.as_str()) + .expect_err("forced profile query error"); + assert!(err.to_string().starts_with("nostrdb error:")); + } + + #[test] + fn profile_lookup_reports_transaction_error() { + 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(); + test_hooks::FORCE_TRANSACTION_ERROR.store(true, Ordering::SeqCst); + + let err = ndb + .get_profile_by_pubkey_hex(pubkey_hex.as_str()) + .expect_err("forced transaction error"); + assert!(err.to_string().starts_with("nostrdb error:")); + } + + #[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"); @@ -524,13 +846,7 @@ mod tests { RadrootsNostrNdbFilterSpec::new().with_author_hex("not-hex"), ); let err = ndb.subscribe(&spec).expect_err("subscribe should fail"); - assert!(matches!( - err, - RadrootsNostrNdbError::InvalidHex { - field: "author", - .. - } - )); + assert!(err.to_string().contains("invalid hex for author")); } #[test] @@ -543,13 +859,9 @@ mod tests { let err = ndb .get_profile_by_pubkey_hex("abcd") .expect_err("lookup should fail"); - assert!(matches!( - err, - RadrootsNostrNdbError::InvalidHexLength { - field: "pubkey", - .. - } - )); + assert!(err + .to_string() + .contains("invalid hex length for pubkey")); } #[tokio::test] @@ -643,13 +955,10 @@ mod tests { let ndb = RadrootsNostrNdb::open(config).expect("database should open"); let result = ndb.add_giftwrap_secret_key_hex("abcd"); - assert!(matches!( - result, - Err(RadrootsNostrNdbError::InvalidHexLength { - field: "secret_key", - .. - }) - )); + let err = result.expect_err("invalid giftwrap key"); + assert!(err + .to_string() + .contains("invalid hex length for secret_key")); } #[cfg(feature = "giftwrap")]