lib

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

commit 57e4944b760dae507a071144aea9f08fe125567d
parent e831a7377e2a3a7369b27376948e25c88588edbe
Author: triesap <tyson@radroots.org>
Date:   Fri,  6 Mar 2026 14:53:01 +0000

replica-sync: add coverage tests for ingest and sync state

- add test failpoints for canonical and event_state error paths
- expand ingest tests for applied or skipped decisions and none location paths
- cover sync status accounting and panic helpers in unit and integration tests
- run cargo check -p radroots-replica-sync and cargo test -p radroots-replica-sync

Diffstat:
Mcrates/replica-sync/src/canonical.rs | 34++++++++++++++++++++++++++++++++++
Mcrates/replica-sync/src/event_state.rs | 8++++++++
Mcrates/replica-sync/src/geo.rs | 10++++++++++
Mcrates/replica-sync/src/ingest.rs | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/replica-sync/src/sync_state.rs | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/replica-sync/src/tests.rs | 10++++++++++
Mcrates/replica-sync/tests/ingest_roundtrip.rs | 33++++++++++++++++++++++++---------
7 files changed, 230 insertions(+), 9 deletions(-)

diff --git a/crates/replica-sync/src/canonical.rs b/crates/replica-sync/src/canonical.rs @@ -9,9 +9,30 @@ use serde_json::{Map, Value}; use crate::error::RadrootsReplicaEventsError; +#[cfg(test)] +pub(crate) mod failpoints { + use core::sync::atomic::{AtomicBool, Ordering}; + + static FORCE_ERROR: AtomicBool = AtomicBool::new(false); + + pub(crate) fn set_error() { + FORCE_ERROR.store(true, Ordering::SeqCst); + } + + pub(crate) fn take_error() -> bool { + FORCE_ERROR.swap(false, Ordering::SeqCst) + } +} + pub fn canonical_json_string<T: Serialize>( value: &T, ) -> Result<String, RadrootsReplicaEventsError> { + #[cfg(test)] + if failpoints::take_error() { + return Err(RadrootsReplicaEventsError::InvalidData( + "canonical json serialization failed".to_string(), + )); + } let value = serde_json::to_value(value).map_err(|_| { RadrootsReplicaEventsError::InvalidData("canonical json serialization failed".to_string()) })?; @@ -75,6 +96,19 @@ mod tests { assert_eq!(json, r#"[{"a":1,"b":2}]"#); } + #[test] + fn canonical_json_string_handles_scalar_values() { + let json = canonical_json_string(&"value").expect("json"); + assert_eq!(json, r#""value""#); + } + + #[test] + fn canonical_json_string_failpoint_returns_error() { + super::failpoints::set_error(); + let err = canonical_json_string(&"value").expect_err("failpoint"); + assert!(err.to_string().contains("canonical json serialization failed")); + } + struct AlwaysErr; impl Serialize for AlwaysErr { diff --git a/crates/replica-sync/src/event_state.rs b/crates/replica-sync/src/event_state.rs @@ -81,6 +81,14 @@ mod tests { } #[test] + fn event_content_hash_reports_failpoint_errors() { + super::failpoints::set_error(); + let tags = vec![vec!["d".to_string(), "tag".to_string()]]; + let err = event_content_hash("content", &tags).expect_err("failpoint"); + assert!(err.to_string().contains("content_hash")); + } + + #[test] fn tag_value_finds_and_misses_keys() { let tags = vec![ vec!["p".to_string(), "member".to_string()], diff --git a/crates/replica-sync/src/geo.rs b/crates/replica-sync/src/geo.rs @@ -103,4 +103,14 @@ mod tests { assert!(point[0] >= -180.0); } } + + #[test] + fn polygon_respects_steps_when_above_minimum() { + let polygon = geojson_polygon_circle_wgs84(0.0, 10.0, 10.0, 5); + assert_eq!(polygon.coordinates[0].len(), 6); + for point in &polygon.coordinates[0] { + assert!(point[0] <= 180.0); + assert!(point[0] >= -180.0); + } + } } diff --git a/crates/replica-sync/src/ingest.rs b/crates/replica-sync/src/ingest.rs @@ -1515,6 +1515,22 @@ mod tests { ingest_profile_event(&exec, &profile_update).expect("profile update"), RadrootsReplicaIngestOutcome::Applied ); + assert_eq!( + ingest_profile_event(&exec, &profile_update).expect("profile skip"), + RadrootsReplicaIngestOutcome::Skipped + ); + let profile_older = profile_event( + 8, + &profile_pubkey, + 1, + Some(RadrootsProfileType::Individual), + "alice-old", + ); + let decision_old = event_state_decision(&exec, &profile_older, "").expect("decision old"); + assert!(!decision_old.apply); + let decision_same = + event_state_decision(&exec, &profile_update, "").expect("decision same"); + assert!(!decision_same.apply); let profile_same_time_diff_hash = profile_event( 12, &profile_pubkey, @@ -1564,6 +1580,10 @@ mod tests { ingest_farm_event(&exec, &farm_update, &FixedFactory).expect("farm update"), RadrootsReplicaIngestOutcome::Applied ); + assert_eq!( + ingest_farm_event(&exec, &farm_update, &FixedFactory).expect("farm skip"), + RadrootsReplicaIngestOutcome::Skipped + ); let plot_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ"; let plot = plot_event( @@ -1606,6 +1626,10 @@ mod tests { ingest_plot_event(&exec, &plot_update, &FixedFactory).expect("plot update"), RadrootsReplicaIngestOutcome::Applied ); + assert_eq!( + ingest_plot_event(&exec, &plot_update, &FixedFactory).expect("plot skip"), + RadrootsReplicaIngestOutcome::Skipped + ); let members = farm_list_sets::farm_members_list_set(farm_d_tag, vec!["m".repeat(64)]) .expect("members"); @@ -1641,6 +1665,10 @@ mod tests { ingest_list_set_event(&exec, &event).expect("list set"), RadrootsReplicaIngestOutcome::Applied ); + assert_eq!( + ingest_list_set_event(&exec, &event).expect("list set skip"), + RadrootsReplicaIngestOutcome::Skipped + ); } let bad_description = RadrootsListSet { @@ -1726,6 +1754,49 @@ mod tests { } #[test] + fn upsert_location_none_paths_are_ok() { + let exec = SqliteExecutor::open_memory().expect("db"); + migrations::run_all_up(&exec).expect("migrations"); + + let farm_row = farm::create( + &exec, + &IFarmFields { + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), + pubkey: "f".repeat(64), + name: "farm-none".to_string(), + about: None, + website: None, + picture: None, + banner: None, + location_primary: None, + location_city: None, + location_region: None, + location_country: None, + }, + ) + .expect("farm") + .result; + let plot_row = plot::create( + &exec, + &IPlotFields { + d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(), + farm_id: farm_row.id.clone(), + name: "plot-none".to_string(), + about: None, + location_primary: None, + location_city: None, + location_region: None, + location_country: None, + }, + ) + .expect("plot") + .result; + + let _ = upsert_farm_location(&exec, &farm_row.id, None, &FixedFactory).expect("farm none"); + let _ = upsert_plot_location(&exec, &plot_row.id, None, &FixedFactory).expect("plot none"); + } + + #[test] fn ingest_delete_error_paths_are_covered() { let exec = SqliteExecutor::open_memory().expect("db"); let (farm_id, _farm_pubkey, farm_d_tag, _plot_d_tag) = seed_rows(&exec); diff --git a/crates/replica-sync/src/sync_state.rs b/crates/replica-sync/src/sync_state.rs @@ -67,3 +67,76 @@ pub fn radroots_replica_sync_status<E: SqlExecutor>( pending_count: pending, }) } + +#[cfg(test)] +mod tests { + use super::radroots_replica_sync_status; + use crate::emit::radroots_replica_sync_all_with_options; + use crate::event_state::{event_content_hash, event_state_key, tag_value}; + use crate::types::RadrootsReplicaFarmSelector; + use radroots_replica_db::{farm, migrations, nostr_event_state}; + use radroots_replica_db_schema::farm::IFarmFields; + use radroots_replica_db_schema::nostr_event_state::INostrEventStateFields; + use radroots_sql_core::SqliteExecutor; + + #[test] + fn sync_status_empty_db_is_zero() { + let exec = SqliteExecutor::open_memory().expect("db"); + migrations::run_all_up(&exec).expect("migrations"); + let status = radroots_replica_sync_status(&exec).expect("status"); + assert_eq!(status.expected_count, 0); + assert_eq!(status.pending_count, 0); + } + + #[test] + fn sync_status_tracks_expected_and_pending() { + let exec = SqliteExecutor::open_memory().expect("db"); + migrations::run_all_up(&exec).expect("migrations"); + + let farm_row = farm::create( + &exec, + &IFarmFields { + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), + pubkey: "f".repeat(64), + name: "farm".to_string(), + about: None, + website: None, + picture: None, + banner: None, + location_primary: None, + location_city: None, + location_region: None, + location_country: None, + }, + ) + .expect("farm") + .result; + + let selector = RadrootsReplicaFarmSelector { + id: Some(farm_row.id.clone()), + d_tag: None, + pubkey: None, + }; + let bundle = radroots_replica_sync_all_with_options(&exec, &selector, None) + .expect("bundle"); + let expected_count = bundle.events.len(); + let first = bundle.events.first().expect("event"); + let d_tag = tag_value(&first.tags, "d").unwrap_or(""); + let key = event_state_key(first.kind, &first.author, d_tag); + let content_hash = event_content_hash(&first.content, &first.tags).expect("hash"); + let fields = INostrEventStateFields { + key, + kind: first.kind, + pubkey: first.author.clone(), + d_tag: d_tag.to_string(), + last_event_id: format!("{:064x}", 1u64), + last_created_at: 1, + content_hash, + }; + let _ = nostr_event_state::create(&exec, &fields).expect("state"); + + let status = radroots_replica_sync_status(&exec).expect("status"); + assert_eq!(status.expected_count, expected_count); + assert_eq!(status.pending_count, expected_count.saturating_sub(1)); + } +} diff --git a/crates/replica-sync/src/tests.rs b/crates/replica-sync/src/tests.rs @@ -21,6 +21,7 @@ use radroots_replica_db_schema::plot_tag::IPlotTagFields; use radroots_sql_core::SqliteExecutor; use radroots_sql_core::error::SqlError; use radroots_types::types::IError; +use std::panic; fn unwrap_sql<T>(result: Result<T, IError<SqlError>>, label: &str) -> T { match result { @@ -233,3 +234,12 @@ fn sync_all_emits_expected_order() { ); assert_eq!(kinds[8], KIND_LIST_SET_GENERIC); } + +#[test] +fn unwrap_sql_panics_on_error() { + let result = panic::catch_unwind(|| { + let err = IError::from(SqlError::InvalidArgument("bad".to_string())); + let _ = unwrap_sql::<()>(Err(err), "unwrap"); + }); + assert!(result.is_err()); +} diff --git a/crates/replica-sync/tests/ingest_roundtrip.rs b/crates/replica-sync/tests/ingest_roundtrip.rs @@ -50,6 +50,7 @@ use radroots_sql_core::SqlExecutor; use radroots_sql_core::SqliteExecutor; use radroots_sql_core::error::SqlError; use radroots_types::types::IError; +use std::panic; fn unwrap_sql<T>(result: Result<T, IError<SqlError>>, label: &str) -> T { match result { @@ -58,6 +59,15 @@ fn unwrap_sql<T>(result: Result<T, IError<SqlError>>, label: &str) -> T { } } +#[test] +fn unwrap_sql_panics_on_error() { + let result = panic::catch_unwind(|| { + let err = IError::from(SqlError::InvalidArgument("bad".to_string())); + let _ = unwrap_sql::<()>(Err(err), "unwrap"); + }); + assert!(result.is_err()); +} + fn draft_to_event(draft: &RadrootsReplicaEventDraft, index: u32) -> RadrootsNostrEvent { RadrootsNostrEvent { id: format!("{:064x}", index as u64 + 1), @@ -1400,12 +1410,17 @@ fn sync_emit_handles_invalid_geojson_and_unknown_profile_type() { assert_eq!(bundle.version, RADROOTS_REPLICA_TRANSFER_VERSION); assert!(bundle.events.iter().any(|event| event.kind == KIND_FARM)); assert!(bundle.events.iter().any(|event| event.kind == KIND_PLOT)); - assert!( - bundle - .events - .iter() - .any(|event| event.kind == KIND_LIST_SET_GENERIC) - ); + let mut list_set_seen = false; + let mut list_set_missed = false; + for event in &bundle.events { + if event.kind == KIND_LIST_SET_GENERIC { + list_set_seen = true; + } else { + list_set_missed = true; + } + } + assert!(list_set_seen); + assert!(list_set_missed); assert!(bundle.events.iter().any(|event| { event.kind == KIND_PROFILE && event.author == member_pubkey @@ -1419,13 +1434,13 @@ fn sync_emit_handles_invalid_geojson_and_unknown_profile_type() { #[test] fn error_conversion_paths_are_exercised() { let sql: RadrootsReplicaEventsError = IError::from(SqlError::Internal).into(); - assert!(matches!(sql, RadrootsReplicaEventsError::Sql(_))); + assert!(sql.to_string().contains("replica_sync.sql")); let encode: RadrootsReplicaEventsError = EventEncodeError::Json.into(); - assert!(matches!(encode, RadrootsReplicaEventsError::Encode(_))); + assert!(encode.to_string().contains("replica_sync.encode")); let parse_number_err = "x".parse::<u32>().expect_err("parse should fail"); let parse: RadrootsReplicaEventsError = EventParseError::InvalidNumber("k", parse_number_err).into(); - assert!(matches!(parse, RadrootsReplicaEventsError::Parse(_))); + assert!(parse.to_string().contains("replica_sync.parse")); }