radrootsd

JSON-RPC bridge for Radroots event publishing
git clone https://radroots.dev/git/radrootsd.git
Log | Files | Refs | README | LICENSE

commit 8dbd0e0c004dd79dd833b39cb14e4e028f9bee09
parent 8b95d98721d1312d7f4969cfb8f653e4781465e7
Author: triesap <triesap@radroots.dev>
Date:   Sat,  3 Jan 2026 17:14:58 +0000

profile: extract row builder and add coverage

- Factor latest-per-author selection into build_profile_rows helper
- Preserve author order while emitting empty rows for missing metadata
- Reuse stored+fetched event stream when constructing response rows
- Add unit tests for latest selection and missing-row ordering behavior

Diffstat:
Msrc/api/jsonrpc/methods/events/profile/list.rs | 168++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
1 file changed, 124 insertions(+), 44 deletions(-)

diff --git a/src/api/jsonrpc/methods/events/profile/list.rs b/src/api/jsonrpc/methods/events/profile/list.rs @@ -38,6 +38,57 @@ struct ProfileListResponse { profiles: Vec<ProfileListRow>, } +fn build_profile_rows<I>( + authors: Vec<RadrootsNostrPublicKey>, + events: I, +) -> Result<Vec<ProfileListRow>, RpcError> +where + I: IntoIterator<Item = RadrootsNostrEvent>, +{ + let mut latest_by_author: HashMap<RadrootsNostrPublicKey, RadrootsNostrEvent> = HashMap::new(); + for event in events { + match latest_by_author.get(&event.pubkey) { + Some(cur) if event.created_at <= cur.created_at => {} + _ => { + latest_by_author.insert(event.pubkey, event); + } + } + } + + authors + .into_iter() + .map(|author| { + let npub = radroots_nostr_npub_string(&author) + .ok_or_else(|| RpcError::Other("bech32 encode failed".into()))?; + let row = match latest_by_author.get(&author) { + Some(event) => { + let parsed: Option<JsonValue> = serde_json::from_str(&event.content).ok(); + let profile: Option<RadrootsProfile> = serde_json::from_str(&event.content).ok(); + ProfileListRow { + author_hex: author.to_string(), + author_npub: npub, + event_id: Some(event.id.to_string()), + created_at: Some(event.created_at.as_secs()), + content: Some(event.content.clone()), + metadata_json: parsed, + radroots_profile: profile, + } + } + None => ProfileListRow { + author_hex: author.to_string(), + author_npub: npub, + event_id: None, + created_at: None, + content: None, + metadata_json: None, + radroots_profile: None, + }, + }; + Ok(row) + }) + .collect::<Result<Vec<_>, RpcError>>() +} + pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { registry.track("events.profile.list"); m.register_async_method("events.profile.list", |params, ctx, _| async move { @@ -67,8 +118,6 @@ pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Res .limit(limit_or(limit)); filter = apply_time_bounds(filter, since, until); - let mut latest_by_author: HashMap<RadrootsNostrPublicKey, RadrootsNostrEvent> = - HashMap::new(); let stored = ctx .state .client @@ -83,51 +132,82 @@ pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Res .await .map_err(|e| RpcError::Other(format!("metadata fetch failed: {e}")))?; - for event in stored.into_iter().chain(fetched.into_iter()) { - match latest_by_author.get(&event.pubkey) { - Some(cur) if event.created_at <= cur.created_at => {} - _ => { - latest_by_author.insert(event.pubkey, event); - } - } - } - - let profiles = authors - .into_iter() - .map(|author| { - let npub = radroots_nostr_npub_string(&author) - .ok_or_else(|| RpcError::Other("bech32 encode failed".into()))?; - let row = match latest_by_author.get(&author) { - Some(event) => { - let parsed: Option<JsonValue> = serde_json::from_str(&event.content).ok(); - let profile: Option<RadrootsProfile> = - serde_json::from_str(&event.content).ok(); - ProfileListRow { - author_hex: author.to_string(), - author_npub: npub, - event_id: Some(event.id.to_string()), - created_at: Some(event.created_at.as_secs()), - content: Some(event.content.clone()), - metadata_json: parsed, - radroots_profile: profile, - } - } - None => ProfileListRow { - author_hex: author.to_string(), - author_npub: npub, - event_id: None, - created_at: None, - content: None, - metadata_json: None, - radroots_profile: None, - }, - }; - Ok(row) - }) - .collect::<Result<Vec<_>, RpcError>>()?; + let profiles = build_profile_rows(authors, stored.into_iter().chain(fetched.into_iter()))?; Ok::<ProfileListResponse, RpcError>(ProfileListResponse { profiles }) })?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::build_profile_rows; + use radroots_nostr::prelude::{RadrootsNostrEvent, RadrootsNostrPublicKey}; + use serde_json::json; + + fn parse_pubkey(hex: &str) -> RadrootsNostrPublicKey { + RadrootsNostrPublicKey::from_hex(hex).expect("pubkey") + } + + fn event_with_profile( + pubkey: &RadrootsNostrPublicKey, + created_at: u64, + name: &str, + id: &str, + ) -> RadrootsNostrEvent { + let content = serde_json::to_string(&json!({ "name": name })).expect("content"); + let sig = format!("{:0128x}", 2); + let event_json = json!({ + "id": id, + "pubkey": pubkey.to_string(), + "created_at": created_at, + "kind": 0, + "tags": [], + "content": content, + "sig": sig, + }); + serde_json::from_value(event_json).expect("event") + } + + #[test] + fn profile_list_picks_latest_per_author() { + let author = parse_pubkey( + "1bdebe7b23fccb167fc8843280b789839dfa296ae9fd86cc9769b4813d76d8a4", + ); + let old_id = format!("{:064x}", 1); + let new_id = format!("{:064x}", 2); + let older = event_with_profile(&author, 100, "old", &old_id); + let newer = event_with_profile(&author, 200, "new", &new_id); + + let profiles = build_profile_rows(vec![author], vec![older, newer]).expect("profiles"); + + assert_eq!(profiles.len(), 1); + let row = &profiles[0]; + assert_eq!(row.created_at, Some(200)); + assert_eq!(row.event_id.as_deref(), Some(new_id.as_str())); + assert_eq!(row.radroots_profile.as_ref().unwrap().name, "new"); + assert_eq!(row.metadata_json.as_ref().unwrap()["name"], "new"); + } + + #[test] + fn profile_list_preserves_author_order_and_missing_rows() { + let author_a = parse_pubkey( + "2bdebe7b23fccb167fc8843280b789839dfa296ae9fd86cc9769b4813d76d8a4", + ); + let author_b = parse_pubkey( + "3bdebe7b23fccb167fc8843280b789839dfa296ae9fd86cc9769b4813d76d8a4", + ); + let event_id = format!("{:064x}", 3); + let event_b = event_with_profile(&author_b, 300, "b", &event_id); + + let profiles = + build_profile_rows(vec![author_a, author_b], vec![event_b]).expect("profiles"); + + assert_eq!(profiles.len(), 2); + assert_eq!(profiles[0].author_hex, author_a.to_string()); + assert!(profiles[0].event_id.is_none()); + assert_eq!(profiles[1].author_hex, author_b.to_string()); + assert_eq!(profiles[1].event_id.as_deref(), Some(event_id.as_str())); + } +}