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:
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);
}
}