lib

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

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:
Mcrates/tangle-events/src/canonical.rs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/tangle-events/src/emit.rs | 658+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/tangle-events/src/error.rs | 48++++++++++++++++++++++++++++++++++++++++++------
Mcrates/tangle-events/src/event_state.rs | 43++++++++++++++++++++++++++++++++++++++++---
Mcrates/tangle-events/src/geo.rs | 37+++++++++++++++++++++++++++++++++++++
Mcrates/tangle-events/src/ingest.rs | 1042+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/tangle-events/src/sync_state.rs | 13++++---------
Acrates/tangle-events/tests/ingest_roundtrip.rs | 1427+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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( + &not_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( + &not_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( + &not_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( + &not_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( + &not_found_members, + &farm_id, + ListSetRole::Members, + &not_found_members_list_set, + ) + .is_ok() + ); + assert!( + upsert_farm_members( + &not_found_members, + &farm_id, + ListSetRole::Plots, + &not_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(&not_found_claims, &"m".repeat(64), &not_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(_))); +}