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