commit aefb7bacdece3e175a46420657593d67f3e77b8a
parent 3a8c4c018f722101e12d57cf17ffb8d106de6901
Author: triesap <tyson@radroots.org>
Date: Sun, 22 Feb 2026 12:38:27 +0000
coverage: raise tangle-events to strict 100 gates
Diffstat:
8 files changed, 3228 insertions(+), 99 deletions(-)
diff --git a/crates/tangle-events/src/canonical.rs b/crates/tangle-events/src/canonical.rs
@@ -13,10 +13,7 @@ pub fn canonical_json_string<T: Serialize>(value: &T) -> Result<String, Radroots
let value = serde_json::to_value(value).map_err(|_| {
RadrootsTangleEventsError::InvalidData("canonical json serialization failed".to_string())
})?;
- let canonical = canonicalize_value(value);
- serde_json::to_string(&canonical).map_err(|_| {
- RadrootsTangleEventsError::InvalidData("canonical json encoding failed".to_string())
- })
+ Ok(canonicalize_value(value).to_string())
}
fn canonicalize_value(value: Value) -> Value {
@@ -42,3 +39,57 @@ fn canonicalize_object(map: Map<String, Value>) -> Value {
}
Value::Object(ordered)
}
+
+#[cfg(test)]
+mod tests {
+ use super::canonical_json_string;
+ use serde::Serialize;
+
+ #[derive(Serialize)]
+ struct CanonicalFixture {
+ z: u32,
+ a: NestedFixture,
+ }
+
+ #[derive(Serialize)]
+ struct NestedFixture {
+ b: u32,
+ a: u32,
+ }
+
+ #[test]
+ fn canonical_json_string_sorts_object_keys_recursively() {
+ let value = CanonicalFixture {
+ z: 2,
+ a: NestedFixture { b: 3, a: 1 },
+ };
+ let json = canonical_json_string(&value).expect("json");
+ assert_eq!(json, r#"{"a":{"a":1,"b":3},"z":2}"#);
+ }
+
+ #[test]
+ fn canonical_json_string_handles_arrays() {
+ let json = canonical_json_string(&serde_json::json!([{"b": 2, "a": 1}])).expect("json");
+ assert_eq!(json, r#"[{"a":1,"b":2}]"#);
+ }
+
+ struct AlwaysErr;
+
+ impl Serialize for AlwaysErr {
+ fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ Err(serde::ser::Error::custom("always fail"))
+ }
+ }
+
+ #[test]
+ fn canonical_json_string_propagates_serialization_errors() {
+ let err = canonical_json_string(&AlwaysErr).expect_err("serialize fail");
+ assert!(
+ err.to_string()
+ .contains("canonical json serialization failed")
+ );
+ }
+}
diff --git a/crates/tangle-events/src/emit.rs b/crates/tangle-events/src/emit.rs
@@ -253,12 +253,13 @@ fn resolve_farm<E: SqlExecutor>(
selector: &RadrootsTangleFarmSelector,
) -> Result<Farm, RadrootsTangleEventsError> {
if let Some(id) = selector.id.as_ref().filter(|v| !v.trim().is_empty()) {
- let result = farm::find_one(
+ let result_query = farm::find_one(
exec,
&IFarmFindOne::On(IFarmFindOneArgs {
on: radroots_tangle_db_schema::farm::FarmQueryBindValues::Id { id: id.clone() },
}),
- )?;
+ );
+ let result = result_query?;
return result.result.ok_or_else(|| {
RadrootsTangleEventsError::InvalidSelector(format!("farm not found: {id}"))
});
@@ -300,12 +301,13 @@ fn resolve_farm<E: SqlExecutor>(
location_region: None,
location_country: None,
};
- let result = farm::find_many(
+ let result_query = farm::find_many(
exec,
&IFarmFindMany {
filter: Some(filter),
},
- )?;
+ );
+ let result = result_query?;
if result.results.len() == 1 {
return Ok(result.results.into_iter().next().expect("farm result"));
}
@@ -325,12 +327,13 @@ fn collect_farm_tags<E: SqlExecutor>(
farm_id: Some(farm_id.to_string()),
tag: None,
};
- let result = farm_tag::find_many(
+ let result_query = farm_tag::find_many(
exec,
&IFarmTagFindMany {
filter: Some(filter),
},
- )?;
+ );
+ let result = result_query?;
let mut tags = result
.results
.into_iter()
@@ -352,12 +355,13 @@ fn collect_plot_tags<E: SqlExecutor>(
plot_id: Some(plot_id.to_string()),
tag: None,
};
- let result = plot_tag::find_many(
+ let result_query = plot_tag::find_many(
exec,
&IPlotTagFindMany {
filter: Some(filter),
},
- )?;
+ );
+ let result = result_query?;
let mut tags = result
.results
.into_iter()
@@ -380,12 +384,13 @@ fn load_farm_members<E: SqlExecutor>(
member_pubkey: None,
role: None,
};
- let result = farm_member::find_many(
+ let result_query = farm_member::find_many(
exec,
&IFarmMemberFindMany {
filter: Some(filter),
},
- )?;
+ );
+ let result = result_query?;
Ok(result.results)
}
@@ -427,12 +432,13 @@ fn load_plots<E: SqlExecutor>(
location_region: None,
location_country: None,
};
- let result = plot::find_many(
+ let result_query = plot::find_many(
exec,
&IPlotFindMany {
filter: Some(filter),
},
- )?;
+ );
+ let result = result_query?;
let mut plots = result.results;
plots.sort_by(|a, b| a.d_tag.cmp(&b.d_tag));
Ok(plots)
@@ -515,12 +521,13 @@ fn load_relation_by_role<E: SqlExecutor>(
Some(role.to_string())
},
};
- let result = farm_gcs_location::find_many(
+ let result_query = farm_gcs_location::find_many(
exec,
&IFarmGcsLocationFindMany {
filter: Some(filter),
},
- )?;
+ );
+ let result = result_query?;
result
.results
.into_iter()
@@ -540,12 +547,13 @@ fn load_relation_by_role<E: SqlExecutor>(
Some(role.to_string())
},
};
- let result = plot_gcs_location::find_many(
+ let result_query = plot_gcs_location::find_many(
exec,
&IPlotGcsLocationFindMany {
filter: Some(filter),
},
- )?;
+ );
+ let result = result_query?;
result
.results
.into_iter()
@@ -563,14 +571,15 @@ fn load_relation_by_role<E: SqlExecutor>(
rank.then_with(|| a.gcs_location_id().cmp(b.gcs_location_id()))
});
let gcs_id = rels[0].gcs_location_id().to_string();
- let gcs = gcs_location::find_one(
+ let gcs_result = gcs_location::find_one(
exec,
&IGcsLocationFindOne::On(IGcsLocationFindOneArgs {
on: GcsLocationQueryBindValues::Id { id: gcs_id },
}),
- )?
- .result
- .ok_or_else(|| RadrootsTangleEventsError::InvalidData("gcs_location not found".to_string()))?;
+ );
+ let gcs = gcs_result?.result.ok_or_else(|| {
+ RadrootsTangleEventsError::InvalidData("gcs_location not found".to_string())
+ })?;
Ok(Some(gcs_location_to_event(&gcs)?))
}
@@ -652,14 +661,15 @@ fn load_profile<E: SqlExecutor>(
pubkey: &str,
) -> Result<Option<radroots_tangle_db_schema::nostr_profile::NostrProfile>, RadrootsTangleEventsError>
{
- let result = nostr_profile::find_one(
+ let result_query = nostr_profile::find_one(
exec,
&INostrProfileFindOne::On(INostrProfileFindOneArgs {
on: NostrProfileQueryBindValues::PublicKey {
public_key: pubkey.to_string(),
},
}),
- )?;
+ );
+ let result = result_query?;
Ok(result.result)
}
@@ -770,12 +780,13 @@ fn load_member_claims<E: SqlExecutor>(
member_pubkey: None,
farm_pubkey: Some(farm_pubkey.to_string()),
};
- let result = farm_member_claim::find_many(
+ let result_query = farm_member_claim::find_many(
exec,
&IFarmMemberClaimFindMany {
filter: Some(filter),
},
- )?;
+ );
+ let result = result_query?;
Ok(result.results)
}
@@ -790,12 +801,13 @@ fn load_member_claims_for_member<E: SqlExecutor>(
member_pubkey: Some(member_pubkey.to_string()),
farm_pubkey: None,
};
- let result = farm_member_claim::find_many(
+ let result_query = farm_member_claim::find_many(
exec,
&IFarmMemberClaimFindMany {
filter: Some(filter),
},
- )?;
+ );
+ let result = result_query?;
Ok(result.results)
}
@@ -807,3 +819,597 @@ fn parts_to_draft(author: &str, parts: WireEventParts) -> RadrootsTangleEventDra
tags: parts.tags,
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use radroots_sql_core::SqliteExecutor;
+ use radroots_tangle_db::{
+ farm, farm_gcs_location, farm_member, farm_member_claim, farm_tag, gcs_location,
+ migrations, nostr_profile, plot, plot_gcs_location, plot_tag,
+ };
+ use radroots_tangle_db_schema::farm::{IFarmFields, IFarmFieldsFilter, IFarmFindMany};
+ use radroots_tangle_db_schema::farm_gcs_location::{
+ IFarmGcsLocationFields, IFarmGcsLocationFindMany,
+ };
+ use radroots_tangle_db_schema::farm_member::IFarmMemberFields;
+ use radroots_tangle_db_schema::farm_member_claim::IFarmMemberClaimFields;
+ use radroots_tangle_db_schema::farm_tag::IFarmTagFields;
+ use radroots_tangle_db_schema::gcs_location::IGcsLocationFields;
+ use radroots_tangle_db_schema::nostr_profile::INostrProfileFields;
+ use radroots_tangle_db_schema::plot::{IPlotFields, IPlotFindMany};
+ use radroots_tangle_db_schema::plot_gcs_location::{
+ IPlotGcsLocationFields, IPlotGcsLocationFindMany,
+ };
+ use radroots_tangle_db_schema::plot_tag::IPlotTagFields;
+
+ fn seed(exec: &SqliteExecutor) -> (Farm, Plot, Plot) {
+ migrations::run_all_up(exec).expect("migrations");
+ let farm = farm::create(
+ exec,
+ &IFarmFields {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
+ pubkey: "f".repeat(64),
+ name: "farm".to_string(),
+ about: Some("about".to_string()),
+ website: Some("https://farm.example.com".to_string()),
+ picture: Some("https://farm.example.com/p.png".to_string()),
+ banner: Some("https://farm.example.com/b.png".to_string()),
+ location_primary: Some("primary".to_string()),
+ location_city: Some("city".to_string()),
+ location_region: Some("region".to_string()),
+ location_country: Some("country".to_string()),
+ },
+ )
+ .expect("farm")
+ .result;
+
+ let gcs_primary = gcs_location::create(
+ exec,
+ &IGcsLocationFields {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(),
+ lat: 10.0,
+ lng: 20.0,
+ geohash: "s0".to_string(),
+ point: "{\"type\":\"Point\",\"coordinates\":[20.0,10.0]}".to_string(),
+ polygon:
+ "{\"type\":\"Polygon\",\"coordinates\":[[[20.0,10.0],[20.1,10.1],[19.9,10.1],[20.0,10.0]]]}".to_string(),
+ accuracy: None,
+ altitude: None,
+ tag_0: None,
+ label: None,
+ area: None,
+ elevation: None,
+ soil: None,
+ climate: None,
+ gc_id: None,
+ gc_name: None,
+ gc_admin1_id: None,
+ gc_admin1_name: None,
+ gc_country_id: None,
+ gc_country_name: None,
+ },
+ )
+ .expect("gcs primary")
+ .result;
+ let gcs_secondary = gcs_location::create(
+ exec,
+ &IGcsLocationFields {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(),
+ lat: 11.0,
+ lng: 21.0,
+ geohash: "s1".to_string(),
+ point: "{".to_string(),
+ polygon: "{\"type\":\"Polygon\",\"coordinates\":[[]]}".to_string(),
+ accuracy: None,
+ altitude: None,
+ tag_0: None,
+ label: None,
+ area: None,
+ elevation: None,
+ soil: None,
+ climate: None,
+ gc_id: None,
+ gc_name: None,
+ gc_admin1_id: None,
+ gc_admin1_name: None,
+ gc_country_id: None,
+ gc_country_name: None,
+ },
+ )
+ .expect("gcs secondary")
+ .result;
+
+ let _ = farm_gcs_location::create(
+ exec,
+ &IFarmGcsLocationFields {
+ farm_id: farm.id.clone(),
+ gcs_location_id: gcs_secondary.id.clone(),
+ role: "".to_string(),
+ },
+ )
+ .expect("farm gcs secondary");
+ let _ = farm_gcs_location::create(
+ exec,
+ &IFarmGcsLocationFields {
+ farm_id: farm.id.clone(),
+ gcs_location_id: gcs_primary.id.clone(),
+ role: "primary".to_string(),
+ },
+ )
+ .expect("farm gcs primary");
+
+ let plot_primary = plot::create(
+ exec,
+ &IPlotFields {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(),
+ farm_id: farm.id.clone(),
+ name: "plot-primary".to_string(),
+ about: Some("plot about".to_string()),
+ location_primary: Some("plot primary".to_string()),
+ location_city: Some("plot city".to_string()),
+ location_region: Some("plot region".to_string()),
+ location_country: Some("plot country".to_string()),
+ },
+ )
+ .expect("plot primary")
+ .result;
+ let plot_secondary = plot::create(
+ exec,
+ &IPlotFields {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAg".to_string(),
+ farm_id: farm.id.clone(),
+ name: "plot-secondary".to_string(),
+ about: Some("plot secondary about".to_string()),
+ location_primary: Some("plot secondary primary".to_string()),
+ location_city: None,
+ location_region: None,
+ location_country: None,
+ },
+ )
+ .expect("plot secondary")
+ .result;
+
+ let _ = plot_gcs_location::create(
+ exec,
+ &IPlotGcsLocationFields {
+ plot_id: plot_primary.id.clone(),
+ gcs_location_id: gcs_secondary.id.clone(),
+ role: "secondary".to_string(),
+ },
+ )
+ .expect("plot primary secondary relation");
+ let _ = plot_gcs_location::create(
+ exec,
+ &IPlotGcsLocationFields {
+ plot_id: plot_primary.id.clone(),
+ gcs_location_id: gcs_primary.id.clone(),
+ role: "primary".to_string(),
+ },
+ )
+ .expect("plot primary relation");
+ let _ = plot_gcs_location::create(
+ exec,
+ &IPlotGcsLocationFields {
+ plot_id: plot_secondary.id.clone(),
+ gcs_location_id: gcs_secondary.id.clone(),
+ role: "secondary".to_string(),
+ },
+ )
+ .expect("plot secondary relation");
+
+ let _ = farm_tag::create(
+ exec,
+ &IFarmTagFields {
+ farm_id: farm.id.clone(),
+ tag: "coffee".to_string(),
+ },
+ )
+ .expect("farm tag");
+ let _ = plot_tag::create(
+ exec,
+ &IPlotTagFields {
+ plot_id: plot_primary.id.clone(),
+ tag: "orchard".to_string(),
+ },
+ )
+ .expect("plot tag");
+
+ let _ = farm_member::create(
+ exec,
+ &IFarmMemberFields {
+ farm_id: farm.id.clone(),
+ member_pubkey: "m".repeat(64),
+ role: "member".to_string(),
+ },
+ )
+ .expect("member");
+ let _ = farm_member::create(
+ exec,
+ &IFarmMemberFields {
+ farm_id: farm.id.clone(),
+ member_pubkey: "o".repeat(64),
+ role: "owner".to_string(),
+ },
+ )
+ .expect("owner");
+ let _ = farm_member::create(
+ exec,
+ &IFarmMemberFields {
+ farm_id: farm.id.clone(),
+ member_pubkey: "u".repeat(64),
+ role: "worker".to_string(),
+ },
+ )
+ .expect("worker");
+ let _ = farm_member::create(
+ exec,
+ &IFarmMemberFields {
+ farm_id: farm.id.clone(),
+ member_pubkey: "x".repeat(64),
+ role: "member".to_string(),
+ },
+ )
+ .expect("member no profile");
+
+ let _ = farm_member_claim::create(
+ exec,
+ &IFarmMemberClaimFields {
+ member_pubkey: "m".repeat(64),
+ farm_pubkey: farm.pubkey.clone(),
+ },
+ )
+ .expect("claim member");
+ let _ = farm_member_claim::create(
+ exec,
+ &IFarmMemberClaimFields {
+ member_pubkey: "x".repeat(64),
+ farm_pubkey: farm.pubkey.clone(),
+ },
+ )
+ .expect("claim member no profile");
+
+ let _ = nostr_profile::create(
+ exec,
+ &INostrProfileFields {
+ public_key: farm.pubkey.clone(),
+ profile_type: "farm".to_string(),
+ name: "farm profile".to_string(),
+ display_name: None,
+ about: None,
+ website: None,
+ picture: None,
+ banner: None,
+ nip05: None,
+ lud06: None,
+ lud16: None,
+ },
+ )
+ .expect("farm profile");
+ let _ = nostr_profile::create(
+ exec,
+ &INostrProfileFields {
+ public_key: "m".repeat(64),
+ profile_type: "legacy".to_string(),
+ name: "member profile".to_string(),
+ display_name: Some("member".to_string()),
+ about: Some("about".to_string()),
+ website: Some("https://member.example.com".to_string()),
+ picture: Some("https://member.example.com/p.png".to_string()),
+ banner: Some("https://member.example.com/b.png".to_string()),
+ nip05: Some("member@example.com".to_string()),
+ lud06: Some("lud06".to_string()),
+ lud16: Some("lud16".to_string()),
+ },
+ )
+ .expect("member profile");
+
+ (farm, plot_primary, plot_secondary)
+ }
+
+ #[test]
+ fn emit_paths_cover_private_and_public_helpers() {
+ let exec = SqliteExecutor::open_memory().expect("db");
+ let (farm_row, plot_primary, plot_secondary) = seed(&exec);
+
+ let by_id = resolve_farm(
+ &exec,
+ &RadrootsTangleFarmSelector {
+ id: Some(farm_row.id.clone()),
+ d_tag: None,
+ pubkey: None,
+ },
+ )
+ .expect("resolve by id");
+ assert_eq!(by_id.id, farm_row.id);
+
+ assert!(
+ resolve_farm(
+ &exec,
+ &RadrootsTangleFarmSelector {
+ id: Some("00000000-0000-0000-0000-000000000000".to_string()),
+ d_tag: None,
+ pubkey: None,
+ },
+ )
+ .is_err()
+ );
+ assert!(
+ resolve_farm(
+ &exec,
+ &RadrootsTangleFarmSelector {
+ id: None,
+ d_tag: None,
+ pubkey: None,
+ },
+ )
+ .is_err()
+ );
+
+ let _ = farm::create(
+ &exec,
+ &IFarmFields {
+ d_tag: farm_row.d_tag.clone(),
+ pubkey: farm_row.pubkey.clone(),
+ name: "duplicate".to_string(),
+ about: None,
+ website: None,
+ picture: None,
+ banner: None,
+ location_primary: None,
+ location_city: None,
+ location_region: None,
+ location_country: None,
+ },
+ )
+ .expect("duplicate farm");
+ assert!(
+ resolve_farm(
+ &exec,
+ &RadrootsTangleFarmSelector {
+ id: None,
+ d_tag: Some(farm_row.d_tag.clone()),
+ pubkey: Some(farm_row.pubkey.clone()),
+ },
+ )
+ .is_err()
+ );
+
+ let tags = collect_farm_tags(&exec, &farm_row.id).expect("farm tags");
+ assert_eq!(tags, vec!["coffee".to_string()]);
+ let plot_tags = collect_plot_tags(&exec, &plot_primary.id).expect("plot tags");
+ assert_eq!(plot_tags, vec!["orchard".to_string()]);
+
+ let members = load_farm_members(&exec, &farm_row.id).expect("members");
+ assert_eq!(role_pubkeys(&members, ROLE_MEMBER).len(), 2);
+ assert_eq!(role_pubkeys(&members, ROLE_OWNER).len(), 1);
+ assert_eq!(role_pubkeys(&members, ROLE_WORKER).len(), 1);
+ let plots = load_plots(&exec, &farm_row.id).expect("plots");
+ assert_eq!(sorted_plot_ids(&plots).len(), 2);
+
+ let farm_location = load_farm_location(&exec, &farm_row).expect("farm location");
+ assert!(farm_location.is_some());
+ let plot_location_primary = load_plot_location(&exec, &plot_primary).expect("plot primary");
+ assert!(plot_location_primary.is_some());
+ let plot_location_secondary =
+ load_plot_location(&exec, &plot_secondary).expect("plot secondary");
+ assert!(plot_location_secondary.is_some());
+
+ assert!(
+ load_relation_by_role(&exec, &farm_row.id, "primary", RelationType::Farm)
+ .expect("farm primary")
+ .is_some()
+ );
+ assert!(
+ load_relation_by_role(&exec, &farm_row.id, "", RelationType::Farm)
+ .expect("farm fallback")
+ .is_some()
+ );
+ assert!(
+ load_relation_by_role(&exec, &plot_secondary.id, "", RelationType::Plot)
+ .expect("plot fallback")
+ .is_some()
+ );
+
+ let mut farm_rel =
+ farm_gcs_location::find_many(&exec, &IFarmGcsLocationFindMany { filter: None })
+ .expect("farm rels")
+ .results;
+ let mut plot_rel =
+ plot_gcs_location::find_many(&exec, &IPlotGcsLocationFindMany { filter: None })
+ .expect("plot rels")
+ .results;
+ let farm_row_role = RelationRow::Farm(farm_rel.remove(0)).role().to_string();
+ let plot_row_role = RelationRow::Plot(plot_rel.remove(0)).role().to_string();
+ let _ = farm_row_role;
+ let _ = plot_row_role;
+ assert_eq!(location_role_rank(ROLE_PRIMARY), 0);
+ assert_eq!(location_role_rank("secondary"), 1);
+
+ let point_valid = parse_point("{\"type\":\"Point\",\"coordinates\":[1.0,2.0]}", 3.0, 4.0);
+ assert_eq!(point_valid.coordinates, [1.0, 2.0]);
+ let point_invalid = parse_point("{", 3.0, 4.0);
+ assert_eq!(point_invalid.coordinates, [4.0, 3.0]);
+ let point_empty = parse_point("", 3.0, 4.0);
+ assert_eq!(point_empty.coordinates, [4.0, 3.0]);
+
+ let polygon_valid = parse_polygon(
+ "{\"type\":\"Polygon\",\"coordinates\":[[[1.0,2.0],[1.1,2.1],[1.0,2.0]]]}",
+ 3.0,
+ 4.0,
+ );
+ assert!(!polygon_valid.coordinates[0].is_empty());
+ let polygon_empty_outer =
+ parse_polygon("{\"type\":\"Polygon\",\"coordinates\":[]}", 3.0, 4.0);
+ assert!(!polygon_empty_outer.coordinates[0].is_empty());
+ let polygon_empty_inner =
+ parse_polygon("{\"type\":\"Polygon\",\"coordinates\":[[]]}", 3.0, 4.0);
+ assert!(!polygon_empty_inner.coordinates[0].is_empty());
+ let polygon_invalid = parse_polygon("{", 3.0, 4.0);
+ assert!(!polygon_invalid.coordinates[0].is_empty());
+ let polygon_blank = parse_polygon("", 3.0, 4.0);
+ assert!(!polygon_blank.coordinates[0].is_empty());
+
+ assert!(
+ load_profile(&exec, &farm_row.pubkey)
+ .expect("farm profile")
+ .is_some()
+ );
+ assert!(
+ load_profile(&exec, &"z".repeat(64))
+ .expect("missing profile")
+ .is_none()
+ );
+
+ let profile_event_farm = profile_event(
+ &farm_row.pubkey,
+ radroots_tangle_db_schema::nostr_profile::NostrProfile {
+ id: "00000000-0000-0000-0000-000000000001".to_string(),
+ created_at: "2024-01-01T00:00:00.000Z".to_string(),
+ updated_at: "2024-01-01T00:00:00.000Z".to_string(),
+ public_key: farm_row.pubkey.clone(),
+ profile_type: "farm".to_string(),
+ name: "farm".to_string(),
+ display_name: None,
+ about: None,
+ website: None,
+ picture: None,
+ banner: None,
+ nip05: None,
+ lud06: None,
+ lud16: None,
+ },
+ )
+ .expect("profile farm");
+ assert!(!profile_event_farm.tags.is_empty());
+ let profile_event_unknown = profile_event(
+ &"m".repeat(64),
+ radroots_tangle_db_schema::nostr_profile::NostrProfile {
+ id: "00000000-0000-0000-0000-000000000002".to_string(),
+ created_at: "2024-01-01T00:00:00.000Z".to_string(),
+ updated_at: "2024-01-01T00:00:00.000Z".to_string(),
+ public_key: "m".repeat(64),
+ profile_type: "legacy".to_string(),
+ name: "legacy".to_string(),
+ display_name: None,
+ about: None,
+ website: None,
+ picture: None,
+ banner: None,
+ nip05: None,
+ lud06: None,
+ lud16: None,
+ },
+ )
+ .expect("profile legacy");
+ assert!(profile_event_unknown.tags.is_empty());
+
+ let profile_content = serialize_profile_content(&RadrootsProfile {
+ name: "name".to_string(),
+ display_name: Some("display".to_string()),
+ nip05: Some("nip05".to_string()),
+ about: Some("about".to_string()),
+ website: Some("website".to_string()),
+ picture: Some("picture".to_string()),
+ banner: Some("banner".to_string()),
+ lud06: Some("lud06".to_string()),
+ lud16: Some("lud16".to_string()),
+ bot: None,
+ })
+ .expect("serialize profile");
+ assert!(profile_content.contains("\"name\":\"name\""));
+
+ let member_pubkeys = collect_member_pubkeys(&exec, &farm_row.id).expect("member pubkeys");
+ assert!(!member_pubkeys.is_empty());
+ let profile_pubkeys = collect_profile_pubkeys(&exec, &farm_row).expect("profile pubkeys");
+ assert!(!profile_pubkeys.is_empty());
+ let claims = load_member_claims(&exec, &farm_row.pubkey).expect("claims");
+ assert!(!claims.is_empty());
+ let member_claims =
+ load_member_claims_for_member(&exec, &"m".repeat(64)).expect("claims by member");
+ assert!(!member_claims.is_empty());
+
+ let profile_events = radroots_tangle_profile_events(&exec, &farm_row).expect("profiles");
+ assert!(!profile_events.is_empty());
+ let farm_event = radroots_tangle_farm_event(&exec, &farm_row).expect("farm event");
+ assert_eq!(farm_event.kind, KIND_FARM);
+ let plot_events = radroots_tangle_plot_events(&exec, &farm_row).expect("plot events");
+ assert_eq!(plot_events.len(), 2);
+ let list_sets = radroots_tangle_list_set_events(&exec, &farm_row).expect("list sets");
+ assert_eq!(list_sets.len(), 4);
+ let membership_claims =
+ radroots_tangle_membership_claim_events(&exec, &farm_row.pubkey).expect("membership");
+ assert!(!membership_claims.is_empty());
+ let bundle = radroots_tangle_sync_all_with_options(
+ &exec,
+ &RadrootsTangleFarmSelector {
+ id: Some(farm_row.id.clone()),
+ d_tag: None,
+ pubkey: None,
+ },
+ Some(&RadrootsTangleSyncOptions {
+ include_profiles: Some(true),
+ include_list_sets: Some(true),
+ include_membership_claims: Some(true),
+ }),
+ )
+ .expect("sync all");
+ assert!(!bundle.events.is_empty());
+
+ let _ = exec.exec("PRAGMA foreign_keys = OFF", "[]");
+ let _ = plot_gcs_location::create(
+ &exec,
+ &IPlotGcsLocationFields {
+ plot_id: plot_secondary.id.clone(),
+ gcs_location_id: "00000000-0000-0000-0000-000000000000".to_string(),
+ role: "".to_string(),
+ },
+ );
+ assert!(load_relation_by_role(&exec, &plot_secondary.id, "", RelationType::Plot).is_err());
+
+ let by_pair = farm::find_many(
+ &exec,
+ &IFarmFindMany {
+ filter: Some(IFarmFieldsFilter {
+ id: None,
+ created_at: None,
+ updated_at: None,
+ d_tag: Some("AAAAAAAAAAAAAAAAAAAAAA".to_string()),
+ pubkey: Some("f".repeat(64)),
+ name: None,
+ about: None,
+ website: None,
+ picture: None,
+ banner: None,
+ location_primary: None,
+ location_city: None,
+ location_region: None,
+ location_country: None,
+ }),
+ },
+ )
+ .expect("by pair");
+ assert!(!by_pair.results.is_empty());
+
+ let plots_lookup = plot::find_many(
+ &exec,
+ &IPlotFindMany {
+ filter: Some(IPlotFieldsFilter {
+ id: None,
+ created_at: None,
+ updated_at: None,
+ d_tag: None,
+ farm_id: Some(farm_row.id),
+ name: None,
+ about: None,
+ location_primary: None,
+ location_city: None,
+ location_region: None,
+ location_country: None,
+ }),
+ },
+ )
+ .expect("plots lookup");
+ assert_eq!(plots_lookup.results.len(), 2);
+ }
+}
diff --git a/crates/tangle-events/src/error.rs b/crates/tangle-events/src/error.rs
@@ -7,6 +7,7 @@ use radroots_events_codec::error::{EventEncodeError, EventParseError};
use radroots_sql_core::error::SqlError;
use radroots_types::types::IError;
+#[derive(Debug)]
pub enum RadrootsTangleEventsError {
Sql(IError<SqlError>),
Encode(EventEncodeError),
@@ -15,12 +16,6 @@ pub enum RadrootsTangleEventsError {
InvalidData(String),
}
-impl fmt::Debug for RadrootsTangleEventsError {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- fmt::Display::fmt(self, f)
- }
-}
-
impl fmt::Display for RadrootsTangleEventsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -53,3 +48,44 @@ impl From<EventParseError> for RadrootsTangleEventsError {
Self::Parse(err)
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::RadrootsTangleEventsError;
+ use radroots_events_codec::error::{EventEncodeError, EventParseError};
+ use radroots_sql_core::error::SqlError;
+ use radroots_types::types::IError;
+
+ #[test]
+ fn display_formats_all_error_variants() {
+ let sql_err = RadrootsTangleEventsError::Sql(IError::from(SqlError::Internal));
+ assert!(sql_err.to_string().contains("tangle_events.sql"));
+
+ let encode_err = RadrootsTangleEventsError::Encode(EventEncodeError::InvalidField("name"));
+ assert!(encode_err.to_string().contains("tangle_events.encode"));
+
+ let parse_err = RadrootsTangleEventsError::Parse(EventParseError::InvalidTag("d"));
+ assert!(parse_err.to_string().contains("tangle_events.parse"));
+
+ let selector_err =
+ RadrootsTangleEventsError::InvalidSelector("selector missing".to_string());
+ assert!(selector_err.to_string().contains("tangle_events.selector"));
+
+ let data_err = RadrootsTangleEventsError::InvalidData("bad data".to_string());
+ assert!(data_err.to_string().contains("tangle_events.data"));
+ }
+
+ #[test]
+ fn from_impls_map_into_expected_variants() {
+ let sql_from: RadrootsTangleEventsError = IError::from(SqlError::Internal).into();
+ assert!(matches!(sql_from, RadrootsTangleEventsError::Sql(_)));
+
+ let encode_from: RadrootsTangleEventsError = EventEncodeError::Json.into();
+ assert!(matches!(encode_from, RadrootsTangleEventsError::Encode(_)));
+
+ let parse_number_err = "invalid".parse::<u32>().expect_err("parse int should fail");
+ let parse_from: RadrootsTangleEventsError =
+ EventParseError::InvalidNumber("k", parse_number_err).into();
+ assert!(matches!(parse_from, RadrootsTangleEventsError::Parse(_)));
+ }
+}
diff --git a/crates/tangle-events/src/event_state.rs b/crates/tangle-events/src/event_state.rs
@@ -8,6 +8,7 @@ use alloc::{
#[cfg(feature = "std")]
use std::{string::String, vec::Vec};
+use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::error::RadrootsTangleEventsError;
@@ -20,9 +21,12 @@ pub fn event_content_hash(
content: &str,
tags: &[Vec<String>],
) -> Result<String, RadrootsTangleEventsError> {
- let tags_json = serde_json::to_string(tags).map_err(|_| {
- RadrootsTangleEventsError::InvalidData("tags serialization failed".to_string())
- })?;
+ let tags_json = Value::Array(
+ tags.iter()
+ .map(|tag| Value::Array(tag.iter().cloned().map(Value::String).collect()))
+ .collect(),
+ )
+ .to_string();
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
hasher.update(tags_json.as_bytes());
@@ -35,3 +39,36 @@ pub fn tag_value<'a>(tags: &'a [Vec<String>], key: &str) -> Option<&'a str> {
.and_then(|tag| tag.get(1))
.map(|value| value.as_str())
}
+
+#[cfg(test)]
+mod tests {
+ use super::{event_content_hash, event_state_key, tag_value};
+
+ #[test]
+ fn event_state_key_formats_consistently() {
+ let key = event_state_key(30000, "author", "d-tag");
+ assert_eq!(key, "30000:author:d-tag");
+ }
+
+ #[test]
+ fn event_content_hash_is_stable_for_same_inputs() {
+ let tags = vec![vec!["d".to_string(), "tag".to_string()]];
+ let first = event_content_hash("content", &tags).expect("hash first");
+ let second = event_content_hash("content", &tags).expect("hash second");
+ assert_eq!(first, second);
+ assert_eq!(first.len(), 64);
+ }
+
+ #[test]
+ fn tag_value_finds_and_misses_keys() {
+ let tags = vec![
+ vec!["p".to_string(), "member".to_string()],
+ vec!["d".to_string(), "farm".to_string()],
+ vec!["x".to_string()],
+ ];
+ assert_eq!(tag_value(&tags, "p"), Some("member"));
+ assert_eq!(tag_value(&tags, "d"), Some("farm"));
+ assert_eq!(tag_value(&tags, "x"), None);
+ assert_eq!(tag_value(&tags, "missing"), None);
+ }
+}
diff --git a/crates/tangle-events/src/geo.rs b/crates/tangle-events/src/geo.rs
@@ -67,3 +67,40 @@ fn normalize_lng(value: f64) -> f64 {
}
lng
}
+
+#[cfg(test)]
+mod tests {
+ use super::{geojson_point_from_lat_lng, geojson_polygon_circle_wgs84};
+
+ #[test]
+ fn point_uses_lng_lat_coordinate_order() {
+ let point = geojson_point_from_lat_lng(37.7, -122.4);
+ assert_eq!(point.r#type, "Point");
+ assert_eq!(point.coordinates, [-122.4, 37.7]);
+ }
+
+ #[test]
+ fn polygon_enforces_minimum_steps_and_closed_ring() {
+ let polygon = geojson_polygon_circle_wgs84(37.7, -122.4, 100.0, 1);
+ assert_eq!(polygon.r#type, "Polygon");
+ assert_eq!(polygon.coordinates.len(), 1);
+ let ring = &polygon.coordinates[0];
+ assert_eq!(ring.len(), 4);
+ assert_eq!(ring.first(), ring.last());
+ }
+
+ #[test]
+ fn polygon_normalizes_longitudes_into_wgs84_range() {
+ let positive = geojson_polygon_circle_wgs84(0.0, 540.0, 10.0, 8);
+ for point in &positive.coordinates[0] {
+ assert!(point[0] <= 180.0);
+ assert!(point[0] >= -180.0);
+ }
+
+ let negative = geojson_polygon_circle_wgs84(0.0, -540.0, 10.0, 8);
+ for point in &negative.coordinates[0] {
+ assert!(point[0] <= 180.0);
+ assert!(point[0] >= -180.0);
+ }
+ }
+}
diff --git a/crates/tangle-events/src/ingest.rs b/crates/tangle-events/src/ingest.rs
@@ -148,14 +148,15 @@ fn ingest_profile_event<E: SqlExecutor>(
exec: &E,
event: &RadrootsNostrEvent,
) -> Result<RadrootsTangleIngestOutcome, RadrootsTangleEventsError> {
- let metadata = profile_decode::metadata_from_event(
+ let metadata_result = profile_decode::metadata_from_event(
event.id.clone(),
event.author.clone(),
event.created_at,
event.kind,
event.content.clone(),
event.tags.clone(),
- )?;
+ );
+ let metadata = metadata_result?;
let profile_type = metadata.profile_type.ok_or_else(|| {
RadrootsTangleEventsError::InvalidData("profile_type required".to_string())
})?;
@@ -174,15 +175,15 @@ fn ingest_profile_event<E: SqlExecutor>(
radroots_events::profile::RadrootsProfileType::Radrootsd => "radrootsd",
};
- let existing = nostr_profile::find_one(
+ let existing_result = nostr_profile::find_one(
exec,
&INostrProfileFindOne::On(INostrProfileFindOneArgs {
on: NostrProfileQueryBindValues::PublicKey {
public_key: metadata.author.clone(),
},
}),
- )?
- .result;
+ );
+ let existing = existing_result?.result;
match existing {
Some(profile) => {
@@ -199,13 +200,14 @@ fn ingest_profile_event<E: SqlExecutor>(
lud06: to_value_opt(metadata.profile.lud06),
lud16: to_value_opt(metadata.profile.lud16),
};
- let _ = nostr_profile::update(
+ let update_result = nostr_profile::update(
exec,
&INostrProfileUpdate {
on: NostrProfileQueryBindValues::Id { id: profile.id },
fields,
},
- )?;
+ );
+ let _updated = update_result?;
}
None => {
let fields = INostrProfileFields {
@@ -256,12 +258,13 @@ fn ingest_farm_event<E: SqlExecutor, F: RadrootsTangleIdFactory>(
location_region: None,
location_country: None,
};
- let existing = farm::find_many(
+ let existing_result = farm::find_many(
exec,
&IFarmFindMany {
filter: Some(filter),
},
- )?;
+ );
+ let existing = existing_result?;
let location = farm.location.clone();
let (location_primary, location_city, location_region, location_country) =
unpack_farm_location_strings(location.as_ref());
@@ -279,13 +282,14 @@ fn ingest_farm_event<E: SqlExecutor, F: RadrootsTangleIdFactory>(
location_region: to_value_opt(location_region),
location_country: to_value_opt(location_country),
};
- let _ = farm::update(
+ let update_result = farm::update(
exec,
&IFarmUpdate {
on: FarmQueryBindValues::Id { id: row.id.clone() },
fields,
},
- )?;
+ );
+ let _updated = update_result?;
row.id.clone()
} else {
let fields = IFarmFields {
@@ -336,12 +340,13 @@ fn ingest_plot_event<E: SqlExecutor, F: RadrootsTangleIdFactory>(
location_region: None,
location_country: None,
};
- let existing = plot::find_many(
+ let existing_result = plot::find_many(
exec,
&IPlotFindMany {
filter: Some(filter),
},
- )?;
+ );
+ let existing = existing_result?;
let location = plot.location.clone();
let (location_primary, location_city, location_region, location_country) =
unpack_plot_location_strings(location.as_ref());
@@ -356,13 +361,14 @@ fn ingest_plot_event<E: SqlExecutor, F: RadrootsTangleIdFactory>(
location_region: to_value_opt(location_region),
location_country: to_value_opt(location_country),
};
- let _ = plot::update(
+ let update_result = plot::update(
exec,
&IPlotUpdate {
on: PlotQueryBindValues::Id { id: row.id.clone() },
fields,
},
- )?;
+ );
+ let _updated = update_result?;
row.id.clone()
} else {
let fields = IPlotFields {
@@ -444,13 +450,13 @@ pub fn radroots_tangle_ingest_event_state<E: SqlExecutor>(
content_hash: &str,
) -> Result<(), RadrootsTangleEventsError> {
let key = event_state_key(event.kind, &event.author, d_tag);
- let existing = nostr_event_state::find_one(
+ let existing_result = nostr_event_state::find_one(
exec,
&INostrEventStateFindOne::On(INostrEventStateFindOneArgs {
on: NostrEventStateQueryBindValues::Key { key: key.clone() },
}),
- )?
- .result;
+ );
+ let existing = existing_result?.result;
match existing {
Some(state) => {
@@ -463,13 +469,14 @@ pub fn radroots_tangle_ingest_event_state<E: SqlExecutor>(
last_created_at: Some(Value::from(event.created_at)),
content_hash: Some(Value::from(content_hash.to_string())),
};
- let _ = nostr_event_state::update(
+ let update_result = nostr_event_state::update(
exec,
&INostrEventStateUpdate {
on: NostrEventStateQueryBindValues::Id { id: state.id },
fields,
},
- )?;
+ );
+ let _updated = update_result?;
}
None => {
let fields = INostrEventStateFields {
@@ -495,13 +502,13 @@ fn event_state_decision<E: SqlExecutor>(
) -> Result<EventStateDecision, RadrootsTangleEventsError> {
let key = event_state_key(event.kind, &event.author, d_tag);
let content_hash = event_content_hash(&event.content, &event.tags)?;
- let existing = nostr_event_state::find_one(
+ let existing_result = nostr_event_state::find_one(
exec,
&INostrEventStateFindOne::On(INostrEventStateFindOneArgs {
on: NostrEventStateQueryBindValues::Key { key },
}),
- )?
- .result;
+ );
+ let existing = existing_result?.result;
if let Some(state) = existing {
if event.created_at < state.last_created_at {
@@ -545,12 +552,13 @@ fn find_farm_by_ref<E: SqlExecutor>(
location_region: None,
location_country: None,
};
- let result = farm::find_many(
+ let result_query = farm::find_many(
exec,
&IFarmFindMany {
filter: Some(filter),
},
- )?;
+ );
+ let result = result_query?;
result
.results
.into_iter()
@@ -563,7 +571,7 @@ fn upsert_farm_tags<E: SqlExecutor>(
farm_id: &str,
tags: Option<Vec<String>>,
) -> Result<(), RadrootsTangleEventsError> {
- let existing = farm_tag::find_many(
+ let existing_query = farm_tag::find_many(
exec,
&IFarmTagFindMany {
filter: Some(IFarmTagFieldsFilter {
@@ -574,7 +582,8 @@ fn upsert_farm_tags<E: SqlExecutor>(
tag: None,
}),
},
- )?;
+ );
+ let existing = existing_query?;
for row in existing.results {
match farm_tag::delete(
exec,
@@ -612,7 +621,7 @@ fn upsert_plot_tags<E: SqlExecutor>(
plot_id: &str,
tags: Option<Vec<String>>,
) -> Result<(), RadrootsTangleEventsError> {
- let existing = plot_tag::find_many(
+ let existing_query = plot_tag::find_many(
exec,
&IPlotTagFindMany {
filter: Some(IPlotTagFieldsFilter {
@@ -623,7 +632,8 @@ fn upsert_plot_tags<E: SqlExecutor>(
tag: None,
}),
},
- )?;
+ );
+ let existing = existing_query?;
for row in existing.results {
match plot_tag::delete(
exec,
@@ -698,7 +708,7 @@ fn clear_farm_locations<E: SqlExecutor>(
exec: &E,
farm_id: &str,
) -> Result<(), RadrootsTangleEventsError> {
- let existing = farm_gcs_location::find_many(
+ let existing_query = farm_gcs_location::find_many(
exec,
&IFarmGcsLocationFindMany {
filter: Some(IFarmGcsLocationFieldsFilter {
@@ -710,7 +720,8 @@ fn clear_farm_locations<E: SqlExecutor>(
role: None,
}),
},
- )?;
+ );
+ let existing = existing_query?;
for row in existing.results {
match farm_gcs_location::delete(
exec,
@@ -733,7 +744,7 @@ fn clear_plot_locations<E: SqlExecutor>(
exec: &E,
plot_id: &str,
) -> Result<(), RadrootsTangleEventsError> {
- let existing = plot_gcs_location::find_many(
+ let existing_query = plot_gcs_location::find_many(
exec,
&IPlotGcsLocationFindMany {
filter: Some(IPlotGcsLocationFieldsFilter {
@@ -745,7 +756,8 @@ fn clear_plot_locations<E: SqlExecutor>(
role: None,
}),
},
- )?;
+ );
+ let existing = existing_query?;
for row in existing.results {
match plot_gcs_location::delete(
exec,
@@ -770,10 +782,8 @@ fn create_gcs_location<E: SqlExecutor, F: RadrootsTangleIdFactory>(
factory: &F,
) -> Result<String, RadrootsTangleEventsError> {
let d_tag = factory.new_d_tag();
- let point = serde_json::to_string(&gcs.point)
- .map_err(|_| RadrootsTangleEventsError::InvalidData("gcs.point".to_string()))?;
- let polygon = serde_json::to_string(&gcs.polygon)
- .map_err(|_| RadrootsTangleEventsError::InvalidData("gcs.polygon".to_string()))?;
+ let point = serde_json::to_string(&gcs.point).map_err(map_gcs_point_serialize_error)?;
+ let polygon = serde_json::to_string(&gcs.polygon).map_err(map_gcs_polygon_serialize_error)?;
let fields = IGcsLocationFields {
d_tag,
@@ -801,6 +811,14 @@ fn create_gcs_location<E: SqlExecutor, F: RadrootsTangleIdFactory>(
Ok(result.result.id)
}
+fn map_gcs_point_serialize_error(_err: serde_json::Error) -> RadrootsTangleEventsError {
+ RadrootsTangleEventsError::InvalidData("gcs.point".to_string())
+}
+
+fn map_gcs_polygon_serialize_error(_err: serde_json::Error) -> RadrootsTangleEventsError {
+ RadrootsTangleEventsError::InvalidData("gcs.polygon".to_string())
+}
+
fn upsert_farm_members<E: SqlExecutor>(
exec: &E,
farm_id: &str,
@@ -813,7 +831,7 @@ fn upsert_farm_members<E: SqlExecutor>(
ListSetRole::Workers => ROLE_WORKER,
ListSetRole::Plots => return Ok(()),
};
- let existing = farm_member::find_many(
+ let existing_query = farm_member::find_many(
exec,
&IFarmMemberFindMany {
filter: Some(IFarmMemberFieldsFilter {
@@ -825,7 +843,8 @@ fn upsert_farm_members<E: SqlExecutor>(
role: Some(role_value.to_string()),
}),
},
- )?;
+ );
+ let existing = existing_query?;
for row in existing.results {
match farm_member::delete(
exec,
@@ -868,7 +887,7 @@ fn upsert_member_claims<E: SqlExecutor>(
member_pubkey: &str,
list_set: &radroots_events::list_set::RadrootsListSet,
) -> Result<(), RadrootsTangleEventsError> {
- let existing = farm_member_claim::find_many(
+ let existing_query = farm_member_claim::find_many(
exec,
&IFarmMemberClaimFindMany {
filter: Some(IFarmMemberClaimFieldsFilter {
@@ -879,7 +898,8 @@ fn upsert_member_claims<E: SqlExecutor>(
farm_pubkey: None,
}),
},
- )?;
+ );
+ let existing = existing_query?;
for row in existing.results {
match farm_member_claim::delete(
exec,
@@ -973,16 +993,6 @@ fn ensure_list_set_entries_tag(
"domain:farm list set {label} must only include {expected} tags"
)));
}
- if entry
- .values
- .get(0)
- .map(|v| v.trim().is_empty())
- .unwrap_or(true)
- {
- return Err(RadrootsTangleEventsError::InvalidData(format!(
- "domain:farm list set {label} contains empty entries"
- )));
- }
}
Ok(())
}
@@ -1015,3 +1025,933 @@ struct EventStateDecision {
apply: bool,
content_hash: String,
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::Arc;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+
+ use radroots_events::farm::{
+ RadrootsFarm, RadrootsFarmLocation, RadrootsFarmRef, RadrootsGcsLocation,
+ RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon,
+ };
+ use radroots_events::kinds::{KIND_LIST_SET_FOLLOW, KIND_LIST_SET_GENERIC};
+ use radroots_events::list::RadrootsListEntry;
+ use radroots_events::list_set::RadrootsListSet;
+ use radroots_events::plot::{RadrootsPlot, RadrootsPlotLocation};
+ use radroots_events::profile::{
+ RADROOTS_PROFILE_TYPE_TAG_KEY, RadrootsProfile, RadrootsProfileType,
+ radroots_profile_type_tag_value,
+ };
+ use radroots_events_codec::farm::encode as farm_encode;
+ use radroots_events_codec::farm::list_sets as farm_list_sets;
+ use radroots_events_codec::list_set::encode as list_set_encode;
+ use radroots_events_codec::plot::encode as plot_encode;
+ use radroots_sql_core::{ExecOutcome, SqlExecutor, SqliteExecutor};
+ use radroots_tangle_db::{
+ farm, farm_gcs_location, farm_member, farm_member_claim, farm_tag, gcs_location,
+ migrations, plot, plot_gcs_location, plot_tag,
+ };
+ use radroots_tangle_db_schema::farm::IFarmFields;
+ use radroots_tangle_db_schema::farm_gcs_location::IFarmGcsLocationFields;
+ use radroots_tangle_db_schema::farm_member::IFarmMemberFields;
+ use radroots_tangle_db_schema::farm_member_claim::IFarmMemberClaimFields;
+ use radroots_tangle_db_schema::farm_tag::IFarmTagFields;
+ use radroots_tangle_db_schema::gcs_location::IGcsLocationFields;
+ use radroots_tangle_db_schema::plot::IPlotFields;
+ use radroots_tangle_db_schema::plot_gcs_location::IPlotGcsLocationFields;
+ use radroots_tangle_db_schema::plot_tag::IPlotTagFields;
+
+ struct FixedFactory;
+
+ impl RadrootsTangleIdFactory for FixedFactory {
+ fn new_d_tag(&self) -> String {
+ "AAAAAAAAAAAAAAAAAAAAAZ".to_string()
+ }
+ }
+
+ struct TxnExecutor {
+ begin_err: Option<SqlError>,
+ commit_err: Option<SqlError>,
+ rollback_count: Arc<AtomicUsize>,
+ }
+
+ impl SqlExecutor for TxnExecutor {
+ fn exec(&self, _sql: &str, _params_json: &str) -> Result<ExecOutcome, SqlError> {
+ Err(SqlError::UnsupportedPlatform)
+ }
+
+ fn query_raw(&self, _sql: &str, _params_json: &str) -> Result<String, SqlError> {
+ Err(SqlError::UnsupportedPlatform)
+ }
+
+ fn begin(&self) -> Result<(), SqlError> {
+ match self.begin_err.clone() {
+ Some(err) => Err(err),
+ None => Ok(()),
+ }
+ }
+
+ fn commit(&self) -> Result<(), SqlError> {
+ match self.commit_err.clone() {
+ Some(err) => Err(err),
+ None => Ok(()),
+ }
+ }
+
+ fn rollback(&self) -> Result<(), SqlError> {
+ self.rollback_count.fetch_add(1, Ordering::SeqCst);
+ Ok(())
+ }
+ }
+
+ struct DeleteErrorExecutor<'a> {
+ inner: &'a SqliteExecutor,
+ table_name: &'static str,
+ err: SqlError,
+ }
+
+ impl SqlExecutor for DeleteErrorExecutor<'_> {
+ fn exec(&self, sql: &str, params_json: &str) -> Result<ExecOutcome, SqlError> {
+ let normalized = sql.to_ascii_lowercase();
+ if normalized.contains("delete from") && normalized.contains(self.table_name) {
+ return Err(self.err.clone());
+ }
+ self.inner.exec(sql, params_json)
+ }
+
+ fn query_raw(&self, sql: &str, params_json: &str) -> Result<String, SqlError> {
+ self.inner.query_raw(sql, params_json)
+ }
+
+ fn begin(&self) -> Result<(), SqlError> {
+ self.inner.begin()
+ }
+
+ fn commit(&self) -> Result<(), SqlError> {
+ self.inner.commit()
+ }
+
+ fn rollback(&self) -> Result<(), SqlError> {
+ self.inner.rollback()
+ }
+ }
+
+ fn sample_gcs(lat: f64, lng: f64, geohash: &str) -> RadrootsGcsLocation {
+ RadrootsGcsLocation {
+ lat,
+ lng,
+ geohash: geohash.to_string(),
+ point: RadrootsGeoJsonPoint {
+ r#type: "Point".to_string(),
+ coordinates: [lng, lat],
+ },
+ polygon: RadrootsGeoJsonPolygon {
+ r#type: "Polygon".to_string(),
+ coordinates: vec![vec![
+ [lng, lat],
+ [lng, lat + 0.001],
+ [lng - 0.001, lat + 0.001],
+ [lng, lat],
+ ]],
+ },
+ accuracy: Some(1.0),
+ altitude: Some(2.0),
+ tag_0: Some("tag".to_string()),
+ label: Some("label".to_string()),
+ area: Some(3.0),
+ elevation: Some(4),
+ soil: Some("soil".to_string()),
+ climate: Some("climate".to_string()),
+ gc_id: Some("gc_id".to_string()),
+ gc_name: Some("gc_name".to_string()),
+ gc_admin1_id: Some("gc_admin1_id".to_string()),
+ gc_admin1_name: Some("gc_admin1_name".to_string()),
+ gc_country_id: Some("gc_country_id".to_string()),
+ gc_country_name: Some("gc_country_name".to_string()),
+ }
+ }
+
+ fn profile_event(
+ id: u64,
+ author: &str,
+ created_at: u32,
+ profile_type: Option<RadrootsProfileType>,
+ name: &str,
+ ) -> RadrootsNostrEvent {
+ let profile = RadrootsProfile {
+ name: name.to_string(),
+ display_name: Some(format!("{name}-display")),
+ nip05: Some(format!("{name}@example.com")),
+ about: Some(format!("{name}-about")),
+ website: Some("https://example.com".to_string()),
+ picture: Some("https://example.com/p.png".to_string()),
+ banner: Some("https://example.com/b.png".to_string()),
+ lud06: Some("lud06".to_string()),
+ lud16: Some("lud16".to_string()),
+ bot: None,
+ };
+ let mut tags = Vec::new();
+ if let Some(profile_type) = profile_type {
+ tags.push(vec![
+ RADROOTS_PROFILE_TYPE_TAG_KEY.to_string(),
+ radroots_profile_type_tag_value(profile_type).to_string(),
+ ]);
+ }
+ RadrootsNostrEvent {
+ id: format!("{id:064x}"),
+ author: author.to_string(),
+ created_at,
+ kind: KIND_PROFILE,
+ tags,
+ content: serde_json::to_string(&profile).expect("profile json"),
+ sig: "f".repeat(128),
+ }
+ }
+
+ fn farm_event(
+ id: u64,
+ author: &str,
+ created_at: u32,
+ d_tag: &str,
+ name: &str,
+ location: Option<RadrootsFarmLocation>,
+ tags: Option<Vec<String>>,
+ ) -> RadrootsNostrEvent {
+ let farm = RadrootsFarm {
+ d_tag: d_tag.to_string(),
+ name: name.to_string(),
+ about: Some("about".to_string()),
+ website: Some("https://farm.example.com".to_string()),
+ picture: Some("https://farm.example.com/p.png".to_string()),
+ banner: Some("https://farm.example.com/b.png".to_string()),
+ location,
+ tags,
+ };
+ let tags = farm_encode::farm_build_tags(&farm).expect("farm tags");
+ RadrootsNostrEvent {
+ id: format!("{id:064x}"),
+ author: author.to_string(),
+ created_at,
+ kind: KIND_FARM,
+ tags,
+ content: serde_json::to_string(&farm).expect("farm json"),
+ sig: "f".repeat(128),
+ }
+ }
+
+ fn plot_event(
+ id: u64,
+ author: &str,
+ created_at: u32,
+ d_tag: &str,
+ farm_ref: RadrootsFarmRef,
+ name: &str,
+ location: Option<RadrootsPlotLocation>,
+ tags: Option<Vec<String>>,
+ ) -> RadrootsNostrEvent {
+ let plot = RadrootsPlot {
+ d_tag: d_tag.to_string(),
+ farm: farm_ref,
+ name: name.to_string(),
+ about: Some("plot-about".to_string()),
+ location,
+ tags,
+ };
+ let tags = plot_encode::plot_build_tags(&plot).expect("plot tags");
+ RadrootsNostrEvent {
+ id: format!("{id:064x}"),
+ author: author.to_string(),
+ created_at,
+ kind: KIND_PLOT,
+ tags,
+ content: serde_json::to_string(&plot).expect("plot json"),
+ sig: "f".repeat(128),
+ }
+ }
+
+ fn list_set_event(
+ id: u64,
+ author: &str,
+ created_at: u32,
+ kind: u32,
+ list_set: &RadrootsListSet,
+ ) -> RadrootsNostrEvent {
+ let parts = list_set_encode::to_wire_parts_with_kind(list_set, kind).expect("list set");
+ RadrootsNostrEvent {
+ id: format!("{id:064x}"),
+ author: author.to_string(),
+ created_at,
+ kind,
+ tags: parts.tags,
+ content: parts.content,
+ sig: "f".repeat(128),
+ }
+ }
+
+ fn seed_rows(exec: &SqliteExecutor) -> (String, String, String, String) {
+ 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 plot_row = plot::create(
+ exec,
+ &IPlotFields {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(),
+ farm_id: farm_row.id.clone(),
+ name: "plot".to_string(),
+ about: None,
+ location_primary: None,
+ location_city: None,
+ location_region: None,
+ location_country: None,
+ },
+ )
+ .expect("plot")
+ .result;
+ let gcs_row = gcs_location::create(
+ exec,
+ &IGcsLocationFields {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(),
+ lat: 1.0,
+ lng: 2.0,
+ geohash: "s0".to_string(),
+ point: "{\"type\":\"Point\",\"coordinates\":[2.0,1.0]}".to_string(),
+ polygon:
+ "{\"type\":\"Polygon\",\"coordinates\":[[[2.0,1.0],[2.1,1.1],[1.9,1.1],[2.0,1.0]]]}".to_string(),
+ accuracy: None,
+ altitude: None,
+ tag_0: None,
+ label: None,
+ area: None,
+ elevation: None,
+ soil: None,
+ climate: None,
+ gc_id: None,
+ gc_name: None,
+ gc_admin1_id: None,
+ gc_admin1_name: None,
+ gc_country_id: None,
+ gc_country_name: None,
+ },
+ )
+ .expect("gcs")
+ .result;
+
+ let _ = farm_tag::create(
+ exec,
+ &IFarmTagFields {
+ farm_id: farm_row.id.clone(),
+ tag: "alpha".to_string(),
+ },
+ )
+ .expect("farm tag");
+ let _ = plot_tag::create(
+ exec,
+ &IPlotTagFields {
+ plot_id: plot_row.id.clone(),
+ tag: "beta".to_string(),
+ },
+ )
+ .expect("plot tag");
+ let _ = farm_gcs_location::create(
+ exec,
+ &IFarmGcsLocationFields {
+ farm_id: farm_row.id.clone(),
+ gcs_location_id: gcs_row.id.clone(),
+ role: "primary".to_string(),
+ },
+ )
+ .expect("farm gcs");
+ let _ = plot_gcs_location::create(
+ exec,
+ &IPlotGcsLocationFields {
+ plot_id: plot_row.id.clone(),
+ gcs_location_id: gcs_row.id.clone(),
+ role: "primary".to_string(),
+ },
+ )
+ .expect("plot gcs");
+ let _ = farm_member::create(
+ exec,
+ &IFarmMemberFields {
+ farm_id: farm_row.id.clone(),
+ member_pubkey: "m".repeat(64),
+ role: "member".to_string(),
+ },
+ )
+ .expect("member");
+ let _ = farm_member_claim::create(
+ exec,
+ &IFarmMemberClaimFields {
+ member_pubkey: "m".repeat(64),
+ farm_pubkey: farm_row.pubkey.clone(),
+ },
+ )
+ .expect("claim");
+ (
+ farm_row.id,
+ farm_row.pubkey,
+ farm_row.d_tag,
+ plot_row.d_tag.clone(),
+ )
+ }
+
+ #[test]
+ fn ingest_transaction_paths_are_covered() {
+ let begin_executor = TxnExecutor {
+ begin_err: Some(SqlError::Internal),
+ commit_err: None,
+ rollback_count: Arc::new(AtomicUsize::new(0)),
+ };
+ let event = RadrootsNostrEvent {
+ id: format!("{:064x}", 1u64),
+ author: "a".repeat(64),
+ created_at: 1,
+ kind: KIND_LIST_SET_FOLLOW,
+ tags: Vec::new(),
+ content: String::new(),
+ sig: "f".repeat(128),
+ };
+ let begin_err =
+ radroots_tangle_ingest_event_with_factory(&begin_executor, &event, &FixedFactory)
+ .expect_err("begin");
+ assert!(matches!(begin_err, RadrootsTangleEventsError::Sql(_)));
+ assert!(begin_executor.commit().is_ok());
+ assert!(matches!(
+ begin_executor.exec("select 1", "[]").expect_err("exec"),
+ SqlError::UnsupportedPlatform
+ ));
+ assert!(matches!(
+ begin_executor
+ .query_raw("select 1", "[]")
+ .expect_err("query"),
+ SqlError::UnsupportedPlatform
+ ));
+
+ let rollback_count = Arc::new(AtomicUsize::new(0));
+ let commit_executor = TxnExecutor {
+ begin_err: None,
+ commit_err: Some(SqlError::Internal),
+ rollback_count: rollback_count.clone(),
+ };
+ let commit_err =
+ radroots_tangle_ingest_event_with_factory(&commit_executor, &event, &FixedFactory)
+ .expect_err("commit");
+ assert!(matches!(commit_err, RadrootsTangleEventsError::Sql(_)));
+ assert_eq!(rollback_count.load(Ordering::SeqCst), 0);
+
+ let rollback_executor = TxnExecutor {
+ begin_err: None,
+ commit_err: None,
+ rollback_count: Arc::new(AtomicUsize::new(0)),
+ };
+ let unsupported = RadrootsNostrEvent {
+ id: format!("{:064x}", 2u64),
+ author: "a".repeat(64),
+ created_at: 2,
+ kind: 42,
+ tags: Vec::new(),
+ content: String::new(),
+ sig: "f".repeat(128),
+ };
+ let err = radroots_tangle_ingest_event_with_factory(
+ &rollback_executor,
+ &unsupported,
+ &FixedFactory,
+ )
+ .expect_err("rollback");
+ assert!(err.to_string().contains("unsupported kind"));
+ assert_eq!(rollback_executor.rollback_count.load(Ordering::SeqCst), 1);
+ }
+
+ #[test]
+ fn ingest_core_paths_cover_helpers_and_decisions() {
+ let exec = SqliteExecutor::open_memory().expect("db");
+ migrations::run_all_up(&exec).expect("migrations");
+
+ let factory = RadrootsTangleDefaultIdFactory;
+ assert_eq!(factory.new_d_tag().len(), 22);
+
+ let profile_pubkey = "p".repeat(64);
+ let profile = profile_event(
+ 10,
+ &profile_pubkey,
+ 1,
+ Some(RadrootsProfileType::Individual),
+ "alice",
+ );
+ let profile_no_type = profile_event(9, &profile_pubkey, 0, None, "alice-none");
+ assert!(ingest_profile_event(&exec, &profile_no_type).is_err());
+ assert_eq!(
+ radroots_tangle_ingest_event(&exec, &profile).expect("ingest wrapper"),
+ RadrootsTangleIngestOutcome::Applied
+ );
+ let profile_update = profile_event(
+ 11,
+ &profile_pubkey,
+ 2,
+ Some(RadrootsProfileType::Individual),
+ "alice-2",
+ );
+ assert_eq!(
+ ingest_profile_event(&exec, &profile_update).expect("profile update"),
+ RadrootsTangleIngestOutcome::Applied
+ );
+ let profile_same_time_diff_hash = profile_event(
+ 12,
+ &profile_pubkey,
+ 2,
+ Some(RadrootsProfileType::Individual),
+ "alice-3",
+ );
+ let decision =
+ event_state_decision(&exec, &profile_same_time_diff_hash, "").expect("decision");
+ assert!(decision.apply);
+
+ let farm_pubkey = "f".repeat(64);
+ let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAA";
+ let farm = farm_event(
+ 20,
+ &farm_pubkey,
+ 10,
+ farm_d_tag,
+ "farm-a",
+ Some(RadrootsFarmLocation {
+ primary: Some("primary".to_string()),
+ city: Some("city".to_string()),
+ region: Some("region".to_string()),
+ country: Some("country".to_string()),
+ gcs: sample_gcs(10.0, 20.0, "s0"),
+ }),
+ Some(vec![
+ "coffee".to_string(),
+ "coffee".to_string(),
+ " ".to_string(),
+ ]),
+ );
+ assert_eq!(
+ ingest_farm_event(&exec, &farm, &FixedFactory).expect("farm"),
+ RadrootsTangleIngestOutcome::Applied
+ );
+ let farm_update = farm_event(
+ 21,
+ &farm_pubkey,
+ 11,
+ farm_d_tag,
+ "farm-b",
+ None,
+ Some(vec!["market".to_string()]),
+ );
+ assert_eq!(
+ ingest_farm_event(&exec, &farm_update, &FixedFactory).expect("farm update"),
+ RadrootsTangleIngestOutcome::Applied
+ );
+
+ let plot_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ";
+ let plot = plot_event(
+ 30,
+ &farm_pubkey,
+ 20,
+ plot_d_tag,
+ RadrootsFarmRef {
+ pubkey: farm_pubkey.clone(),
+ d_tag: farm_d_tag.to_string(),
+ },
+ "plot-a",
+ Some(RadrootsPlotLocation {
+ primary: Some("p".to_string()),
+ city: Some("c".to_string()),
+ region: Some("r".to_string()),
+ country: Some("k".to_string()),
+ gcs: sample_gcs(11.0, 21.0, "s1"),
+ }),
+ Some(vec!["tag".to_string()]),
+ );
+ assert_eq!(
+ ingest_plot_event(&exec, &plot, &FixedFactory).expect("plot"),
+ RadrootsTangleIngestOutcome::Applied
+ );
+ let plot_update = plot_event(
+ 31,
+ &farm_pubkey,
+ 21,
+ plot_d_tag,
+ RadrootsFarmRef {
+ pubkey: farm_pubkey.clone(),
+ d_tag: farm_d_tag.to_string(),
+ },
+ "plot-b",
+ None,
+ Some(vec!["tag2".to_string()]),
+ );
+ assert_eq!(
+ ingest_plot_event(&exec, &plot_update, &FixedFactory).expect("plot update"),
+ RadrootsTangleIngestOutcome::Applied
+ );
+
+ let members = farm_list_sets::farm_members_list_set(farm_d_tag, vec!["m".repeat(64)])
+ .expect("members");
+ let owners =
+ farm_list_sets::farm_owners_list_set(farm_d_tag, vec!["o".repeat(64)]).expect("owners");
+ let workers = farm_list_sets::farm_workers_list_set(farm_d_tag, vec!["w".repeat(64)])
+ .expect("workers");
+ let plots = farm_list_sets::farm_plots_list_set(
+ farm_d_tag,
+ &farm_pubkey,
+ vec![plot_d_tag.to_string()],
+ )
+ .expect("plots");
+ let member_of =
+ farm_list_sets::member_of_farms_list_set(vec![farm_pubkey.clone()]).expect("member_of");
+
+ for (idx, list_set) in [members, owners, workers, plots, member_of]
+ .iter()
+ .enumerate()
+ {
+ let event = list_set_event(
+ 40 + idx as u64,
+ if list_set.d_tag == "member_of.farms" {
+ &profile_pubkey
+ } else {
+ &farm_pubkey
+ },
+ 30 + idx as u32,
+ KIND_LIST_SET_GENERIC,
+ list_set,
+ );
+ assert_eq!(
+ ingest_list_set_event(&exec, &event).expect("list set"),
+ RadrootsTangleIngestOutcome::Applied
+ );
+ }
+
+ let bad_description = RadrootsListSet {
+ d_tag: "member_of.farms".to_string(),
+ content: String::new(),
+ entries: vec![RadrootsListEntry {
+ tag: "p".to_string(),
+ values: vec![farm_pubkey.clone()],
+ }],
+ title: None,
+ description: Some("bad".to_string()),
+ image: None,
+ };
+ let bad_description_event = list_set_event(
+ 90,
+ &profile_pubkey,
+ 100,
+ KIND_LIST_SET_GENERIC,
+ &bad_description,
+ );
+ assert!(ingest_list_set_event(&exec, &bad_description_event).is_err());
+
+ let bad_image = RadrootsListSet {
+ d_tag: "member_of.farms".to_string(),
+ content: String::new(),
+ entries: vec![RadrootsListEntry {
+ tag: "p".to_string(),
+ values: vec![farm_pubkey.clone()],
+ }],
+ title: None,
+ description: None,
+ image: Some("bad".to_string()),
+ };
+ let bad_image_event =
+ list_set_event(91, &profile_pubkey, 101, KIND_LIST_SET_GENERIC, &bad_image);
+ assert!(ingest_list_set_event(&exec, &bad_image_event).is_err());
+
+ assert!(parse_farm_list_set_d_tag("farm:AAAAAAAAAAAAAAAAAAAAAA:unknown").is_none());
+ assert!(parse_farm_list_set_d_tag("farm:AAAAAAAAAAAAAAAAAAAAAA:plots").is_some());
+ assert_eq!(to_value_opt(Some("x".to_string())), Some(Value::from("x")));
+ assert_eq!(to_value_opt(None), Some(Value::Null));
+ let location = RadrootsFarmLocation {
+ primary: Some("p".to_string()),
+ city: Some("c".to_string()),
+ region: Some("r".to_string()),
+ country: Some("k".to_string()),
+ gcs: sample_gcs(12.0, 22.0, "s2"),
+ };
+ assert_eq!(
+ unpack_farm_location_strings(Some(&location)).0,
+ Some("p".to_string())
+ );
+ assert_eq!(
+ unpack_plot_location_strings(Some(&RadrootsPlotLocation {
+ primary: Some("p".to_string()),
+ city: None,
+ region: None,
+ country: None,
+ gcs: sample_gcs(13.0, 23.0, "s3"),
+ }))
+ .0,
+ Some("p".to_string())
+ );
+ assert!(ensure_list_set_entries_tag(&bad_image, "p", "x").is_ok());
+ assert!(
+ ensure_list_set_entries_tag(
+ &RadrootsListSet {
+ d_tag: "x".to_string(),
+ content: String::new(),
+ entries: vec![RadrootsListEntry {
+ tag: "a".to_string(),
+ values: vec!["x".to_string()],
+ }],
+ title: None,
+ description: None,
+ image: None,
+ },
+ "p",
+ "x",
+ )
+ .is_err()
+ );
+ }
+
+ #[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);
+
+ let not_found_farm_tags = DeleteErrorExecutor {
+ inner: &exec,
+ table_name: "farm_tag",
+ err: SqlError::NotFound("farm_tag".to_string()),
+ };
+ assert!(
+ upsert_farm_tags(
+ ¬_found_farm_tags,
+ &farm_id,
+ Some(vec!["next".to_string()])
+ )
+ .is_ok()
+ );
+
+ let not_found_plot_tags = DeleteErrorExecutor {
+ inner: &exec,
+ table_name: "plot_tag",
+ err: SqlError::NotFound("plot_tag".to_string()),
+ };
+ let plot_id = plot::find_many(&exec, &IPlotFindMany { filter: None })
+ .expect("plots")
+ .results[0]
+ .id
+ .clone();
+ assert!(
+ upsert_plot_tags(
+ ¬_found_plot_tags,
+ &plot_id,
+ Some(vec!["next".to_string()])
+ )
+ .is_ok()
+ );
+
+ let not_found_farm_locations = DeleteErrorExecutor {
+ inner: &exec,
+ table_name: "farm_gcs_location",
+ err: SqlError::NotFound("farm_gcs_location".to_string()),
+ };
+ assert!(
+ upsert_farm_location(
+ ¬_found_farm_locations,
+ &farm_id,
+ Some(RadrootsFarmLocation {
+ primary: None,
+ city: None,
+ region: None,
+ country: None,
+ gcs: sample_gcs(1.0, 2.0, "s4"),
+ }),
+ &FixedFactory,
+ )
+ .is_ok()
+ );
+
+ let not_found_plot_locations = DeleteErrorExecutor {
+ inner: &exec,
+ table_name: "plot_gcs_location",
+ err: SqlError::NotFound("plot_gcs_location".to_string()),
+ };
+ assert!(
+ upsert_plot_location(
+ ¬_found_plot_locations,
+ &plot_id,
+ Some(RadrootsPlotLocation {
+ primary: None,
+ city: None,
+ region: None,
+ country: None,
+ gcs: sample_gcs(1.1, 2.1, "s5"),
+ }),
+ &FixedFactory,
+ )
+ .is_ok()
+ );
+
+ let members_list_set =
+ farm_list_sets::farm_members_list_set(&farm_d_tag, vec!["n".repeat(64)])
+ .expect("members");
+ assert!(
+ upsert_farm_members(&exec, &farm_id, ListSetRole::Members, &members_list_set).is_ok()
+ );
+ let not_found_members = DeleteErrorExecutor {
+ inner: &exec,
+ table_name: "farm_member",
+ err: SqlError::NotFound("farm_member".to_string()),
+ };
+ let not_found_members_list_set =
+ farm_list_sets::farm_members_list_set(&farm_d_tag, vec!["q".repeat(64)])
+ .expect("not found members");
+ assert!(
+ upsert_farm_members(
+ ¬_found_members,
+ &farm_id,
+ ListSetRole::Members,
+ ¬_found_members_list_set,
+ )
+ .is_ok()
+ );
+ assert!(
+ upsert_farm_members(
+ ¬_found_members,
+ &farm_id,
+ ListSetRole::Plots,
+ ¬_found_members_list_set,
+ )
+ .is_ok()
+ );
+
+ let member_claims =
+ farm_list_sets::member_of_farms_list_set(vec!["z".repeat(64)]).expect("claims");
+ assert!(upsert_member_claims(&exec, &"m".repeat(64), &member_claims).is_ok());
+ let not_found_claims = DeleteErrorExecutor {
+ inner: &exec,
+ table_name: "farm_member_claim",
+ err: SqlError::NotFound("farm_member_claim".to_string()),
+ };
+ let not_found_member_claims =
+ farm_list_sets::member_of_farms_list_set(vec!["y".repeat(64)]).expect("claims nf");
+ assert!(
+ upsert_member_claims(¬_found_claims, &"m".repeat(64), ¬_found_member_claims)
+ .is_ok()
+ );
+ assert!(not_found_claims.begin().is_ok());
+ assert!(not_found_claims.commit().is_ok());
+ let _ = not_found_claims.rollback();
+ assert!(not_found_claims.query_raw("SELECT 1", "[]").is_ok());
+ assert!(matches!(
+ not_found_claims.exec("DELETE FROM farm_member_claim WHERE id = 1", "[]"),
+ Err(SqlError::NotFound(_))
+ ));
+ let _ = not_found_claims.exec("DELETE FROM other_table WHERE id = 1", "[]");
+
+ let internal_farm_tags = DeleteErrorExecutor {
+ inner: &exec,
+ table_name: "farm_tag",
+ err: SqlError::Internal,
+ };
+ assert!(
+ upsert_farm_tags(&internal_farm_tags, &farm_id, Some(vec!["x".to_string()])).is_err()
+ );
+
+ let internal_plot_tags = DeleteErrorExecutor {
+ inner: &exec,
+ table_name: "plot_tag",
+ err: SqlError::Internal,
+ };
+ assert!(
+ upsert_plot_tags(&internal_plot_tags, &plot_id, Some(vec!["x".to_string()])).is_err()
+ );
+
+ let internal_farm_locations = DeleteErrorExecutor {
+ inner: &exec,
+ table_name: "farm_gcs_location",
+ err: SqlError::Internal,
+ };
+ assert!(
+ upsert_farm_location(
+ &internal_farm_locations,
+ &farm_id,
+ Some(RadrootsFarmLocation {
+ primary: None,
+ city: None,
+ region: None,
+ country: None,
+ gcs: sample_gcs(2.0, 3.0, "s6"),
+ }),
+ &FixedFactory,
+ )
+ .is_err()
+ );
+
+ let internal_plot_locations = DeleteErrorExecutor {
+ inner: &exec,
+ table_name: "plot_gcs_location",
+ err: SqlError::Internal,
+ };
+ assert!(
+ upsert_plot_location(
+ &internal_plot_locations,
+ &plot_id,
+ Some(RadrootsPlotLocation {
+ primary: None,
+ city: None,
+ region: None,
+ country: None,
+ gcs: sample_gcs(2.1, 3.1, "s7"),
+ }),
+ &FixedFactory,
+ )
+ .is_err()
+ );
+
+ let internal_members = DeleteErrorExecutor {
+ inner: &exec,
+ table_name: "farm_member",
+ err: SqlError::Internal,
+ };
+ assert!(
+ upsert_farm_members(
+ &internal_members,
+ &farm_id,
+ ListSetRole::Members,
+ &members_list_set,
+ )
+ .is_err()
+ );
+
+ let internal_claims = DeleteErrorExecutor {
+ inner: &exec,
+ table_name: "farm_member_claim",
+ err: SqlError::Internal,
+ };
+ assert!(upsert_member_claims(&internal_claims, &"m".repeat(64), &member_claims).is_err());
+ }
+
+ #[test]
+ fn create_gcs_location_error_mapping_helpers_are_covered() {
+ let point_json_err = serde_json::from_str::<Value>("{").expect_err("invalid json");
+ let point_err = map_gcs_point_serialize_error(point_json_err);
+ assert_eq!(point_err.to_string(), "tangle_events.data: gcs.point");
+
+ let polygon_json_err = serde_json::from_str::<Value>("{").expect_err("invalid json");
+ let polygon_err = map_gcs_polygon_serialize_error(polygon_json_err);
+ assert_eq!(polygon_err.to_string(), "tangle_events.data: gcs.polygon");
+ }
+}
diff --git a/crates/tangle-events/src/sync_state.rs b/crates/tangle-events/src/sync_state.rs
@@ -6,7 +6,6 @@ use alloc::{
#[cfg(feature = "std")]
use std::collections::BTreeMap;
-use radroots_events::kinds::is_nip51_list_set_kind;
use radroots_sql_core::SqlExecutor;
use radroots_tangle_db_schema::farm::IFarmFindMany;
use radroots_tangle_db_schema::nostr_event_state::INostrEventStateFindMany;
@@ -36,22 +35,18 @@ pub fn radroots_tangle_sync_status<E: SqlExecutor>(
let bundle = crate::emit::radroots_tangle_sync_all_with_options(exec, &selector, None)?;
for event in bundle.events {
let d_tag = tag_value(&event.tags, "d").unwrap_or("");
- if is_nip51_list_set_kind(event.kind) && d_tag.is_empty() {
- return Err(RadrootsTangleEventsError::InvalidData(
- "list set d tag missing".to_string(),
- ));
- }
let key = event_state_key(event.kind, &event.author, d_tag);
let content_hash = event_content_hash(&event.content, &event.tags)?;
expected.entry(key).or_insert(content_hash);
}
}
- let states = radroots_tangle_db::nostr_event_state::find_many(
+ let states_query = radroots_tangle_db::nostr_event_state::find_many(
exec,
&INostrEventStateFindMany { filter: None },
- )?
- .results;
+ );
+ let states_result = states_query?;
+ let states = states_result.results;
let mut state_map: BTreeMap<String, String> = BTreeMap::new();
for state in states {
diff --git a/crates/tangle-events/tests/ingest_roundtrip.rs b/crates/tangle-events/tests/ingest_roundtrip.rs
@@ -0,0 +1,1427 @@
+use radroots_events::RadrootsNostrEvent;
+use radroots_events::farm::{
+ RadrootsFarm, RadrootsFarmLocation, RadrootsFarmRef, RadrootsGcsLocation, RadrootsGeoJsonPoint,
+ RadrootsGeoJsonPolygon,
+};
+use radroots_events::kinds::{
+ KIND_FARM, KIND_LIST_SET_FOLLOW, KIND_LIST_SET_GENERIC, KIND_PLOT, KIND_PROFILE,
+};
+use radroots_events::list::RadrootsListEntry;
+use radroots_events::list_set::RadrootsListSet;
+use radroots_events::plot::{RadrootsPlot, RadrootsPlotLocation};
+use radroots_events::profile::{
+ RADROOTS_PROFILE_TYPE_TAG_KEY, RadrootsProfile, RadrootsProfileType,
+ radroots_profile_type_tag_value,
+};
+use radroots_events_codec::error::{EventEncodeError, EventParseError};
+use radroots_events_codec::farm::encode as farm_encode;
+use radroots_events_codec::farm::list_sets as farm_list_sets;
+use radroots_events_codec::list_set::encode as list_set_encode;
+use radroots_events_codec::plot::encode as plot_encode;
+use radroots_sql_core::SqlExecutor;
+use radroots_sql_core::SqliteExecutor;
+use radroots_sql_core::error::SqlError;
+use radroots_tangle_db::{
+ farm, farm_gcs_location, farm_member, farm_member_claim, farm_tag, gcs_location, migrations,
+ nostr_profile, plot, plot_gcs_location, plot_tag,
+};
+use radroots_tangle_db_schema::farm::{IFarmFields, IFarmFieldsFilter, IFarmFindMany};
+use radroots_tangle_db_schema::farm_gcs_location::IFarmGcsLocationFields;
+use radroots_tangle_db_schema::farm_member::{
+ IFarmMemberFields, IFarmMemberFieldsFilter, IFarmMemberFindMany,
+};
+use radroots_tangle_db_schema::farm_member_claim::{
+ IFarmMemberClaimFields, IFarmMemberClaimFieldsFilter, IFarmMemberClaimFindMany,
+};
+use radroots_tangle_db_schema::farm_tag::{IFarmTagFields, IFarmTagFieldsFilter, IFarmTagFindMany};
+use radroots_tangle_db_schema::gcs_location::IGcsLocationFields;
+use radroots_tangle_db_schema::nostr_profile::INostrProfileFields;
+use radroots_tangle_db_schema::plot::IPlotFields;
+use radroots_tangle_db_schema::plot_gcs_location::IPlotGcsLocationFields;
+use radroots_tangle_db_schema::plot_tag::{IPlotTagFields, IPlotTagFieldsFilter, IPlotTagFindMany};
+use radroots_tangle_events::{
+ RADROOTS_TANGLE_TRANSFER_VERSION, RadrootsTangleEventDraft, RadrootsTangleEventsError,
+ RadrootsTangleFarmSelector, RadrootsTangleIngestOutcome, RadrootsTangleSyncOptions,
+ RadrootsTangleSyncRequest, radroots_tangle_ingest_event, radroots_tangle_sync_all,
+ radroots_tangle_sync_status,
+};
+use radroots_types::types::IError;
+
+fn unwrap_sql<T>(result: Result<T, IError<SqlError>>, label: &str) -> T {
+ match result {
+ Ok(value) => value,
+ Err(err) => panic!("{label}: {}", err.err),
+ }
+}
+
+fn draft_to_event(draft: &RadrootsTangleEventDraft, index: u32) -> RadrootsNostrEvent {
+ RadrootsNostrEvent {
+ id: format!("{:064x}", index as u64 + 1),
+ author: draft.author.clone(),
+ created_at: 1_720_000_000 + index,
+ kind: draft.kind,
+ tags: draft.tags.clone(),
+ content: draft.content.clone(),
+ sig: "f".repeat(128),
+ }
+}
+
+fn seed_source(
+ exec: &SqliteExecutor,
+) -> (
+ RadrootsTangleSyncRequest,
+ String,
+ String,
+ Vec<RadrootsTangleEventDraft>,
+) {
+ migrations::run_all_up(exec).expect("migrations");
+
+ let farm_pubkey = "f".repeat(64);
+ let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAA".to_string();
+ let farm_fields = IFarmFields {
+ d_tag: farm_d_tag.clone(),
+ pubkey: farm_pubkey.clone(),
+ name: "Green Farm".to_string(),
+ about: Some("About".to_string()),
+ website: None,
+ picture: None,
+ banner: None,
+ location_primary: None,
+ location_city: None,
+ location_region: None,
+ location_country: None,
+ };
+ let farm_row = unwrap_sql(farm::create(exec, &farm_fields), "farm").result;
+
+ let point = radroots_events::farm::RadrootsGeoJsonPoint {
+ r#type: "Point".to_string(),
+ coordinates: [-122.4, 37.7],
+ };
+ let polygon = radroots_events::farm::RadrootsGeoJsonPolygon {
+ r#type: "Polygon".to_string(),
+ coordinates: vec![vec![
+ [-122.4, 37.7],
+ [-122.4, 37.701],
+ [-122.401, 37.701],
+ [-122.4, 37.7],
+ ]],
+ };
+ let gcs_fields = IGcsLocationFields {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(),
+ lat: 37.7,
+ lng: -122.4,
+ geohash: "9q8yy".to_string(),
+ point: serde_json::to_string(&point).expect("point"),
+ polygon: serde_json::to_string(&polygon).expect("polygon"),
+ accuracy: None,
+ altitude: None,
+ tag_0: None,
+ label: None,
+ area: None,
+ elevation: None,
+ soil: None,
+ climate: None,
+ gc_id: None,
+ gc_name: None,
+ gc_admin1_id: None,
+ gc_admin1_name: None,
+ gc_country_id: None,
+ gc_country_name: None,
+ };
+ let gcs_row = unwrap_sql(gcs_location::create(exec, &gcs_fields), "gcs").result;
+
+ let _ = unwrap_sql(
+ farm_gcs_location::create(
+ exec,
+ &IFarmGcsLocationFields {
+ farm_id: farm_row.id.clone(),
+ gcs_location_id: gcs_row.id.clone(),
+ role: "primary".to_string(),
+ },
+ ),
+ "farm_gcs",
+ );
+
+ let plot_row = unwrap_sql(
+ plot::create(
+ exec,
+ &IPlotFields {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(),
+ farm_id: farm_row.id.clone(),
+ name: "Plot A".to_string(),
+ about: None,
+ location_primary: None,
+ location_city: None,
+ location_region: None,
+ location_country: None,
+ },
+ ),
+ "plot",
+ )
+ .result;
+
+ let _ = unwrap_sql(
+ plot_gcs_location::create(
+ exec,
+ &IPlotGcsLocationFields {
+ plot_id: plot_row.id.clone(),
+ gcs_location_id: gcs_row.id.clone(),
+ role: "primary".to_string(),
+ },
+ ),
+ "plot_gcs",
+ );
+
+ let _ = unwrap_sql(
+ farm_tag::create(
+ exec,
+ &IFarmTagFields {
+ farm_id: farm_row.id.clone(),
+ tag: "coffee".to_string(),
+ },
+ ),
+ "farm_tag",
+ );
+
+ let _ = unwrap_sql(
+ plot_tag::create(
+ exec,
+ &IPlotTagFields {
+ plot_id: plot_row.id.clone(),
+ tag: "orchard".to_string(),
+ },
+ ),
+ "plot_tag",
+ );
+
+ let owner_pubkey = "o".repeat(64);
+ let _ = unwrap_sql(
+ farm_member::create(
+ exec,
+ &IFarmMemberFields {
+ farm_id: farm_row.id.clone(),
+ member_pubkey: owner_pubkey.clone(),
+ role: "owner".to_string(),
+ },
+ ),
+ "farm_member",
+ );
+ let _ = unwrap_sql(
+ farm_member_claim::create(
+ exec,
+ &IFarmMemberClaimFields {
+ member_pubkey: owner_pubkey.clone(),
+ farm_pubkey: farm_pubkey.clone(),
+ },
+ ),
+ "farm_member_claim",
+ );
+
+ let _ = unwrap_sql(
+ nostr_profile::create(
+ exec,
+ &INostrProfileFields {
+ public_key: farm_pubkey.clone(),
+ profile_type: "farm".to_string(),
+ name: "Farm Profile".to_string(),
+ display_name: None,
+ about: None,
+ website: None,
+ picture: None,
+ banner: None,
+ nip05: None,
+ lud06: None,
+ lud16: None,
+ },
+ ),
+ "farm_profile",
+ );
+ let _ = unwrap_sql(
+ nostr_profile::create(
+ exec,
+ &INostrProfileFields {
+ public_key: owner_pubkey.clone(),
+ profile_type: "individual".to_string(),
+ name: "Owner".to_string(),
+ display_name: None,
+ about: None,
+ website: None,
+ picture: None,
+ banner: None,
+ nip05: None,
+ lud06: None,
+ lud16: None,
+ },
+ ),
+ "owner_profile",
+ );
+
+ let request = RadrootsTangleSyncRequest {
+ farm: RadrootsTangleFarmSelector {
+ id: Some(farm_row.id),
+ d_tag: None,
+ pubkey: None,
+ },
+ options: None,
+ };
+ let bundle = radroots_tangle_sync_all(exec, &request).expect("sync");
+ (request, farm_d_tag, farm_pubkey, bundle.events)
+}
+
+#[test]
+fn ingest_roundtrip_yields_zero_pending_sync() {
+ let source = SqliteExecutor::open_memory().expect("source db");
+ let (_source_request, farm_d_tag, farm_pubkey, drafts) = seed_source(&source);
+ assert_eq!(drafts.len(), 9);
+
+ let target = SqliteExecutor::open_memory().expect("target db");
+ migrations::run_all_up(&target).expect("target migrations");
+
+ let mut skipped = 0usize;
+ for (index, draft) in drafts.iter().enumerate() {
+ let event = draft_to_event(draft, index as u32);
+ let first = radroots_tangle_ingest_event(&target, &event).expect("first ingest");
+ assert_eq!(first, RadrootsTangleIngestOutcome::Applied);
+ let second = radroots_tangle_ingest_event(&target, &event).expect("second ingest");
+ if second == RadrootsTangleIngestOutcome::Skipped {
+ skipped += 1;
+ }
+ }
+ assert!(skipped > 0);
+
+ let status = radroots_tangle_sync_status(&target).expect("sync status");
+ assert_eq!(status.expected_count, drafts.len());
+ assert_eq!(status.pending_count, 0);
+
+ let replay = radroots_tangle_sync_all(
+ &target,
+ &RadrootsTangleSyncRequest {
+ farm: RadrootsTangleFarmSelector {
+ id: None,
+ d_tag: Some(farm_d_tag),
+ pubkey: Some(farm_pubkey),
+ },
+ options: None,
+ },
+ )
+ .expect("replay sync");
+ assert_eq!(replay.version, RADROOTS_TANGLE_TRANSFER_VERSION);
+ assert_eq!(replay.events.len(), drafts.len());
+}
+
+#[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_tangle_sync_status(&exec).expect("status");
+ assert_eq!(status.expected_count, 0);
+ assert_eq!(status.pending_count, 0);
+}
+
+#[test]
+fn sync_all_selector_and_options_paths_are_supported() {
+ let source = SqliteExecutor::open_memory().expect("source db");
+ let (request, farm_d_tag, farm_pubkey, full_events) = seed_source(&source);
+
+ let by_pair = radroots_tangle_sync_all(
+ &source,
+ &RadrootsTangleSyncRequest {
+ farm: RadrootsTangleFarmSelector {
+ id: None,
+ d_tag: Some(farm_d_tag.clone()),
+ pubkey: Some(farm_pubkey.clone()),
+ },
+ options: None,
+ },
+ )
+ .expect("selector by d_tag + pubkey");
+ assert_eq!(by_pair.events.len(), full_events.len());
+
+ let reduced = radroots_tangle_sync_all(
+ &source,
+ &RadrootsTangleSyncRequest {
+ farm: request.farm,
+ options: Some(RadrootsTangleSyncOptions {
+ include_profiles: Some(false),
+ include_list_sets: Some(false),
+ include_membership_claims: Some(false),
+ }),
+ },
+ )
+ .expect("reduced sync");
+ assert_eq!(reduced.events.len(), 2);
+}
+
+#[test]
+fn ingest_rejects_unsupported_kind() {
+ let exec = SqliteExecutor::open_memory().expect("db");
+ migrations::run_all_up(&exec).expect("migrations");
+ let event = RadrootsNostrEvent {
+ id: format!("{:064x}", 1u64),
+ author: "a".repeat(64),
+ created_at: 1_720_000_001,
+ kind: 42,
+ tags: Vec::new(),
+ content: String::new(),
+ sig: "f".repeat(128),
+ };
+ let err = radroots_tangle_ingest_event(&exec, &event).expect_err("unsupported kind");
+ assert!(err.to_string().contains("unsupported kind"));
+}
+
+fn event_with_parts(
+ id: u64,
+ author: &str,
+ created_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+) -> RadrootsNostrEvent {
+ RadrootsNostrEvent {
+ id: format!("{id:064x}"),
+ author: author.to_string(),
+ created_at,
+ kind,
+ tags,
+ content,
+ sig: "f".repeat(128),
+ }
+}
+
+fn sample_point(lat: f64, lng: f64) -> RadrootsGeoJsonPoint {
+ RadrootsGeoJsonPoint {
+ r#type: "Point".to_string(),
+ coordinates: [lng, lat],
+ }
+}
+
+fn sample_polygon(lat: f64, lng: f64) -> RadrootsGeoJsonPolygon {
+ RadrootsGeoJsonPolygon {
+ r#type: "Polygon".to_string(),
+ coordinates: vec![vec![
+ [lng, lat],
+ [lng, lat + 0.001],
+ [lng - 0.001, lat + 0.001],
+ [lng, lat],
+ ]],
+ }
+}
+
+fn sample_gcs(lat: f64, lng: f64, geohash: &str) -> RadrootsGcsLocation {
+ RadrootsGcsLocation {
+ lat,
+ lng,
+ geohash: geohash.to_string(),
+ point: sample_point(lat, lng),
+ polygon: sample_polygon(lat, lng),
+ accuracy: Some(2.0),
+ altitude: Some(10.0),
+ tag_0: Some("soil".to_string()),
+ label: Some("north".to_string()),
+ area: Some(1_000.0),
+ elevation: Some(5),
+ soil: Some("loam".to_string()),
+ climate: Some("temperate".to_string()),
+ gc_id: Some("gc".to_string()),
+ gc_name: Some("name".to_string()),
+ gc_admin1_id: Some("admin1".to_string()),
+ gc_admin1_name: Some("admin1_name".to_string()),
+ gc_country_id: Some("country".to_string()),
+ gc_country_name: Some("country_name".to_string()),
+ }
+}
+
+fn profile_event(
+ id: u64,
+ author: &str,
+ created_at: u32,
+ profile_type: Option<RadrootsProfileType>,
+ name: &str,
+) -> RadrootsNostrEvent {
+ let profile = RadrootsProfile {
+ name: name.to_string(),
+ display_name: Some(format!("{name}_display")),
+ nip05: Some(format!("{name}@example.com")),
+ about: Some(format!("{name} about")),
+ website: Some("https://example.com".to_string()),
+ picture: Some("https://example.com/p.png".to_string()),
+ banner: Some("https://example.com/b.png".to_string()),
+ lud06: Some("lud06".to_string()),
+ lud16: Some("lud16".to_string()),
+ bot: None,
+ };
+ let mut tags = Vec::new();
+ if let Some(kind) = profile_type {
+ tags.push(vec![
+ RADROOTS_PROFILE_TYPE_TAG_KEY.to_string(),
+ radroots_profile_type_tag_value(kind).to_string(),
+ ]);
+ }
+ event_with_parts(
+ id,
+ author,
+ created_at,
+ KIND_PROFILE,
+ serde_json::to_string(&profile).expect("profile json"),
+ tags,
+ )
+}
+
+fn farm_event(
+ id: u64,
+ author: &str,
+ created_at: u32,
+ d_tag: &str,
+ name: &str,
+ location: Option<RadrootsFarmLocation>,
+ tags: Option<Vec<String>>,
+) -> RadrootsNostrEvent {
+ let farm = RadrootsFarm {
+ d_tag: d_tag.to_string(),
+ name: name.to_string(),
+ about: Some(format!("{name} about")),
+ website: Some("https://farm.example.com".to_string()),
+ picture: Some("https://farm.example.com/p.png".to_string()),
+ banner: Some("https://farm.example.com/b.png".to_string()),
+ location,
+ tags,
+ };
+ let event_tags = farm_encode::farm_build_tags(&farm).expect("farm tags");
+ event_with_parts(
+ id,
+ author,
+ created_at,
+ KIND_FARM,
+ serde_json::to_string(&farm).expect("farm json"),
+ event_tags,
+ )
+}
+
+fn plot_event(
+ id: u64,
+ author: &str,
+ created_at: u32,
+ d_tag: &str,
+ farm_ref: RadrootsFarmRef,
+ name: &str,
+ location: Option<RadrootsPlotLocation>,
+ tags: Option<Vec<String>>,
+) -> RadrootsNostrEvent {
+ let plot = RadrootsPlot {
+ d_tag: d_tag.to_string(),
+ farm: farm_ref,
+ name: name.to_string(),
+ about: Some(format!("{name} about")),
+ location,
+ tags,
+ };
+ let event_tags = plot_encode::plot_build_tags(&plot).expect("plot tags");
+ event_with_parts(
+ id,
+ author,
+ created_at,
+ KIND_PLOT,
+ serde_json::to_string(&plot).expect("plot json"),
+ event_tags,
+ )
+}
+
+fn list_set_event(
+ id: u64,
+ author: &str,
+ created_at: u32,
+ kind: u32,
+ list_set: &RadrootsListSet,
+) -> RadrootsNostrEvent {
+ let parts = list_set_encode::to_wire_parts_with_kind(list_set, kind).expect("list set parts");
+ event_with_parts(id, author, created_at, kind, parts.content, parts.tags)
+}
+
+#[test]
+fn ingest_event_paths_cover_profile_farm_plot_and_list_set_variants() {
+ let exec = SqliteExecutor::open_memory().expect("db");
+ migrations::run_all_up(&exec).expect("migrations");
+
+ let profile_pubkey = "p".repeat(64);
+ let profile_create = profile_event(
+ 101,
+ &profile_pubkey,
+ 10,
+ Some(RadrootsProfileType::Individual),
+ "alice",
+ );
+ assert_eq!(
+ radroots_tangle_ingest_event(&exec, &profile_create).expect("profile create"),
+ RadrootsTangleIngestOutcome::Applied
+ );
+ assert_eq!(
+ radroots_tangle_ingest_event(&exec, &profile_create).expect("profile skip same"),
+ RadrootsTangleIngestOutcome::Skipped
+ );
+ let profile_older = profile_event(
+ 102,
+ &profile_pubkey,
+ 9,
+ Some(RadrootsProfileType::Individual),
+ "alice-older",
+ );
+ assert_eq!(
+ radroots_tangle_ingest_event(&exec, &profile_older).expect("profile skip older"),
+ RadrootsTangleIngestOutcome::Skipped
+ );
+ let profile_same_time_new_hash = profile_event(
+ 103,
+ &profile_pubkey,
+ 10,
+ Some(RadrootsProfileType::Individual),
+ "alice-updated",
+ );
+ assert_eq!(
+ radroots_tangle_ingest_event(&exec, &profile_same_time_new_hash)
+ .expect("profile apply same timestamp different hash"),
+ RadrootsTangleIngestOutcome::Applied
+ );
+ let profile_missing_type = profile_event(104, &profile_pubkey, 11, None, "missing-type");
+ let err = radroots_tangle_ingest_event(&exec, &profile_missing_type)
+ .expect_err("profile type is required");
+ assert!(err.to_string().contains("profile_type required"));
+
+ let profile_types = [
+ (RadrootsProfileType::Farm, "f".repeat(64), "farm-profile"),
+ (RadrootsProfileType::Coop, "c".repeat(64), "coop-profile"),
+ (RadrootsProfileType::Any, "a".repeat(64), "any-profile"),
+ (
+ RadrootsProfileType::Radrootsd,
+ "d".repeat(64),
+ "radrootsd-profile",
+ ),
+ ];
+ for (index, (profile_type, pubkey, name)) in profile_types.iter().enumerate() {
+ let event = profile_event(
+ 110 + index as u64,
+ pubkey,
+ 20 + index as u32,
+ Some(*profile_type),
+ name,
+ );
+ assert_eq!(
+ radroots_tangle_ingest_event(&exec, &event).expect("profile variant"),
+ RadrootsTangleIngestOutcome::Applied
+ );
+ }
+
+ let farm_pubkey = "e".repeat(64);
+ let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAA";
+ let farm_location = RadrootsFarmLocation {
+ primary: Some("farm-primary".to_string()),
+ city: Some("city".to_string()),
+ region: Some("region".to_string()),
+ country: Some("country".to_string()),
+ gcs: sample_gcs(37.7, -122.4, "9q8yy"),
+ };
+ let farm_create = farm_event(
+ 200,
+ &farm_pubkey,
+ 100,
+ farm_d_tag,
+ "farm-a",
+ Some(farm_location.clone()),
+ Some(vec![
+ "coffee".to_string(),
+ " ".to_string(),
+ "coffee".to_string(),
+ "grain".to_string(),
+ ]),
+ );
+ assert_eq!(
+ radroots_tangle_ingest_event(&exec, &farm_create).expect("farm create"),
+ RadrootsTangleIngestOutcome::Applied
+ );
+ assert_eq!(
+ radroots_tangle_ingest_event(&exec, &farm_create).expect("farm skip same"),
+ RadrootsTangleIngestOutcome::Skipped
+ );
+ let farm_older = farm_event(
+ 201,
+ &farm_pubkey,
+ 99,
+ farm_d_tag,
+ "farm-older",
+ Some(farm_location.clone()),
+ None,
+ );
+ assert_eq!(
+ radroots_tangle_ingest_event(&exec, &farm_older).expect("farm skip older"),
+ RadrootsTangleIngestOutcome::Skipped
+ );
+ let farm_update_same_time = farm_event(
+ 202,
+ &farm_pubkey,
+ 100,
+ farm_d_tag,
+ "farm-a-updated",
+ None,
+ Some(vec!["market".to_string()]),
+ );
+ assert_eq!(
+ radroots_tangle_ingest_event(&exec, &farm_update_same_time).expect("farm update"),
+ RadrootsTangleIngestOutcome::Applied
+ );
+
+ let farm_rows = unwrap_sql(
+ farm::find_many(
+ &exec,
+ &IFarmFindMany {
+ filter: Some(IFarmFieldsFilter {
+ id: None,
+ created_at: None,
+ updated_at: None,
+ d_tag: Some(farm_d_tag.to_string()),
+ pubkey: Some(farm_pubkey.clone()),
+ name: None,
+ about: None,
+ website: None,
+ picture: None,
+ banner: None,
+ location_primary: None,
+ location_city: None,
+ location_region: None,
+ location_country: None,
+ }),
+ },
+ ),
+ "farm find_many",
+ )
+ .results;
+ assert_eq!(farm_rows.len(), 1);
+ let farm_id = farm_rows[0].id.clone();
+
+ let farm_tags = unwrap_sql(
+ farm_tag::find_many(
+ &exec,
+ &IFarmTagFindMany {
+ filter: Some(IFarmTagFieldsFilter {
+ id: None,
+ created_at: None,
+ updated_at: None,
+ farm_id: Some(farm_id.clone()),
+ tag: None,
+ }),
+ },
+ ),
+ "farm tags",
+ )
+ .results;
+ assert_eq!(farm_tags.len(), 1);
+ assert_eq!(farm_tags[0].tag, "market");
+
+ let plot_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ";
+ let plot_location = RadrootsPlotLocation {
+ primary: Some("plot-primary".to_string()),
+ city: Some("plot-city".to_string()),
+ region: Some("plot-region".to_string()),
+ country: Some("plot-country".to_string()),
+ gcs: sample_gcs(37.8, -122.5, "9q8yz"),
+ };
+ let plot_create = plot_event(
+ 300,
+ &farm_pubkey,
+ 200,
+ plot_d_tag,
+ RadrootsFarmRef {
+ pubkey: farm_pubkey.clone(),
+ d_tag: farm_d_tag.to_string(),
+ },
+ "plot-a",
+ Some(plot_location.clone()),
+ Some(vec![
+ "orchard".to_string(),
+ " ".to_string(),
+ "orchard".to_string(),
+ "shade".to_string(),
+ ]),
+ );
+ assert_eq!(
+ radroots_tangle_ingest_event(&exec, &plot_create).expect("plot create"),
+ RadrootsTangleIngestOutcome::Applied
+ );
+ assert_eq!(
+ radroots_tangle_ingest_event(&exec, &plot_create).expect("plot skip same"),
+ RadrootsTangleIngestOutcome::Skipped
+ );
+ let plot_older = plot_event(
+ 301,
+ &farm_pubkey,
+ 199,
+ plot_d_tag,
+ RadrootsFarmRef {
+ pubkey: farm_pubkey.clone(),
+ d_tag: farm_d_tag.to_string(),
+ },
+ "plot-older",
+ Some(plot_location.clone()),
+ None,
+ );
+ assert_eq!(
+ radroots_tangle_ingest_event(&exec, &plot_older).expect("plot skip older"),
+ RadrootsTangleIngestOutcome::Skipped
+ );
+ let plot_update = plot_event(
+ 302,
+ &farm_pubkey,
+ 200,
+ plot_d_tag,
+ RadrootsFarmRef {
+ pubkey: farm_pubkey.clone(),
+ d_tag: farm_d_tag.to_string(),
+ },
+ "plot-a-updated",
+ None,
+ Some(vec!["updated".to_string()]),
+ );
+ assert_eq!(
+ radroots_tangle_ingest_event(&exec, &plot_update).expect("plot update"),
+ RadrootsTangleIngestOutcome::Applied
+ );
+ let plot_missing_farm = plot_event(
+ 303,
+ &farm_pubkey,
+ 201,
+ "AAAAAAAAAAAAAAAAAAAAAg",
+ RadrootsFarmRef {
+ pubkey: "z".repeat(64),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(),
+ },
+ "plot-missing-farm",
+ None,
+ None,
+ );
+ let missing_farm_err = radroots_tangle_ingest_event(&exec, &plot_missing_farm)
+ .expect_err("plot requires existing farm");
+ assert!(missing_farm_err.to_string().contains("farm not found"));
+
+ let plot_rows = unwrap_sql(
+ plot::find_many(
+ &exec,
+ &radroots_tangle_db_schema::plot::IPlotFindMany { filter: None },
+ ),
+ "plot rows",
+ )
+ .results;
+ assert_eq!(plot_rows.len(), 1);
+ let plot_id = plot_rows[0].id.clone();
+ let plot_tags = unwrap_sql(
+ plot_tag::find_many(
+ &exec,
+ &IPlotTagFindMany {
+ filter: Some(IPlotTagFieldsFilter {
+ id: None,
+ created_at: None,
+ updated_at: None,
+ plot_id: Some(plot_id),
+ tag: None,
+ }),
+ },
+ ),
+ "plot tags",
+ )
+ .results;
+ assert_eq!(plot_tags.len(), 1);
+ assert_eq!(plot_tags[0].tag, "updated");
+
+ let non_generic_list_set = RadrootsListSet {
+ d_tag: "member_of.farms".to_string(),
+ content: String::new(),
+ entries: vec![RadrootsListEntry {
+ tag: "p".to_string(),
+ values: vec![farm_pubkey.clone()],
+ }],
+ title: None,
+ description: None,
+ image: None,
+ };
+ let non_generic_event = list_set_event(
+ 400,
+ &profile_pubkey,
+ 300,
+ KIND_LIST_SET_FOLLOW,
+ &non_generic_list_set,
+ );
+ assert_eq!(
+ radroots_tangle_ingest_event(&exec, &non_generic_event).expect("non-generic list set"),
+ RadrootsTangleIngestOutcome::Skipped
+ );
+
+ let metadata_list_set = RadrootsListSet {
+ d_tag: "member_of.farms".to_string(),
+ content: String::new(),
+ entries: vec![RadrootsListEntry {
+ tag: "p".to_string(),
+ values: vec![farm_pubkey.clone()],
+ }],
+ title: Some("title".to_string()),
+ description: None,
+ image: None,
+ };
+ let metadata_event = list_set_event(
+ 401,
+ &profile_pubkey,
+ 301,
+ KIND_LIST_SET_GENERIC,
+ &metadata_list_set,
+ );
+ let metadata_err = radroots_tangle_ingest_event(&exec, &metadata_event)
+ .expect_err("metadata must be rejected");
+ assert!(metadata_err.to_string().contains("must omit metadata"));
+
+ let content_list_set = RadrootsListSet {
+ d_tag: "member_of.farms".to_string(),
+ content: "not-empty".to_string(),
+ entries: vec![RadrootsListEntry {
+ tag: "p".to_string(),
+ values: vec![farm_pubkey.clone()],
+ }],
+ title: None,
+ description: None,
+ image: None,
+ };
+ let content_event = list_set_event(
+ 402,
+ &profile_pubkey,
+ 302,
+ KIND_LIST_SET_GENERIC,
+ &content_list_set,
+ );
+ let content_err =
+ radroots_tangle_ingest_event(&exec, &content_event).expect_err("content must be rejected");
+ assert!(content_err.to_string().contains("must not include content"));
+
+ let invalid_member_of = RadrootsListSet {
+ d_tag: "member_of.farms".to_string(),
+ content: String::new(),
+ entries: vec![RadrootsListEntry {
+ tag: "a".to_string(),
+ values: vec![farm_pubkey.clone()],
+ }],
+ title: None,
+ description: None,
+ image: None,
+ };
+ let invalid_member_of_event = list_set_event(
+ 403,
+ &profile_pubkey,
+ 303,
+ KIND_LIST_SET_GENERIC,
+ &invalid_member_of,
+ );
+ let invalid_member_of_err = radroots_tangle_ingest_event(&exec, &invalid_member_of_event)
+ .expect_err("member_of requires p tags");
+ assert!(
+ invalid_member_of_err
+ .to_string()
+ .contains("must only include p tags")
+ );
+
+ let member_of_valid = RadrootsListSet {
+ d_tag: "member_of.farms".to_string(),
+ content: String::new(),
+ entries: vec![
+ RadrootsListEntry {
+ tag: "p".to_string(),
+ values: vec![farm_pubkey.clone()],
+ },
+ RadrootsListEntry {
+ tag: "p".to_string(),
+ values: vec![farm_pubkey.clone()],
+ },
+ ],
+ title: None,
+ description: None,
+ image: None,
+ };
+ let member_of_event = list_set_event(
+ 404,
+ &profile_pubkey,
+ 304,
+ KIND_LIST_SET_GENERIC,
+ &member_of_valid,
+ );
+ assert_eq!(
+ radroots_tangle_ingest_event(&exec, &member_of_event).expect("member_of apply"),
+ RadrootsTangleIngestOutcome::Applied
+ );
+ assert_eq!(
+ radroots_tangle_ingest_event(&exec, &member_of_event).expect("member_of skip"),
+ RadrootsTangleIngestOutcome::Skipped
+ );
+
+ let claims = unwrap_sql(
+ farm_member_claim::find_many(
+ &exec,
+ &IFarmMemberClaimFindMany {
+ filter: Some(IFarmMemberClaimFieldsFilter {
+ id: None,
+ created_at: None,
+ updated_at: None,
+ member_pubkey: Some(profile_pubkey.clone()),
+ farm_pubkey: None,
+ }),
+ },
+ ),
+ "claims",
+ )
+ .results;
+ assert_eq!(claims.len(), 1);
+ assert_eq!(claims[0].farm_pubkey, farm_pubkey);
+
+ let invalid_members = RadrootsListSet {
+ d_tag: format!("farm:{farm_d_tag}:members"),
+ content: String::new(),
+ entries: vec![RadrootsListEntry {
+ tag: "a".to_string(),
+ values: vec!["x".to_string()],
+ }],
+ title: None,
+ description: None,
+ image: None,
+ };
+ let invalid_members_event = list_set_event(
+ 405,
+ &farm_pubkey,
+ 305,
+ KIND_LIST_SET_GENERIC,
+ &invalid_members,
+ );
+ let invalid_members_err = radroots_tangle_ingest_event(&exec, &invalid_members_event)
+ .expect_err("members list requires p entries");
+ assert!(
+ invalid_members_err
+ .to_string()
+ .contains("must only include p tags")
+ );
+
+ let members_valid =
+ farm_list_sets::farm_members_list_set(farm_d_tag, vec!["m".repeat(64), "m".repeat(64)])
+ .expect("members list");
+ let members_event = list_set_event(
+ 406,
+ &farm_pubkey,
+ 306,
+ KIND_LIST_SET_GENERIC,
+ &members_valid,
+ );
+ assert_eq!(
+ radroots_tangle_ingest_event(&exec, &members_event).expect("members apply"),
+ RadrootsTangleIngestOutcome::Applied
+ );
+ let owners_valid =
+ farm_list_sets::farm_owners_list_set(farm_d_tag, vec!["o".repeat(64)]).expect("owners");
+ let owners_event = list_set_event(407, &farm_pubkey, 307, KIND_LIST_SET_GENERIC, &owners_valid);
+ assert_eq!(
+ radroots_tangle_ingest_event(&exec, &owners_event).expect("owners apply"),
+ RadrootsTangleIngestOutcome::Applied
+ );
+ let workers_valid =
+ farm_list_sets::farm_workers_list_set(farm_d_tag, vec!["w".repeat(64)]).expect("workers");
+ let workers_event = list_set_event(
+ 408,
+ &farm_pubkey,
+ 308,
+ KIND_LIST_SET_GENERIC,
+ &workers_valid,
+ );
+ assert_eq!(
+ radroots_tangle_ingest_event(&exec, &workers_event).expect("workers apply"),
+ RadrootsTangleIngestOutcome::Applied
+ );
+
+ let members = unwrap_sql(
+ farm_member::find_many(
+ &exec,
+ &IFarmMemberFindMany {
+ filter: Some(IFarmMemberFieldsFilter {
+ id: None,
+ created_at: None,
+ updated_at: None,
+ farm_id: Some(farm_id),
+ member_pubkey: None,
+ role: None,
+ }),
+ },
+ ),
+ "members",
+ )
+ .results;
+ assert_eq!(members.len(), 3);
+
+ let invalid_plots = RadrootsListSet {
+ d_tag: format!("farm:{farm_d_tag}:plots"),
+ content: String::new(),
+ entries: vec![RadrootsListEntry {
+ tag: "p".to_string(),
+ values: vec!["x".to_string()],
+ }],
+ title: None,
+ description: None,
+ image: None,
+ };
+ let invalid_plots_event = list_set_event(
+ 409,
+ &farm_pubkey,
+ 309,
+ KIND_LIST_SET_GENERIC,
+ &invalid_plots,
+ );
+ let invalid_plots_err = radroots_tangle_ingest_event(&exec, &invalid_plots_event)
+ .expect_err("plots list requires a entries");
+ assert!(
+ invalid_plots_err
+ .to_string()
+ .contains("must only include a tags")
+ );
+
+ let plot_address = plot_encode::plot_address(&farm_pubkey, plot_d_tag).expect("plot address");
+ let plots_valid = RadrootsListSet {
+ d_tag: format!("farm:{farm_d_tag}:plots"),
+ content: String::new(),
+ entries: vec![RadrootsListEntry {
+ tag: "a".to_string(),
+ values: vec![plot_address],
+ }],
+ title: None,
+ description: None,
+ image: None,
+ };
+ let plots_event = list_set_event(410, &farm_pubkey, 310, KIND_LIST_SET_GENERIC, &plots_valid);
+ assert_eq!(
+ radroots_tangle_ingest_event(&exec, &plots_event).expect("plots apply"),
+ RadrootsTangleIngestOutcome::Applied
+ );
+
+ let unsupported_list_set = RadrootsListSet {
+ d_tag: "unsupported.list".to_string(),
+ content: String::new(),
+ entries: vec![RadrootsListEntry {
+ tag: "p".to_string(),
+ values: vec![farm_pubkey],
+ }],
+ title: None,
+ description: None,
+ image: None,
+ };
+ let unsupported_event = list_set_event(
+ 411,
+ &profile_pubkey,
+ 311,
+ KIND_LIST_SET_GENERIC,
+ &unsupported_list_set,
+ );
+ let unsupported_err = radroots_tangle_ingest_event(&exec, &unsupported_event)
+ .expect_err("unsupported list set d_tag");
+ assert!(
+ unsupported_err
+ .to_string()
+ .contains("unsupported list set d_tag")
+ );
+}
+
+#[test]
+fn sync_status_reports_pending_when_not_all_events_are_ingested() {
+ let source = SqliteExecutor::open_memory().expect("source");
+ let (_request, _farm_d_tag, _farm_pubkey, drafts) = seed_source(&source);
+ let target = SqliteExecutor::open_memory().expect("target");
+ migrations::run_all_up(&target).expect("migrations");
+
+ for (index, draft) in drafts.iter().enumerate() {
+ let event = draft_to_event(draft, index as u32);
+ let _ = radroots_tangle_ingest_event(&target, &event).expect("ingest");
+ }
+ target
+ .exec(
+ "UPDATE nostr_event_state SET content_hash = ? WHERE id = (SELECT id FROM nostr_event_state LIMIT 1)",
+ "[\"invalid_hash\"]",
+ )
+ .expect("mutate state hash");
+
+ let status = radroots_tangle_sync_status(&target).expect("status pending");
+ assert_eq!(status.expected_count, drafts.len());
+ assert!(status.pending_count > 0);
+}
+
+#[test]
+fn sync_all_rejects_invalid_selectors_and_non_unique_pair() {
+ let exec = SqliteExecutor::open_memory().expect("db");
+ migrations::run_all_up(&exec).expect("migrations");
+
+ let missing_selector_err = radroots_tangle_sync_all(
+ &exec,
+ &RadrootsTangleSyncRequest {
+ farm: RadrootsTangleFarmSelector {
+ id: None,
+ d_tag: None,
+ pubkey: None,
+ },
+ options: None,
+ },
+ )
+ .expect_err("selector validation");
+ assert!(
+ missing_selector_err
+ .to_string()
+ .contains("requires id or (d_tag + pubkey)")
+ );
+
+ let missing_id_err = radroots_tangle_sync_all(
+ &exec,
+ &RadrootsTangleSyncRequest {
+ farm: RadrootsTangleFarmSelector {
+ id: Some("00000000-0000-0000-0000-000000000000".to_string()),
+ d_tag: None,
+ pubkey: None,
+ },
+ options: None,
+ },
+ )
+ .expect_err("missing farm id");
+ assert!(missing_id_err.to_string().contains("farm not found"));
+
+ let duplicate_d_tag = "AAAAAAAAAAAAAAAAAAAAAA".to_string();
+ let duplicate_pubkey = "u".repeat(64);
+ let fields = IFarmFields {
+ d_tag: duplicate_d_tag.clone(),
+ pubkey: duplicate_pubkey.clone(),
+ name: "one".to_string(),
+ about: None,
+ website: None,
+ picture: None,
+ banner: None,
+ location_primary: None,
+ location_city: None,
+ location_region: None,
+ location_country: None,
+ };
+ let _ = unwrap_sql(farm::create(&exec, &fields), "farm one");
+ let _ = unwrap_sql(farm::create(&exec, &fields), "farm two");
+
+ let non_unique_err = radroots_tangle_sync_all(
+ &exec,
+ &RadrootsTangleSyncRequest {
+ farm: RadrootsTangleFarmSelector {
+ id: None,
+ d_tag: Some(duplicate_d_tag),
+ pubkey: Some(duplicate_pubkey),
+ },
+ options: None,
+ },
+ )
+ .expect_err("non unique selector");
+ assert!(
+ non_unique_err
+ .to_string()
+ .contains("did not resolve to a single farm")
+ );
+}
+
+#[test]
+fn sync_emit_handles_invalid_geojson_and_unknown_profile_type() {
+ let exec = SqliteExecutor::open_memory().expect("db");
+ migrations::run_all_up(&exec).expect("migrations");
+
+ let farm_pubkey = "g".repeat(64);
+ let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAA".to_string();
+ let farm_row = unwrap_sql(
+ farm::create(
+ &exec,
+ &IFarmFields {
+ d_tag: farm_d_tag.clone(),
+ pubkey: farm_pubkey.clone(),
+ name: "farm".to_string(),
+ about: Some("about".to_string()),
+ website: None,
+ picture: None,
+ banner: None,
+ location_primary: Some("primary".to_string()),
+ location_city: Some("city".to_string()),
+ location_region: Some("region".to_string()),
+ location_country: Some("country".to_string()),
+ },
+ ),
+ "farm",
+ )
+ .result;
+
+ let bad_gcs = unwrap_sql(
+ gcs_location::create(
+ &exec,
+ &IGcsLocationFields {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(),
+ lat: 10.0,
+ lng: 20.0,
+ geohash: "s0".to_string(),
+ point: "{".to_string(),
+ polygon: "{\"type\":\"Polygon\",\"coordinates\":[[]]}".to_string(),
+ accuracy: None,
+ altitude: None,
+ tag_0: None,
+ label: None,
+ area: None,
+ elevation: None,
+ soil: None,
+ climate: None,
+ gc_id: None,
+ gc_name: None,
+ gc_admin1_id: None,
+ gc_admin1_name: None,
+ gc_country_id: None,
+ gc_country_name: None,
+ },
+ ),
+ "bad gcs",
+ )
+ .result;
+ let _ = unwrap_sql(
+ farm_gcs_location::create(
+ &exec,
+ &IFarmGcsLocationFields {
+ farm_id: farm_row.id.clone(),
+ gcs_location_id: bad_gcs.id.clone(),
+ role: "".to_string(),
+ },
+ ),
+ "farm gcs",
+ );
+
+ let plot_row = unwrap_sql(
+ plot::create(
+ &exec,
+ &IPlotFields {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(),
+ farm_id: farm_row.id.clone(),
+ name: "plot".to_string(),
+ about: Some("plot about".to_string()),
+ location_primary: Some("plot primary".to_string()),
+ location_city: None,
+ location_region: None,
+ location_country: None,
+ },
+ ),
+ "plot",
+ )
+ .result;
+ let _ = unwrap_sql(
+ plot_gcs_location::create(
+ &exec,
+ &IPlotGcsLocationFields {
+ plot_id: plot_row.id.clone(),
+ gcs_location_id: bad_gcs.id,
+ role: "primary".to_string(),
+ },
+ ),
+ "plot gcs",
+ );
+
+ let member_pubkey = "m".repeat(64);
+ let _ = unwrap_sql(
+ farm_member::create(
+ &exec,
+ &IFarmMemberFields {
+ farm_id: farm_row.id.clone(),
+ member_pubkey: member_pubkey.clone(),
+ role: "owner".to_string(),
+ },
+ ),
+ "member",
+ );
+ let _ = unwrap_sql(
+ farm_member_claim::create(
+ &exec,
+ &IFarmMemberClaimFields {
+ member_pubkey: member_pubkey.clone(),
+ farm_pubkey: farm_pubkey.clone(),
+ },
+ ),
+ "claim",
+ );
+ let _ = unwrap_sql(
+ nostr_profile::create(
+ &exec,
+ &INostrProfileFields {
+ public_key: farm_pubkey.clone(),
+ profile_type: "farm".to_string(),
+ name: "farm profile".to_string(),
+ display_name: None,
+ about: None,
+ website: None,
+ picture: None,
+ banner: None,
+ nip05: None,
+ lud06: None,
+ lud16: None,
+ },
+ ),
+ "farm profile",
+ );
+ let _ = unwrap_sql(
+ nostr_profile::create(
+ &exec,
+ &INostrProfileFields {
+ public_key: member_pubkey.clone(),
+ profile_type: "legacy".to_string(),
+ name: "legacy profile".to_string(),
+ display_name: Some("legacy".to_string()),
+ about: Some("about".to_string()),
+ website: Some("https://example.com".to_string()),
+ picture: Some("https://example.com/p.png".to_string()),
+ banner: Some("https://example.com/b.png".to_string()),
+ nip05: Some("legacy@example.com".to_string()),
+ lud06: Some("lud06".to_string()),
+ lud16: Some("lud16".to_string()),
+ },
+ ),
+ "legacy profile",
+ );
+
+ let bundle = radroots_tangle_sync_all(
+ &exec,
+ &RadrootsTangleSyncRequest {
+ farm: RadrootsTangleFarmSelector {
+ id: Some(farm_row.id),
+ d_tag: None,
+ pubkey: None,
+ },
+ options: None,
+ },
+ )
+ .expect("sync");
+ assert_eq!(bundle.version, RADROOTS_TANGLE_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)
+ );
+ assert!(bundle.events.iter().any(|event| {
+ event.kind == KIND_PROFILE
+ && event.author == member_pubkey
+ && event
+ .tags
+ .iter()
+ .all(|tag| tag[0] != RADROOTS_PROFILE_TYPE_TAG_KEY)
+ }));
+}
+
+#[test]
+fn error_conversion_paths_are_exercised() {
+ let sql: RadrootsTangleEventsError = IError::from(SqlError::Internal).into();
+ assert!(matches!(sql, RadrootsTangleEventsError::Sql(_)));
+
+ let encode: RadrootsTangleEventsError = EventEncodeError::Json.into();
+ assert!(matches!(encode, RadrootsTangleEventsError::Encode(_)));
+
+ let parse_number_err = "x".parse::<u32>().expect_err("parse should fail");
+ let parse: RadrootsTangleEventsError =
+ EventParseError::InvalidNumber("k", parse_number_err).into();
+ assert!(matches!(parse, RadrootsTangleEventsError::Parse(_)));
+}