lib

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

commit 3538714739bf448682ecfabf5495818994bb8f9c
parent 581b94fca5cd0e9769b7afd864dbafb15758f9a3
Author: triesap <tyson@radroots.org>
Date:   Fri, 26 Jun 2026 05:35:23 +0000

events: enforce public location privacy

Diffstat:
Mcrates/events/src/coop.rs | 2+-
Mcrates/events/src/dto.rs | 44+++++++++++++++++++++++++++-----------------
Mcrates/events/src/farm.rs | 51++++-----------------------------------------------
Acrates/events/src/gcs.rs | 45+++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events/src/lib.rs | 2++
Mcrates/events/src/listing.rs | 8+++-----
Acrates/events/src/location.rs | 30++++++++++++++++++++++++++++++
Mcrates/events/src/order.rs | 15++++++---------
Mcrates/events/src/order_economics.rs | 10++--------
Mcrates/events/src/plot.rs | 2+-
Mcrates/events/src/resource_area.rs | 2+-
Mcrates/events/src/trade_validation.rs | 6++++++
Mcrates/events_codec/src/coop/mod.rs | 5++---
Mcrates/events_codec/src/farm/decode.rs | 66+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/events_codec/src/farm/encode.rs | 22+++++++++++++++-------
Mcrates/events_codec/src/farm/mod.rs | 114+++++++++++++++----------------------------------------------------------------
Mcrates/events_codec/src/listing/decode.rs | 96++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mcrates/events_codec/src/listing/tags.rs | 455++++++++++++-------------------------------------------------------------------
Mcrates/events_codec/src/plot/mod.rs | 5++---
Mcrates/events_codec/src/resource_area/mod.rs | 4+---
Mcrates/events_codec/tests/domain_encode_non_serde.rs | 148++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mcrates/events_codec/tests/listing.rs | 101+++++++++++++++++++++++++++++++------------------------------------------------
Mcrates/events_codec/tests/structured_decode.rs | 6++----
Mcrates/events_codec/tests/structured_encode_default.rs | 39+++++++++++++++------------------------
Mcrates/events_codec/tests/tag_builders.rs | 56+++++++++++++++++++++-----------------------------------
Mcrates/replica_sync/src/emit.rs | 77++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mcrates/replica_sync/src/geo.rs | 2+-
Mcrates/replica_sync/src/ingest.rs | 269+++++++++++++++----------------------------------------------------------------
Mcrates/replica_sync/src/tests.rs | 2+-
Mcrates/replica_sync/tests/ingest_roundtrip.rs | 26+++++++++++---------------
Mcrates/trade/src/dto.rs | 2+-
Mcrates/trade/src/listing/codec.rs | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mcrates/trade/src/listing/mutation.rs | 14++++++--------
Mcrates/trade/src/listing/validation.rs | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
34 files changed, 794 insertions(+), 1116 deletions(-)

diff --git a/crates/events/src/coop.rs b/crates/events/src/coop.rs @@ -1,6 +1,6 @@ #![forbid(unsafe_code)] -use crate::farm::RadrootsGcsLocation; +use crate::gcs::RadrootsGcsLocation; #[cfg(not(feature = "std"))] use alloc::{string::String, vec::Vec}; diff --git a/crates/events/src/dto.rs b/crates/events/src/dto.rs @@ -12,11 +12,9 @@ use crate::{ comment::RadrootsComment, coop::{RadrootsCoop, RadrootsCoopLocation, RadrootsCoopRef}, document::{RadrootsDocument, RadrootsDocumentSubject}, - farm::{ - RadrootsFarm, RadrootsFarmLocation, RadrootsFarmRef, RadrootsGcsLocation, - RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon, - }, + farm::{RadrootsFarm, RadrootsFarmPublicLocation, RadrootsFarmRef}, follow::{RadrootsFollow, RadrootsFollowProfile}, + gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon}, geochat::RadrootsGeoChat, gift_wrap::{RadrootsGiftWrap, RadrootsGiftWrapRecipient}, ids::{ @@ -32,7 +30,7 @@ use crate::{ listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingImageSize, - RadrootsListingLocation, RadrootsListingProduct, RadrootsListingStatus, + RadrootsListingProduct, RadrootsListingPublicLocation, RadrootsListingStatus, }, message::{RadrootsMessage, RadrootsMessageRecipient}, message_file::{RadrootsMessageFile, RadrootsMessageFileDimensions}, @@ -76,7 +74,7 @@ pub fn dto_roots() -> Vec<RootDescriptor> { RootDescriptor::new::<RadrootsDocument>(), RootDescriptor::new::<RadrootsDocumentSubject>(), RootDescriptor::new::<RadrootsFarm>(), - RootDescriptor::new::<RadrootsFarmLocation>(), + RootDescriptor::new::<RadrootsFarmPublicLocation>(), RootDescriptor::new::<RadrootsFarmRef>(), RootDescriptor::new::<RadrootsFollow>(), RootDescriptor::new::<RadrootsFollowProfile>(), @@ -101,7 +99,7 @@ pub fn dto_roots() -> Vec<RootDescriptor> { RootDescriptor::new::<RadrootsNostrEvent>(), RootDescriptor::new::<RadrootsNostrEventRef>(), RootDescriptor::new::<RadrootsNostrEventPtr>(), - RootDescriptor::new::<RadrootsListingLocation>(), + RootDescriptor::new::<RadrootsListingPublicLocation>(), RootDescriptor::new::<RadrootsListingProduct>(), RootDescriptor::new::<RadrootsListingStatus>(), RootDescriptor::new::<RadrootsListingImage>(), @@ -758,7 +756,7 @@ impl Dto for RadrootsListing { .with_field(optional_nullable_field( "location", "location", - <Option<RadrootsListingLocation> as Dto>::describe(ctx), + <Option<RadrootsListingPublicLocation> as Dto>::describe(ctx), "crates/events/src/listing.rs", 78, )) @@ -1574,7 +1572,11 @@ impl Dto for RadrootsCommercialTransportLaneDto { "crates/events/src/order.rs", 408, )); - register(ctx, "RadrootsCommercialTransportLaneDto", TypeDef::Enum(def)) + register( + ctx, + "RadrootsCommercialTransportLaneDto", + TypeDef::Enum(def), + ) } } @@ -1695,7 +1697,11 @@ impl Dto for RadrootsListingDiscountRequestDto { "crates/events/src/order.rs", 408, )); - register(ctx, "RadrootsListingDiscountRequestDto", TypeDef::Struct(def)) + register( + ctx, + "RadrootsListingDiscountRequestDto", + TypeDef::Struct(def), + ) } } @@ -1759,7 +1765,11 @@ impl Dto for RadrootsListingDiscountDecisionDto { )]), span("crates/events/src/order.rs", 408), )); - register(ctx, "RadrootsListingDiscountDecisionDto", TypeDef::Enum(def)) + register( + ctx, + "RadrootsListingDiscountDecisionDto", + TypeDef::Enum(def), + ) } } @@ -1783,11 +1793,7 @@ impl Dto for RadrootsListingCancelDto { impl Dto for RadrootsOrderResponseDto { fn describe(ctx: &mut DescribeCtx) -> TypeRef { - accepted_reason_struct( - ctx, - "RadrootsOrderResponseDto", - "RadrootsOrderResponse", - ) + accepted_reason_struct(ctx, "RadrootsOrderResponseDto", "RadrootsOrderResponse") } } @@ -1967,7 +1973,11 @@ impl Dto for RadrootsCommercialMessagePayloadDto { "cancel", RadrootsListingCancelDto::describe(ctx), )); - register(ctx, "RadrootsCommercialMessagePayloadDto", TypeDef::Enum(def)) + register( + ctx, + "RadrootsCommercialMessagePayloadDto", + TypeDef::Enum(def), + ) } } diff --git a/crates/events/src/farm.rs b/crates/events/src/farm.rs @@ -11,7 +11,7 @@ pub struct RadrootsFarm { pub website: Option<String>, pub picture: Option<String>, pub banner: Option<String>, - pub location: Option<RadrootsFarmLocation>, + pub location: Option<RadrootsFarmPublicLocation>, pub tags: Option<Vec<String>>, } @@ -26,53 +26,10 @@ pub struct RadrootsFarmRef { #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] -pub struct RadrootsGeoJsonPoint { - #[cfg_attr(feature = "serde", serde(rename = "type"))] - pub r#type: String, - pub coordinates: [f64; 2], -} - -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Debug)] -pub struct RadrootsGeoJsonPolygon { - #[cfg_attr(feature = "serde", serde(rename = "type"))] - pub r#type: String, - pub coordinates: Vec<Vec<[f64; 2]>>, -} - -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Debug)] -pub struct RadrootsGcsLocation { - pub lat: f64, - pub lng: f64, - pub geohash: String, - pub point: RadrootsGeoJsonPoint, - pub polygon: RadrootsGeoJsonPolygon, - pub accuracy: Option<f64>, - pub altitude: Option<f64>, - pub tag_0: Option<String>, - pub label: Option<String>, - pub area: Option<f64>, - pub elevation: Option<u32>, - pub soil: Option<String>, - pub climate: Option<String>, - pub gc_id: Option<String>, - pub gc_name: Option<String>, - pub gc_admin1_id: Option<String>, - pub gc_admin1_name: Option<String>, - pub gc_country_id: Option<String>, - pub gc_country_name: Option<String>, -} - -#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Debug)] -pub struct RadrootsFarmLocation { - pub primary: Option<String>, +pub struct RadrootsFarmPublicLocation { + pub primary: String, pub city: Option<String>, pub region: Option<String>, pub country: Option<String>, - pub gcs: Option<RadrootsGcsLocation>, + pub geohash: String, } diff --git a/crates/events/src/gcs.rs b/crates/events/src/gcs.rs @@ -0,0 +1,45 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsGeoJsonPoint { + #[cfg_attr(feature = "serde", serde(rename = "type"))] + pub r#type: String, + pub coordinates: [f64; 2], +} + +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsGeoJsonPolygon { + #[cfg_attr(feature = "serde", serde(rename = "type"))] + pub r#type: String, + pub coordinates: Vec<Vec<[f64; 2]>>, +} + +#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsGcsLocation { + pub lat: f64, + pub lng: f64, + pub geohash: String, + pub point: RadrootsGeoJsonPoint, + pub polygon: RadrootsGeoJsonPolygon, + pub accuracy: Option<f64>, + pub altitude: Option<f64>, + pub tag_0: Option<String>, + pub label: Option<String>, + pub area: Option<f64>, + pub elevation: Option<u32>, + pub soil: Option<String>, + pub climate: Option<String>, + pub gc_id: Option<String>, + pub gc_name: Option<String>, + pub gc_admin1_id: Option<String>, + pub gc_admin1_name: Option<String>, + pub gc_country_id: Option<String>, + pub gc_country_name: Option<String>, +} diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs @@ -24,6 +24,7 @@ pub mod farm_file; pub mod farm_workspace; pub mod file_metadata; pub mod follow; +pub mod gcs; pub mod geochat; pub mod gift_wrap; pub mod group; @@ -37,6 +38,7 @@ pub mod kinds; pub mod list; pub mod list_set; pub mod listing; +pub mod location; pub mod message; pub mod message_file; pub mod order; diff --git a/crates/events/src/listing.rs b/crates/events/src/listing.rs @@ -72,7 +72,7 @@ pub struct RadrootsListing { pub inventory_available: Option<RadrootsCoreDecimal>, pub availability: Option<RadrootsListingAvailability>, pub delivery_method: Option<RadrootsListingDeliveryMethod>, - pub location: Option<RadrootsListingLocation>, + pub location: Option<RadrootsListingPublicLocation>, pub images: Option<Vec<RadrootsListingImage>>, } @@ -112,14 +112,12 @@ pub struct RadrootsListingBin { #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] -pub struct RadrootsListingLocation { +pub struct RadrootsListingPublicLocation { pub primary: String, pub city: Option<String>, pub region: Option<String>, pub country: Option<String>, - pub lat: Option<f64>, - pub lng: Option<f64>, - pub geohash: Option<String>, + pub geohash: String, } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] diff --git a/crates/events/src/location.rs b/crates/events/src/location.rs @@ -0,0 +1,30 @@ +pub const RADROOTS_PUBLIC_GEOHASH_PRECISION: usize = 5; +pub const RADROOTS_PUBLIC_GEOHASH_BASE32: &str = "0123456789bcdefghjkmnpqrstuvwxyz"; + +pub fn is_public_geohash5(value: &str) -> bool { + let value = value.trim(); + value.len() == RADROOTS_PUBLIC_GEOHASH_PRECISION + && value.bytes().all(|byte| { + RADROOTS_PUBLIC_GEOHASH_BASE32 + .as_bytes() + .contains(&byte.to_ascii_lowercase()) + }) +} + +pub fn has_textual_locality( + primary: &str, + city: Option<&str>, + region: Option<&str>, + country: Option<&str>, +) -> bool { + has_public_location_text(primary) + && [city, region, country] + .into_iter() + .flatten() + .any(has_public_location_text) +} + +fn has_public_location_text(value: &str) -> bool { + let value = value.trim(); + !value.is_empty() && !value.eq_ignore_ascii_case("null") +} diff --git a/crates/events/src/order.rs b/crates/events/src/order.rs @@ -283,7 +283,10 @@ pub struct RadrootsOrderInventoryCommitment { } #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr(feature = "dto-bindgen", dto(ts(name = "RadrootsOrderDecisionOutcome")))] +#[cfg_attr( + feature = "dto-bindgen", + dto(ts(name = "RadrootsOrderDecisionOutcome")) +)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "decision"))] #[derive(Clone, Debug, PartialEq, Eq)] @@ -308,10 +311,7 @@ impl RadrootsOrderDecisionOutcome { } #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr( - feature = "dto-bindgen", - dto(ts(name = "RadrootsOrderDecision")) -)] +#[cfg_attr(feature = "dto-bindgen", dto(ts(name = "RadrootsOrderDecision")))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsOrderDecision { @@ -365,10 +365,7 @@ pub enum RadrootsCommercialDomain { } #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr( - feature = "dto-bindgen", - dto(ts(name = "RadrootsOrderEventType")) -)] +#[cfg_attr(feature = "dto-bindgen", dto(ts(name = "RadrootsOrderEventType")))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RadrootsOrderEventType { diff --git a/crates/events/src/order_economics.rs b/crates/events/src/order_economics.rs @@ -82,10 +82,7 @@ pub struct RadrootsOrderEconomicItem { } #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr( - feature = "dto-bindgen", - dto(ts(name = "RadrootsOrderEconomicLine")) -)] +#[cfg_attr(feature = "dto-bindgen", dto(ts(name = "RadrootsOrderEconomicLine")))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsOrderEconomicLine { @@ -98,10 +95,7 @@ pub struct RadrootsOrderEconomicLine { } #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] -#[cfg_attr( - feature = "dto-bindgen", - dto(ts(name = "RadrootsOrderEconomicTotals")) -)] +#[cfg_attr(feature = "dto-bindgen", dto(ts(name = "RadrootsOrderEconomicTotals")))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsOrderEconomicTotals { diff --git a/crates/events/src/plot.rs b/crates/events/src/plot.rs @@ -1,4 +1,4 @@ -use crate::farm::{RadrootsFarmRef, RadrootsGcsLocation}; +use crate::{farm::RadrootsFarmRef, gcs::RadrootsGcsLocation}; #[cfg(not(feature = "std"))] use alloc::{string::String, vec::Vec}; diff --git a/crates/events/src/resource_area.rs b/crates/events/src/resource_area.rs @@ -1,6 +1,6 @@ #![forbid(unsafe_code)] -use crate::farm::RadrootsGcsLocation; +use crate::gcs::RadrootsGcsLocation; #[cfg(not(feature = "std"))] use alloc::{string::String, vec::Vec}; diff --git a/crates/events/src/trade_validation.rs b/crates/events/src/trade_validation.rs @@ -32,6 +32,9 @@ pub enum RadrootsTradeValidationListingError { InvalidInventory, MissingAvailability, MissingLocation, + MissingLocationLocality, + MissingLocationGeohash, + InvalidLocationGeohash, MissingDeliveryMethod, } @@ -62,6 +65,9 @@ impl core::fmt::Display for RadrootsTradeValidationListingError { Self::InvalidInventory => write!(f, "invalid listing inventory"), Self::MissingAvailability => write!(f, "missing listing availability"), Self::MissingLocation => write!(f, "missing listing location"), + Self::MissingLocationLocality => write!(f, "missing listing location locality"), + Self::MissingLocationGeohash => write!(f, "missing listing location geohash"), + Self::InvalidLocationGeohash => write!(f, "invalid listing location geohash"), Self::MissingDeliveryMethod => write!(f, "missing listing delivery method"), } } diff --git a/crates/events_codec/src/coop/mod.rs b/crates/events_codec/src/coop/mod.rs @@ -15,9 +15,8 @@ mod tests { }; use crate::error::EventEncodeError; use radroots_events::coop::{RadrootsCoop, RadrootsCoopLocation, RadrootsCoopRef}; - use radroots_events::farm::{ - RadrootsFarmRef, RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon, - }; + use radroots_events::farm::RadrootsFarmRef; + use radroots_events::gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon}; #[cfg(feature = "serde_json")] use radroots_events::kinds::KIND_COOP; diff --git a/crates/events_codec/src/farm/decode.rs b/crates/events_codec/src/farm/decode.rs @@ -6,13 +6,20 @@ use alloc::{ vec::Vec, }; -use radroots_events::{RadrootsNostrEvent, farm::RadrootsFarm, kinds::KIND_FARM, tags::TAG_D}; +use radroots_events::{ + RadrootsNostrEvent, + farm::RadrootsFarm, + kinds::KIND_FARM, + location::{has_textual_locality, is_public_geohash5}, + tags::TAG_D, +}; use crate::d_tag::validate_d_tag_tag; use crate::error::EventParseError; use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent}; const DEFAULT_KIND: u32 = KIND_FARM; +const TAG_G: &str = "g"; fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, EventParseError> { let tag = tags @@ -45,6 +52,7 @@ pub fn farm_from_event( return Err(EventParseError::InvalidJson("content")); } let d_tag = parse_d_tag(tags)?; + reject_private_farm_location_tags(tags)?; reject_private_farm_ops_content(content)?; let mut farm: RadrootsFarm = serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?; @@ -54,10 +62,47 @@ pub fn farm_from_event( } else if farm.d_tag != d_tag { return Err(EventParseError::InvalidTag(TAG_D)); } + if let Some(location) = farm.location.as_ref() { + if !is_public_geohash5(&location.geohash) + || !has_textual_locality( + &location.primary, + location.city.as_deref(), + location.region.as_deref(), + location.country.as_deref(), + ) + { + return Err(EventParseError::InvalidTag(TAG_G)); + } + } Ok(farm) } +fn reject_private_farm_location_tags(tags: &[Vec<String>]) -> Result<(), EventParseError> { + for tag in tags { + let Some(key) = tag.first().map(|value| value.as_str()) else { + continue; + }; + match key { + TAG_G => { + let Some(value) = tag.get(1).map(|value| value.trim()) else { + return Err(EventParseError::InvalidTag(TAG_G)); + }; + if !is_public_geohash5(value) { + return Err(EventParseError::InvalidTag(TAG_G)); + } + } + "dd" => return Err(EventParseError::InvalidTag("dd")), + "dd.lat" => return Err(EventParseError::InvalidTag("dd.lat")), + "dd.lon" => return Err(EventParseError::InvalidTag("dd.lon")), + "l" => return Err(EventParseError::InvalidTag("l")), + "L" => return Err(EventParseError::InvalidTag("L")), + _ => {} + } + } + Ok(()) +} + fn reject_private_farm_ops_content(content: &str) -> Result<(), EventParseError> { let value: serde_json::Value = serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?; @@ -83,6 +128,25 @@ fn reject_private_farm_ops_content(content: &str) -> Result<(), EventParseError> return Err(EventParseError::InvalidJson("content")); } } + if let Some(location) = object.get("location").and_then(|value| value.as_object()) { + for key in [ + "gcs", + "lat", + "lng", + "lon", + "point", + "polygon", + "coordinates", + "accuracy", + "altitude", + "label", + "tag_0", + ] { + if location.contains_key(key) { + return Err(EventParseError::InvalidJson("content")); + } + } + } Ok(()) } diff --git a/crates/events_codec/src/farm/encode.rs b/crates/events_codec/src/farm/encode.rs @@ -7,6 +7,7 @@ use alloc::{ use radroots_events::{ farm::{RadrootsFarm, RadrootsFarmRef}, kinds::KIND_FARM, + location::{has_textual_locality, is_public_geohash5}, tags::TAG_D, }; @@ -38,14 +39,21 @@ pub fn farm_build_tags(farm: &RadrootsFarm) -> Result<Vec<Vec<String>>, EventEnc push_tag(&mut tags, TAG_T, item); } } - if let Some(geohash) = farm - .location - .as_ref() - .and_then(|location| location.gcs.as_ref()) - .map(|gcs| gcs.geohash.trim()) - { + if let Some(location) = farm.location.as_ref() { + if !has_textual_locality( + &location.primary, + location.city.as_deref(), + location.region.as_deref(), + location.country.as_deref(), + ) { + return Err(EventEncodeError::EmptyRequiredField("location.locality")); + } + let geohash = location.geohash.trim(); if geohash.is_empty() { - return Err(EventEncodeError::EmptyRequiredField("location.gcs.geohash")); + return Err(EventEncodeError::EmptyRequiredField("location.geohash")); + } + if !is_public_geohash5(geohash) { + return Err(EventEncodeError::InvalidField("location.geohash")); } push_tag(&mut tags, TAG_G, geohash); } diff --git a/crates/events_codec/src/farm/mod.rs b/crates/events_codec/src/farm/mod.rs @@ -16,10 +16,7 @@ mod tests { RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, }; - use radroots_events::farm::{ - RadrootsFarm, RadrootsFarmLocation, RadrootsFarmRef, RadrootsGcsLocation, - RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon, - }; + use radroots_events::farm::{RadrootsFarm, RadrootsFarmPublicLocation, RadrootsFarmRef}; use radroots_events::ids::{RadrootsDTag, RadrootsInventoryBinId}; #[cfg(feature = "serde_json")] use radroots_events::kinds::KIND_FARM; @@ -43,43 +40,12 @@ mod tests { website: None, picture: None, banner: None, - location: Some(RadrootsFarmLocation { - primary: None, - city: None, - region: None, - country: None, - gcs: Some(RadrootsGcsLocation { - lat: 37.0, - lng: -122.0, - geohash: "9q8yy".to_string(), - point: RadrootsGeoJsonPoint { - r#type: "Point".to_string(), - coordinates: [-122.0, 37.0], - }, - polygon: RadrootsGeoJsonPolygon { - r#type: "Polygon".to_string(), - coordinates: vec![vec![ - [-122.0, 37.0], - [-122.0, 37.0001], - [-122.0001, 37.0001], - [-122.0, 37.0], - ]], - }, - 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, - }), + location: Some(RadrootsFarmPublicLocation { + primary: "Test Farm".to_string(), + city: Some("Santa Cruz".to_string()), + region: Some("California".to_string()), + country: Some("US".to_string()), + geohash: "9q8yy".to_string(), }), tags: Some(vec!["orchard".to_string()]), }; @@ -209,43 +175,12 @@ mod tests { website: None, picture: None, banner: None, - location: Some(RadrootsFarmLocation { - primary: None, - city: None, - region: None, - country: None, - gcs: Some(RadrootsGcsLocation { - lat: 37.0, - lng: -122.0, - geohash: "9q8yy".to_string(), - point: RadrootsGeoJsonPoint { - r#type: "Point".to_string(), - coordinates: [-122.0, 37.0], - }, - polygon: RadrootsGeoJsonPolygon { - r#type: "Polygon".to_string(), - coordinates: vec![vec![ - [-122.0, 37.0], - [-122.0, 37.0001], - [-122.0001, 37.0001], - [-122.0, 37.0], - ]], - }, - 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, - }), + location: Some(RadrootsFarmPublicLocation { + primary: "Test Farm".to_string(), + city: Some("Santa Cruz".to_string()), + region: Some("California".to_string()), + country: Some("US".to_string()), + geohash: "9q8yy".to_string(), }), tags: None, }; @@ -260,26 +195,19 @@ mod tests { assert!(matches!(err, EventEncodeError::EmptyRequiredField("name"))); farm.name = "Test Farm".to_string(); - farm.location - .as_mut() - .expect("location") - .gcs - .as_mut() - .expect("gcs") - .geohash = " ".to_string(); + farm.location.as_mut().expect("location").geohash = " ".to_string(); let err = farm_build_tags(&farm).expect_err("expected empty geohash"); assert!(matches!( err, - EventEncodeError::EmptyRequiredField("location.gcs.geohash") + EventEncodeError::EmptyRequiredField("location.geohash") )); - farm.location.as_mut().expect("location").gcs = None; - let tags = farm_build_tags(&farm).expect("string-only farm location should be allowed"); - assert!( - !tags - .iter() - .any(|tag| tag.get(0).map(|v| v.as_str()) == Some("g")) - ); + farm.location.as_mut().expect("location").geohash = "9q8yy6".to_string(); + let err = farm_build_tags(&farm).expect_err("expected invalid geohash"); + assert!(matches!( + err, + EventEncodeError::InvalidField("location.geohash") + )); let err = farm_ref_tags(&RadrootsFarmRef { pubkey: " ".to_string(), diff --git a/crates/events_codec/src/listing/decode.rs b/crates/events_codec/src/listing/decode.rs @@ -18,8 +18,9 @@ use radroots_events::{ listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingImageSize, - RadrootsListingLocation, RadrootsListingProduct, RadrootsListingStatus, + RadrootsListingProduct, RadrootsListingPublicLocation, RadrootsListingStatus, }, + location::is_public_geohash5, plot::RadrootsPlotRef, resource_area::RadrootsResourceAreaRef, tags::{TAG_D, TAG_PUBLISHED_AT}, @@ -42,18 +43,61 @@ const TAG_RADROOTS_PLOT: &str = "radroots:plot"; const TAG_LOCATION: &str = "location"; const TAG_IMAGE: &str = "image"; const TAG_GEOHASH: &str = "g"; +const TAG_LABEL: &str = "l"; +const TAG_LABEL_NS: &str = "L"; +const TAG_DD: &str = "dd"; +const TAG_DD_LAT: &str = "dd.lat"; +const TAG_DD_LON: &str = "dd.lon"; const TAG_INVENTORY: &str = "inventory"; const TAG_DELIVERY: &str = "delivery"; const TAG_RADROOTS_AVAILABILITY_START: &str = "radroots:availability_start"; const TAG_STATUS: &str = "status"; const TAG_EXPIRES_AT: &str = "expires_at"; +struct ListingLocationDraft { + primary: String, + city: Option<String>, + region: Option<String>, + country: Option<String>, +} + fn parse_decimal(value: &str, field: &'static str) -> Result<RadrootsCoreDecimal, EventParseError> { value .parse::<RadrootsCoreDecimal>() .map_err(|_| EventParseError::InvalidTag(field)) } +fn reject_private_listing_location_content(content: &str) -> Result<(), EventParseError> { + let trimmed = content.trim(); + if !trimmed.starts_with('{') { + return Ok(()); + } + let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else { + return Ok(()); + }; + let Some(location) = value.get("location").and_then(|value| value.as_object()) else { + return Ok(()); + }; + for key in [ + "lat", + "lng", + "lon", + "point", + "polygon", + "coordinates", + "accuracy", + "altitude", + "label", + "tag_0", + "gcs", + ] { + if location.contains_key(key) { + return Err(EventParseError::InvalidJson("content")); + } + } + Ok(()) +} + fn parse_currency( value: &str, field: &'static str, @@ -229,8 +273,9 @@ pub fn listing_from_event( pub fn listing_from_event_parts( tags: &[Vec<String>], - _content: &str, + content: &str, ) -> Result<RadrootsListing, EventParseError> { + reject_private_listing_location_content(content)?; let d_tag = parse_d_tag(tags)?; let farm_ref = parse_farm_ref(tags)?; let farm_pubkey = parse_farm_pubkey(tags)?; @@ -252,7 +297,7 @@ pub fn listing_from_event_parts( let mut bin_drafts: Vec<BinDraft> = Vec::new(); let mut bin_order = 0usize; let mut discounts: Vec<RadrootsCoreDiscount> = Vec::new(); - let mut location: Option<RadrootsListingLocation> = None; + let mut location: Option<ListingLocationDraft> = None; let mut inventory_available: Option<RadrootsCoreDecimal> = None; let mut availability_status: Option<RadrootsListingStatus> = None; let mut availability_start: Option<u64> = None; @@ -262,10 +307,6 @@ pub fn listing_from_event_parts( let mut geohash: Option<String> = None; let mut published_at: Option<u64> = None; - let has_structured_location = tags - .iter() - .any(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_LOCATION) && tag.len() >= 3); - for tag in tags { if tag.is_empty() { continue; @@ -283,7 +324,7 @@ pub fn listing_from_event_parts( "location" => { let parse_structured_location = match tag.len() { 0 | 1 => false, - 2 => !has_structured_location && location.is_none(), + 2 => false, _ => true, }; if parse_structured_location { @@ -291,14 +332,11 @@ pub fn listing_from_event_parts( .get(1) .and_then(|value| clean_value(value)) .ok_or(EventParseError::InvalidTag(TAG_LOCATION))?; - let mut parsed = RadrootsListingLocation { + let mut parsed = ListingLocationDraft { primary, city: None, region: None, country: None, - lat: None, - lng: None, - geohash: None, }; if let Some(city) = tag.get(2).and_then(|value| clean_value(value)) { parsed.city = Some(city); @@ -409,10 +447,24 @@ pub fn listing_from_event_parts( discounts.push(parse_discount(payload)?); } TAG_GEOHASH => { - if let Some(value) = tag.get(1).and_then(|value| clean_value(value)) { - geohash = Some(value); + let value = tag + .get(1) + .and_then(|value| clean_value(value)) + .ok_or(EventParseError::InvalidTag(TAG_GEOHASH))?; + if !is_public_geohash5(&value) { + return Err(EventParseError::InvalidTag(TAG_GEOHASH)); } + let value = value.to_ascii_lowercase(); + if geohash.as_ref().is_some_and(|existing| existing != &value) { + return Err(EventParseError::InvalidTag(TAG_GEOHASH)); + } + geohash = Some(value); } + TAG_DD => return Err(EventParseError::InvalidTag(TAG_DD)), + TAG_DD_LAT => return Err(EventParseError::InvalidTag(TAG_DD_LAT)), + TAG_DD_LON => return Err(EventParseError::InvalidTag(TAG_DD_LON)), + TAG_LABEL => return Err(EventParseError::InvalidTag(TAG_LABEL)), + TAG_LABEL_NS => return Err(EventParseError::InvalidTag(TAG_LABEL_NS)), TAG_INVENTORY => { let value = tag .get(1) @@ -477,10 +529,18 @@ pub fn listing_from_event_parts( }, }; - let location = location.map(|mut location| { - location.geohash = location.geohash.or(geohash); - location - }); + let location = location + .map(|location| { + let geohash = geohash.ok_or(EventParseError::InvalidTag(TAG_GEOHASH))?; + Ok(RadrootsListingPublicLocation { + primary: location.primary, + city: location.city, + region: location.region, + country: location.country, + geohash, + }) + }) + .transpose()?; if farm_pubkey != farm_ref.pubkey { return Err(EventParseError::InvalidTag(TAG_P)); diff --git a/crates/events_codec/src/listing/tags.rs b/crates/events_codec/src/listing/tags.rs @@ -8,8 +8,6 @@ use alloc::{ vec::Vec, }; -use core::cmp; - #[cfg(any(feature = "serde_json", test))] use radroots_core::RadrootsCoreDiscount; use radroots_core::RadrootsCoreMoney; @@ -17,9 +15,10 @@ use radroots_events::farm::RadrootsFarmRef; use radroots_events::kinds::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA}; use radroots_events::listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, - RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingLocation, + RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingPublicLocation, RadrootsListingStatus, }; +use radroots_events::location::{has_textual_locality, is_public_geohash5}; use radroots_events::plot::RadrootsPlotRef; use radroots_events::resource_area::RadrootsResourceAreaRef; use radroots_events::tags::{TAG_D, TAG_PUBLISHED_AT}; @@ -38,11 +37,6 @@ const TAG_RADROOTS_PLOT: &str = "radroots:plot"; const TAG_LOCATION: &str = "location"; const TAG_IMAGE: &str = "image"; const TAG_GEOHASH: &str = "g"; -const TAG_LABEL: &str = "l"; -const TAG_LABEL_NS: &str = "L"; -const TAG_DD: &str = "dd"; -const TAG_DD_LAT: &str = "dd.lat"; -const TAG_DD_LON: &str = "dd.lon"; const TAG_INVENTORY: &str = "inventory"; const TAG_DELIVERY: &str = "delivery"; const TAG_RADROOTS_AVAILABILITY_START: &str = "radroots:availability_start"; @@ -51,17 +45,9 @@ const TAG_EXPIRES_AT: &str = "expires_at"; const TAG_P: &str = "p"; const TAG_A: &str = "a"; -const GEOHASH_PRECISION_DEFAULT: usize = 9; -const DD_MAX_RESOLUTION_DEFAULT: u32 = 9; - -const BASE32_CODES: &[u8; 32] = b"0123456789bcdefghjkmnpqrstuvwxyz"; - #[derive(Clone, Copy, Debug)] pub struct ListingTagOptions { - pub geohash_precision: usize, - pub dd_max_resolution: u32, pub include_geohash: bool, - pub include_gps: bool, pub include_inventory: bool, pub include_availability: bool, pub include_delivery: bool, @@ -70,10 +56,7 @@ pub struct ListingTagOptions { impl Default for ListingTagOptions { fn default() -> Self { Self { - geohash_precision: GEOHASH_PRECISION_DEFAULT, - dd_max_resolution: DD_MAX_RESOLUTION_DEFAULT, include_geohash: true, - include_gps: true, include_inventory: false, include_availability: false, include_delivery: false, @@ -230,9 +213,17 @@ pub fn listing_tags_with_options( tags.push(tag); } - if let Some(location) = &listing.location - && let Some(primary) = clean_value(&location.primary) - { + if let Some(location) = &listing.location { + let primary = clean_value(&location.primary) + .ok_or(EventEncodeError::EmptyRequiredField("location.primary"))?; + if !has_textual_locality( + &location.primary, + location.city.as_deref(), + location.region.as_deref(), + location.country.as_deref(), + ) { + return Err(EventEncodeError::EmptyRequiredField("location.locality")); + } let mut tag = Vec::with_capacity(5); tag.push(TAG_LOCATION.to_string()); tag.push(primary); @@ -246,8 +237,8 @@ pub fn listing_tags_with_options( tag.push(country); } tags.push(tag); - if options.include_geohash || options.include_gps { - push_location_geotags(&mut tags, location, options); + if options.include_geohash { + push_location_geotag(&mut tags, location)?; } } @@ -408,169 +399,17 @@ fn tag_listing_image(image: &RadrootsListingImage) -> Option<Vec<String>> { Some(tag) } -fn push_location_geotags( +fn push_location_geotag( tags: &mut Vec<Vec<String>>, - location: &RadrootsListingLocation, - options: ListingTagOptions, -) { - let mut lat = location.lat.filter(|value| value.is_finite()); - let mut lon = location.lng.filter(|value| value.is_finite()); - let location_geohash = location.geohash.as_deref().and_then(clean_value); - - let geohash = if options.include_geohash { - if let (Some(lat), Some(lon)) = (lat, lon) { - let precision = options.geohash_precision.max(1); - Some(geohash_encode(lat, lon, precision)) - } else { - location_geohash.clone() - } - } else { - None - }; - - if let Some(geohash) = geohash.as_ref() { - for idx in (1..=geohash.len()).rev() { - tags.push(vec![TAG_GEOHASH.to_string(), geohash[..idx].to_string()]); - } - } - - if options.include_gps { - if (lat.is_none() || lon.is_none()) - && let Some(geohash) = geohash.as_deref().or(location_geohash.as_deref()) - && let Some((decoded_lat, decoded_lon)) = geohash_decode(geohash) - { - lat = Some(decoded_lat); - lon = Some(decoded_lon); - } - if let (Some(lat), Some(lon)) = (lat, lon) { - tags.push(vec![ - TAG_LABEL.to_string(), - format!("{lat}, {lon}"), - TAG_DD.to_string(), - ]); - let max_resolution = options.dd_max_resolution.max(1); - let lat_resolution = calculate_resolution(lat, max_resolution); - let lon_resolution = calculate_resolution(lon, max_resolution); - tags.push(vec![TAG_LABEL_NS.to_string(), TAG_DD_LAT.to_string()]); - for idx in (1..=lat_resolution).rev() { - let truncated = truncate_to_resolution(lat, idx); - tags.push(vec![ - TAG_LABEL.to_string(), - truncated.to_string(), - TAG_DD_LAT.to_string(), - ]); - } - tags.push(vec![TAG_LABEL_NS.to_string(), TAG_DD_LON.to_string()]); - for idx in (1..=lon_resolution).rev() { - let truncated = truncate_to_resolution(lon, idx); - tags.push(vec![ - TAG_LABEL.to_string(), - truncated.to_string(), - TAG_DD_LON.to_string(), - ]); - } - } - } -} - -fn calculate_resolution(value: f64, max: u32) -> u32 { - let s = value.to_string(); - let decimals = s.split('.').nth(1).map(|v| v.len() as u32).unwrap_or(0); - cmp::min(decimals, max.max(1)).max(1) -} - -fn truncate_to_resolution(value: f64, resolution: u32) -> f64 { - let multiplier = 10_f64.powi(resolution as i32); - (value * multiplier).floor() / multiplier -} - -fn geohash_encode(latitude: f64, longitude: f64, precision: usize) -> String { - let precision = precision.max(1); - let mut out = String::with_capacity(precision); - let mut bits: u8 = 0; - let mut bits_total: u8 = 0; - let mut hash_value: u8 = 0; - let mut max_lat = 90.0; - let mut min_lat = -90.0; - let mut max_lon = 180.0; - let mut min_lon = -180.0; - - while out.len() < precision { - if bits_total.is_multiple_of(2) { - let mid = (max_lon + min_lon) / 2.0; - if longitude > mid { - hash_value = (hash_value << 1) + 1; - min_lon = mid; - } else { - hash_value <<= 1; - max_lon = mid; - } - } else { - let mid = (max_lat + min_lat) / 2.0; - if latitude > mid { - hash_value = (hash_value << 1) + 1; - min_lat = mid; - } else { - hash_value <<= 1; - max_lat = mid; - } - } - bits += 1; - bits_total += 1; - if bits == 5 { - out.push(BASE32_CODES[hash_value as usize] as char); - bits = 0; - hash_value = 0; - } - } - out -} - -fn geohash_decode(hash: &str) -> Option<(f64, f64)> { - let (min_lat, min_lon, max_lat, max_lon) = geohash_decode_bbox(hash)?; - let lat = (min_lat + max_lat) / 2.0; - let lon = (min_lon + max_lon) / 2.0; - Some((lat, lon)) -} - -fn geohash_decode_bbox(hash: &str) -> Option<(f64, f64, f64, f64)> { - let mut is_lon = true; - let mut max_lat = 90.0; - let mut min_lat = -90.0; - let mut max_lon = 180.0; - let mut min_lon = -180.0; - - for b in hash.bytes() { - let value = base32_value(b)?; - for bits in (0..5).rev() { - let bit = (value >> bits) & 1; - if is_lon { - let mid = (max_lon + min_lon) / 2.0; - if bit == 1 { - min_lon = mid; - } else { - max_lon = mid; - } - } else { - let mid = (max_lat + min_lat) / 2.0; - if bit == 1 { - min_lat = mid; - } else { - max_lat = mid; - } - } - is_lon = !is_lon; - } + location: &RadrootsListingPublicLocation, +) -> Result<(), EventEncodeError> { + let geohash = clean_value(&location.geohash) + .ok_or(EventEncodeError::EmptyRequiredField("location.geohash"))?; + if !is_public_geohash5(&geohash) { + return Err(EventEncodeError::InvalidField("location.geohash")); } - Some((min_lat, min_lon, max_lat, max_lon)) -} - -fn base32_value(c: u8) -> Option<u8> { - let needle = c.to_ascii_lowercase(); - BASE32_CODES - .iter() - .position(|&b| b == needle) - .map(|idx| idx as u8) + tags.push(vec![TAG_GEOHASH.to_string(), geohash.to_ascii_lowercase()]); + Ok(()) } fn push_tag_value(tags: &mut Vec<Vec<String>>, key: &str, value: &str) { @@ -702,14 +541,12 @@ mod tests { end: Some(20), }), delivery_method: Some(RadrootsListingDeliveryMethod::Pickup), - location: Some(RadrootsListingLocation { + location: Some(RadrootsListingPublicLocation { primary: "Moyobamba".to_string(), city: Some("Moyobamba".to_string()), region: Some("San Martin".to_string()), country: Some("PE".to_string()), - lat: Some(-6.0346), - lng: Some(-76.9714), - geohash: None, + geohash: "9q8yy".to_string(), }), images: Some(vec![ RadrootsListingImage { @@ -733,14 +570,12 @@ mod tests { fn options_defaults_and_trade_fields() { let defaults = ListingTagOptions::default(); assert!(defaults.include_geohash); - assert!(defaults.include_gps); assert!(!defaults.include_inventory); assert!(!defaults.include_availability); assert!(!defaults.include_delivery); - assert_eq!(defaults.geohash_precision, GEOHASH_PRECISION_DEFAULT); - assert_eq!(defaults.dd_max_resolution, DD_MAX_RESOLUTION_DEFAULT); let trade = ListingTagOptions::with_trade_fields(); + assert!(trade.include_geohash); assert!(trade.include_inventory); assert!(trade.include_availability); assert!(trade.include_delivery); @@ -761,187 +596,56 @@ mod tests { } #[test] - fn base32_and_geohash_helpers_cover_branches() { - assert_eq!(base32_value(b'0'), Some(0)); - assert_eq!(base32_value(b'B'), Some(10)); - assert_eq!(base32_value(b'?'), None); - - assert_eq!(geohash_encode(1.0, 1.0, 0).len(), 1); - let geohash = geohash_encode(-6.0346, -76.9714, 9); - assert_eq!(geohash.len(), 9); - let decoded = geohash_decode(&geohash).expect("decode geohash"); - assert!(decoded.0.is_finite()); - assert!(decoded.1.is_finite()); - assert!(geohash_decode_bbox(&geohash).is_some()); - assert!(geohash_decode("invalid*").is_none()); - } - - #[test] - fn calculate_and_truncate_resolution_cover_integer_and_fractional() { - assert_eq!(calculate_resolution(10.0, 9), 1); - assert_eq!(calculate_resolution(1.23456, 3), 3); - assert_eq!(calculate_resolution(1.2, 0), 1); - assert_eq!(truncate_to_resolution(12.9876, 2), 12.98); - } - - #[test] - fn location_geotags_cover_lat_lon_and_geohash_decode_paths() { + fn location_geotag_accepts_public_geohash5() { let mut tags = Vec::new(); - let location = RadrootsListingLocation { + let location = RadrootsListingPublicLocation { primary: "Test".to_string(), - city: None, + city: Some("Town".to_string()), region: None, country: None, - lat: Some(-6.0346), - lng: Some(-76.9714), - geohash: None, + geohash: "9Q8YY".to_string(), }; - push_location_geotags(&mut tags, &location, ListingTagOptions::default()); - assert!( - tags.iter() - .any(|tag| tag.first().map(|v| v.as_str()) == Some("g")) - ); - assert!(tags.iter().any(|tag| { - tag.first().map(|v| v.as_str()) == Some("L") - && tag.get(1).map(|v| v.as_str()) == Some("dd.lat") - })); - assert!(tags.iter().any(|tag| { - tag.first().map(|v| v.as_str()) == Some("L") - && tag.get(1).map(|v| v.as_str()) == Some("dd.lon") - })); + push_location_geotag(&mut tags, &location).expect("public geohash"); - let mut decoded_tags = Vec::new(); - let location_with_geohash = RadrootsListingLocation { - primary: "Test".to_string(), - city: None, - region: None, - country: None, - lat: None, - lng: None, - geohash: Some("6gkzwgjzn".to_string()), - }; - push_location_geotags( - &mut decoded_tags, - &location_with_geohash, - ListingTagOptions { - include_geohash: false, - include_gps: true, - ..ListingTagOptions::default() - }, - ); - let decoded_l_scopes: Vec<&str> = decoded_tags - .iter() - .filter(|tag| tag.first().map(|v| v.as_str()) == Some("l")) - .filter_map(|tag| tag.get(2).map(|v| v.as_str())) - .collect(); - assert!(decoded_l_scopes.contains(&"dd")); - assert!(decoded_l_scopes.contains(&"dd.lat")); - - let mut invalid_tags = Vec::new(); - let invalid_geohash = RadrootsListingLocation { - primary: "Test".to_string(), - city: None, - region: None, - country: None, - lat: None, - lng: None, - geohash: Some("???".to_string()), - }; - push_location_geotags( - &mut invalid_tags, - &invalid_geohash, - ListingTagOptions { - include_geohash: false, - include_gps: true, - ..ListingTagOptions::default() - }, - ); - assert!(find_tag(&invalid_tags, "l").is_none()); + assert_eq!(tags, vec![vec!["g".to_string(), "9q8yy".to_string()]]); + assert!(find_tag(&tags, "l").is_none()); + assert!(find_tag(&tags, "L").is_none()); + } - let mut geohash_only_tags = Vec::new(); - let geohash_only_location = RadrootsListingLocation { + #[test] + fn location_geotag_rejects_blank_geohash() { + let mut tags = Vec::new(); + let location = RadrootsListingPublicLocation { primary: "Test".to_string(), - city: None, + city: Some("Town".to_string()), region: None, country: None, - lat: None, - lng: None, - geohash: Some("6gkzwgjzn".to_string()), + geohash: " ".to_string(), }; - push_location_geotags( - &mut geohash_only_tags, - &geohash_only_location, - ListingTagOptions { - include_geohash: true, - include_gps: false, - ..ListingTagOptions::default() - }, - ); - assert!(find_tag(&geohash_only_tags, "g").is_some()); - let mut invalid_with_geohash_enabled = Vec::new(); - push_location_geotags( - &mut invalid_with_geohash_enabled, - &invalid_geohash, - ListingTagOptions { - include_geohash: true, - include_gps: true, - ..ListingTagOptions::default() - }, - ); - assert!(find_tag(&invalid_with_geohash_enabled, "g").is_some()); - assert!( - !invalid_with_geohash_enabled - .iter() - .any(|tag| tag.first().map(|v| v.as_str()) == Some("l")) - ); + assert!(matches!( + push_location_geotag(&mut tags, &location), + Err(EventEncodeError::EmptyRequiredField("location.geohash")) + )); + assert!(tags.is_empty()); + } - let mut no_coordinate_tags = Vec::new(); - let no_coordinate_location = RadrootsListingLocation { + #[test] + fn location_geotag_rejects_non_public_geohash() { + let mut tags = Vec::new(); + let location = RadrootsListingPublicLocation { primary: "Test".to_string(), - city: None, + city: Some("Town".to_string()), region: None, country: None, - lat: None, - lng: None, - geohash: None, + geohash: "9q8yyz".to_string(), }; - push_location_geotags( - &mut no_coordinate_tags, - &no_coordinate_location, - ListingTagOptions { - include_geohash: false, - include_gps: true, - ..ListingTagOptions::default() - }, - ); - assert!(find_tag(&no_coordinate_tags, "l").is_none()); - let mut partial_coordinate_tags = Vec::new(); - let partial_coordinate_location = RadrootsListingLocation { - primary: "Test".to_string(), - city: None, - region: None, - country: None, - lat: Some(-6.03), - lng: None, - geohash: Some("6gkzwgjzn".to_string()), - }; - push_location_geotags( - &mut partial_coordinate_tags, - &partial_coordinate_location, - ListingTagOptions { - include_geohash: false, - include_gps: true, - ..ListingTagOptions::default() - }, - ); - let partial_l_scopes: Vec<&str> = partial_coordinate_tags - .iter() - .filter(|tag| tag.first().map(|v| v.as_str()) == Some("l")) - .filter_map(|tag| tag.get(2).map(|v| v.as_str())) - .collect(); - assert!(partial_l_scopes.contains(&"dd")); + assert!(matches!( + push_location_geotag(&mut tags, &location), + Err(EventEncodeError::InvalidField("location.geohash")) + )); + assert!(tags.is_empty()); } #[test] @@ -1365,7 +1069,6 @@ mod tests { &no_geo, ListingTagOptions { include_geohash: false, - include_gps: false, ..ListingTagOptions::default() }, ) @@ -1378,24 +1081,12 @@ mod tests { &no_geo, ListingTagOptions { include_geohash: true, - include_gps: false, ..ListingTagOptions::default() }, ) .expect("location with geohash only"); assert!(find_tag(&geohash_only_tags, "g").is_some()); assert!(find_tag(&geohash_only_tags, "l").is_none()); - - let gps_only_tags = listing_tags_with_options( - &no_geo, - ListingTagOptions { - include_geohash: false, - include_gps: true, - ..ListingTagOptions::default() - }, - ) - .expect("location with gps only"); - assert!(find_tag(&gps_only_tags, "l").is_some()); } #[test] @@ -1508,15 +1199,7 @@ mod tests { listing.discounts = None; listing.product.location = Some(" null ".to_string()); listing.product.profile = Some(" ".to_string()); - listing.location = Some(RadrootsListingLocation { - primary: "null".to_string(), - city: Some("city".to_string()), - region: Some("region".to_string()), - country: Some("country".to_string()), - lat: Some(-6.0), - lng: Some(-77.0), - geohash: None, - }); + listing.location = None; listing.images = Some(vec![RadrootsListingImage { url: "null".to_string(), size: None, @@ -1532,14 +1215,12 @@ mod tests { let mut listing = base_listing(); listing.discounts = None; listing.images = None; - listing.location = Some(RadrootsListingLocation { + listing.location = Some(RadrootsListingPublicLocation { primary: "Moyobamba".to_string(), city: Some("Moyobamba".to_string()), region: Some("San Martin".to_string()), country: Some("PE".to_string()), - lat: None, - lng: None, - geohash: Some("6gkzwgjzn".to_string()), + geohash: "6gkzw".to_string(), }); let tags = listing_tags_full(&listing).expect("location tags"); let location = find_tag(&tags, "location").expect("location tag"); @@ -1597,14 +1278,12 @@ mod tests { fn listing_tags_location_handles_partial_optional_components() { let mut listing = base_listing(); listing.discounts = None; - listing.location = Some(RadrootsListingLocation { + listing.location = Some(RadrootsListingPublicLocation { primary: "Moyobamba".to_string(), city: Some(" ".to_string()), region: Some("San Martin".to_string()), country: Some(" ".to_string()), - lat: Some(-6.03), - lng: Some(-76.97), - geohash: None, + geohash: "9q8yy".to_string(), }); let tags = listing_tags(&listing).expect("listing tags"); @@ -1614,14 +1293,12 @@ mod tests { assert_eq!(location.get(2).map(|v| v.as_str()), Some("San Martin")); assert_eq!(location.len(), 3); - listing.location = Some(RadrootsListingLocation { + listing.location = Some(RadrootsListingPublicLocation { primary: "Moyobamba".to_string(), city: Some("Moyobamba".to_string()), region: Some(" ".to_string()), country: Some("PE".to_string()), - lat: Some(-6.03), - lng: Some(-76.97), - geohash: None, + geohash: "9q8yy".to_string(), }); let tags = listing_tags(&listing).expect("listing tags"); let location = find_tag(&tags, "location").expect("location tag"); diff --git a/crates/events_codec/src/plot/mod.rs b/crates/events_codec/src/plot/mod.rs @@ -6,9 +6,8 @@ mod tests { use crate::error::EventEncodeError; use crate::plot::encode::{plot_address, plot_build_tags}; use radroots_events::{ - farm::{ - RadrootsFarmRef, RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon, - }, + farm::RadrootsFarmRef, + gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon}, plot::{RadrootsPlot, RadrootsPlotLocation}, }; diff --git a/crates/events_codec/src/resource_area/mod.rs b/crates/events_codec/src/resource_area/mod.rs @@ -13,9 +13,7 @@ mod tests { resource_area_stewards_list_set, }; use radroots_events::farm::RadrootsFarmRef; - use radroots_events::farm::{ - RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon, - }; + use radroots_events::gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon}; use radroots_events::plot::RadrootsPlotRef; use radroots_events::resource_area::{ RadrootsResourceArea, RadrootsResourceAreaLocation, RadrootsResourceAreaRef, diff --git a/crates/events_codec/tests/domain_encode_non_serde.rs b/crates/events_codec/tests/domain_encode_non_serde.rs @@ -10,14 +10,12 @@ use radroots_core::{ use radroots_events::{ coop::{RadrootsCoop, RadrootsCoopLocation, RadrootsCoopRef}, document::{RadrootsDocument, RadrootsDocumentSubject}, - farm::{ - RadrootsFarm, RadrootsFarmLocation, RadrootsFarmRef, RadrootsGcsLocation, - RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon, - }, + farm::{RadrootsFarm, RadrootsFarmPublicLocation, RadrootsFarmRef}, + gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon}, ids::{RadrootsDTag, RadrootsInventoryBinId}, listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, - RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingProduct, + RadrootsListingDeliveryMethod, RadrootsListingProduct, RadrootsListingPublicLocation, }, plot::{RadrootsPlot, RadrootsPlotLocation, RadrootsPlotRef}, resource_area::{RadrootsResourceArea, RadrootsResourceAreaLocation, RadrootsResourceAreaRef}, @@ -126,12 +124,12 @@ fn sample_farm() -> RadrootsFarm { website: None, picture: None, banner: None, - location: Some(RadrootsFarmLocation { - primary: None, - city: None, - region: None, - country: None, - gcs: Some(sample_gcs("9q8yy")), + location: Some(RadrootsFarmPublicLocation { + primary: "Test Farm".to_string(), + city: Some("Santa Cruz".to_string()), + region: Some("California".to_string()), + country: Some("US".to_string()), + geohash: "9q8yy".to_string(), }), tags: Some(vec!["orchard".to_string()]), } @@ -472,27 +470,20 @@ fn farm_encode_and_list_set_paths() { assert!(matches!(err, EventEncodeError::EmptyRequiredField("name"))); let mut farm = sample_farm(); - farm.location - .as_mut() - .expect("location") - .gcs - .as_mut() - .expect("gcs") - .geohash = " ".to_string(); + farm.location.as_mut().expect("location").geohash = " ".to_string(); let err = farm_build_tags(&farm).expect_err("empty geohash"); assert!(matches!( err, - EventEncodeError::EmptyRequiredField("location.gcs.geohash") + EventEncodeError::EmptyRequiredField("location.geohash") )); let mut farm = sample_farm(); - farm.location.as_mut().expect("location").gcs = None; - let tags = farm_build_tags(&farm).expect("farm location without geo"); - assert!( - !tags - .iter() - .any(|tag| tag.first().map(|v| v.as_str()) == Some("g")) - ); + farm.location.as_mut().expect("location").geohash = "9q8yy6".to_string(); + let err = farm_build_tags(&farm).expect_err("invalid public geohash"); + assert!(matches!( + err, + EventEncodeError::InvalidField("location.geohash") + )); let tags = farm_ref_tags(&RadrootsFarmRef { pubkey: VALID_PUBKEY.to_string(), @@ -691,55 +682,50 @@ fn listing_encode_paths() { })); let mut listing_with_geohash = sample_listing(); - listing_with_geohash.location = Some(RadrootsListingLocation { + listing_with_geohash.location = Some(RadrootsListingPublicLocation { primary: "Origin".to_string(), - city: None, - region: None, - country: None, - lat: None, - lng: None, - geohash: Some("6gkzwgjzn".to_string()), + city: Some("Town".to_string()), + region: Some("Region".to_string()), + country: Some("PE".to_string()), + geohash: "6gkzw".to_string(), }); let decoded_tags = listing_tags_with_options( &listing_with_geohash, ListingTagOptions { include_geohash: false, - include_gps: true, ..ListingTagOptions::default() }, ) - .expect("listing tags with decoded geohash"); - assert!(decoded_tags.iter().any(|tag| { - tag.first().map(|v| v.as_str()) == Some("l") && tag.get(2).map(|v| v.as_str()) == Some("dd") - })); + .expect("listing tags without geohash tag"); + assert!( + !decoded_tags + .iter() + .any(|tag| tag.first().map(|v| v.as_str()) == Some("g")) + ); let mut listing_with_shared_geohash = sample_listing(); - listing_with_shared_geohash.location = Some(RadrootsListingLocation { + listing_with_shared_geohash.location = Some(RadrootsListingPublicLocation { primary: "Origin".to_string(), - city: None, - region: None, - country: None, - lat: None, - lng: None, - geohash: Some("6gkzwgjzn".to_string()), + city: Some("Town".to_string()), + region: Some("Region".to_string()), + country: Some("PE".to_string()), + geohash: "6gkzw".to_string(), }); let shared_geohash_tags = listing_tags_with_options(&listing_with_shared_geohash, ListingTagOptions::default()) .expect("listing tags with shared geohash"); assert!(shared_geohash_tags.iter().any(|tag| { tag.first().map(|v| v.as_str()) == Some("g") - && tag.get(1).map(|v| v.as_str()) == Some("6gkzwgjzn") + && tag.get(1).map(|v| v.as_str()) == Some("6gkzw") })); let mut listing_without_coordinates = sample_listing(); - listing_without_coordinates.location = Some(RadrootsListingLocation { + listing_without_coordinates.location = Some(RadrootsListingPublicLocation { primary: "Origin".to_string(), - city: None, - region: None, - country: None, - lat: None, - lng: None, - geohash: None, + city: Some("Town".to_string()), + region: Some("Region".to_string()), + country: Some("PE".to_string()), + geohash: "6gkzw".to_string(), }); let no_coordinates_tags = listing_tags_with_options(&listing_without_coordinates, ListingTagOptions::default()) @@ -749,11 +735,10 @@ fn listing_encode_paths() { .iter() .any(|tag| tag.first().map(|v| v.as_str()) == Some("L")) ); - assert!( - !no_coordinates_tags - .iter() - .any(|tag| tag.first().map(|v| v.as_str()) == Some("g")) - ); + assert!(no_coordinates_tags.iter().any(|tag| { + tag.first().map(|v| v.as_str()) == Some("g") + && tag.get(1).map(|v| v.as_str()) == Some("6gkzw") + })); let mut listing_with_blank_optionals = sample_listing(); listing_with_blank_optionals.product.summary = Some(" ".to_string()); @@ -761,18 +746,31 @@ fn listing_encode_paths() { listing_with_blank_optionals.product.location = Some(" ".to_string()); listing_with_blank_optionals.product.profile = Some("null".to_string()); listing_with_blank_optionals.product.year = Some(" ".to_string()); - listing_with_blank_optionals.location = Some(RadrootsListingLocation { + listing_with_blank_optionals.location = Some(RadrootsListingPublicLocation { primary: " ".to_string(), city: Some(" ".to_string()), region: Some("null".to_string()), country: Some(" ".to_string()), - lat: None, - lng: None, - geohash: None, + geohash: "6gkzw".to_string(), }); let blank_optional_tags = listing_tags_with_options(&listing_with_blank_optionals, ListingTagOptions::default()) - .expect("listing tags with blank optional values"); + .expect_err("blank public location is invalid"); + assert!(matches!( + blank_optional_tags, + EventEncodeError::EmptyRequiredField("location.primary") + )); + let mut listing_with_blank_product_optionals = sample_listing(); + listing_with_blank_product_optionals.product.summary = Some(" ".to_string()); + listing_with_blank_product_optionals.product.process = Some("null".to_string()); + listing_with_blank_product_optionals.product.location = Some(" ".to_string()); + listing_with_blank_product_optionals.product.profile = Some("null".to_string()); + listing_with_blank_product_optionals.product.year = Some(" ".to_string()); + let blank_optional_tags = listing_tags_with_options( + &listing_with_blank_product_optionals, + ListingTagOptions::default(), + ) + .expect("listing tags with blank product optional values"); assert!( !blank_optional_tags .iter() @@ -784,26 +782,24 @@ fn listing_encode_paths() { .any(|tag| tag.first().map(|v| v.as_str()) == Some("location")) ); - let mut listing_no_gps = sample_listing(); - listing_no_gps.location = Some(RadrootsListingLocation { + let mut listing_without_geohash = sample_listing(); + listing_without_geohash.location = Some(RadrootsListingPublicLocation { primary: "Origin".to_string(), - city: None, - region: None, - country: None, - lat: Some(37.0), - lng: Some(-122.0), - geohash: None, + city: Some("Town".to_string()), + region: Some("Region".to_string()), + country: Some("PE".to_string()), + geohash: "6gkzw".to_string(), }); - let no_gps_tags = listing_tags_with_options( - &listing_no_gps, + let without_geohash_tags = listing_tags_with_options( + &listing_without_geohash, ListingTagOptions { - include_gps: false, + include_geohash: false, ..ListingTagOptions::default() }, ) - .expect("listing tags without gps labels"); + .expect("listing tags without geohash"); assert!( - !no_gps_tags + !without_geohash_tags .iter() .any(|tag| tag.first().map(|v| v.as_str()) == Some("L")) ); diff --git a/crates/events_codec/tests/listing.rs b/crates/events_codec/tests/listing.rs @@ -15,7 +15,7 @@ use radroots_events::{ listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingImageSize, - RadrootsListingLocation, RadrootsListingProduct, RadrootsListingStatus, + RadrootsListingProduct, RadrootsListingPublicLocation, RadrootsListingStatus, }, plot::RadrootsPlotRef, resource_area::RadrootsResourceAreaRef, @@ -177,14 +177,12 @@ fn sample_listing_full(d_tag: &str) -> RadrootsListing { inventory_available: None, availability: None, delivery_method: None, - location: Some(RadrootsListingLocation { + location: Some(RadrootsListingPublicLocation { primary: "Moyobamba".to_string(), city: Some("Moyobamba".to_string()), region: Some("San Martin".to_string()), country: Some("PE".to_string()), - lat: Some(-6.0346), - lng: Some(-76.9714), - geohash: None, + geohash: "9q8yy".to_string(), }), images: Some(vec![RadrootsListingImage { url: "http://example.com/widget.jpg".to_string(), @@ -587,13 +585,8 @@ fn listing_from_event_covers_trade_location_delivery_and_image_paths() { let mut tags = sample_listing_tags(); tags.push(vec!["location".to_string(), "Farm shelf".to_string()]); let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap(); - assert_eq!( - decoded - .location - .as_ref() - .map(|location| location.primary.as_str()), - Some("Farm shelf") - ); + assert_eq!(decoded.product.location.as_deref(), Some("Farm shelf")); + assert!(decoded.location.is_none()); let mut tags = sample_listing_tags(); tags.push(vec!["location".to_string(), "Farm shelf".to_string()]); @@ -604,7 +597,7 @@ fn listing_from_event_covers_trade_location_delivery_and_image_paths() { "San Martin".to_string(), "PE".to_string(), ]); - tags.push(vec!["g".to_string(), "6gkzwgjzn".to_string()]); + tags.push(vec!["g".to_string(), "6gkzw".to_string()]); let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap(); assert_eq!(decoded.product.location.as_deref(), Some("Farm shelf")); assert_eq!( @@ -612,10 +605,10 @@ fn listing_from_event_covers_trade_location_delivery_and_image_paths() { ( location.primary.as_str(), location.city.as_deref(), - location.geohash.as_deref(), + location.geohash.as_str(), ) }), - Some(("Peru", Some("Moyobamba"), Some("6gkzwgjzn"))) + Some(("Peru", Some("Moyobamba"), "6gkzw")) ); let mut tags = sample_listing_tags(); @@ -643,7 +636,6 @@ fn listing_from_event_covers_trade_location_delivery_and_image_paths() { tags.push(vec!["expires_at".to_string(), "1740".to_string()]); tags.push(vec!["delivery".to_string(), "pickup".to_string()]); tags.push(vec!["image".to_string(), " ".to_string()]); - tags.push(vec!["g".to_string(), " ".to_string()]); tags.push(vec![ "image".to_string(), "https://example.test/a.jpg".to_string(), @@ -992,24 +984,18 @@ fn listing_build_tags_includes_listing_fields() { .iter() .filter(|t| t.get(0).map(|s| s.as_str()) == Some("g")) .collect(); - assert!(!g_tags.is_empty()); - let full_len = g_tags[0][1].len(); - assert_eq!(g_tags.len(), full_len); - for (idx, tag) in g_tags.iter().enumerate() { - assert_eq!(tag[1].len(), full_len - idx); - } - assert!(tags.iter().any(|t| { - t.get(0).map(|s| s.as_str()) == Some("L") && t.get(1).map(|s| s.as_str()) == Some("dd.lat") - })); - assert!(tags.iter().any(|t| { - t.get(0).map(|s| s.as_str()) == Some("L") && t.get(1).map(|s| s.as_str()) == Some("dd.lon") - })); - assert!(tags.iter().any(|t| { - t.get(0).map(|s| s.as_str()) == Some("l") && t.get(2).map(|s| s.as_str()) == Some("dd.lat") - })); - assert!(tags.iter().any(|t| { - t.get(0).map(|s| s.as_str()) == Some("l") && t.get(2).map(|s| s.as_str()) == Some("dd.lon") - })); + assert_eq!(g_tags.len(), 1); + assert_eq!(g_tags[0][1].len(), 5); + assert!( + !tags + .iter() + .any(|t| t.get(0).map(|s| s.as_str()) == Some("L")) + ); + assert!( + !tags + .iter() + .any(|t| t.get(0).map(|s| s.as_str()) == Some("l")) + ); assert!(tags.iter().any(|t| { t.get(0).map(|s| s.as_str()) == Some("image") @@ -1116,14 +1102,12 @@ fn listing_build_tags_ignores_null_strings() { listing.product.location = Some("null".to_string()); listing.product.profile = Some("null".to_string()); listing.product.year = Some("null".to_string()); - listing.location = Some(RadrootsListingLocation { + listing.location = Some(RadrootsListingPublicLocation { primary: "Moyobamba".to_string(), city: Some("null".to_string()), region: Some("San Martin".to_string()), country: Some("null".to_string()), - lat: Some(-6.0346), - lng: Some(-76.9714), - geohash: None, + geohash: "9q8yy".to_string(), }); listing.images = Some(vec![RadrootsListingImage { url: "null".to_string(), @@ -1141,34 +1125,31 @@ fn listing_build_tags_ignores_null_strings() { #[test] fn listing_tags_with_options_cover_location_fallback_paths() { let mut geohash_only = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); - geohash_only.location = Some(RadrootsListingLocation { + geohash_only.location = Some(RadrootsListingPublicLocation { primary: "Moyobamba".to_string(), - city: None, + city: Some("Moyobamba".to_string()), region: None, country: None, - lat: None, - lng: None, - geohash: Some("6gkzwgjzn".to_string()), + geohash: "6gkzw".to_string(), }); let tags = listing_tags_with_options(&geohash_only, ListingTagOptions::default()).unwrap(); assert!( tags.iter() .any(|tag| tag.get(0).map(|value| value.as_str()) == Some("g")) ); - assert!(tags.iter().any(|tag| { - tag.get(0).map(|value| value.as_str()) == Some("l") - && tag.get(2).map(|value| value.as_str()) == Some("dd") - })); + assert!( + !tags + .iter() + .any(|tag| tag.get(0).map(|value| value.as_str()) == Some("l")) + ); let mut no_coordinates = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ"); - no_coordinates.location = Some(RadrootsListingLocation { + no_coordinates.location = Some(RadrootsListingPublicLocation { primary: "Moyobamba".to_string(), - city: None, + city: Some("Moyobamba".to_string()), region: None, country: None, - lat: None, - lng: None, - geohash: None, + geohash: "9q8yy".to_string(), }); let tags = listing_tags_with_options(&no_coordinates, ListingTagOptions::default()).unwrap(); assert!( @@ -1177,25 +1158,21 @@ fn listing_tags_with_options_cover_location_fallback_paths() { .any(|tag| tag.get(0).map(|value| value.as_str()) == Some("L")) ); assert!( - !tags - .iter() + tags.iter() .any(|tag| tag.get(0).map(|value| value.as_str()) == Some("g")) ); - let mut no_gps = sample_listing("AAAAAAAAAAAAAAAAAAAAAw"); - no_gps.location = Some(RadrootsListingLocation { + let mut without_geohash = sample_listing("AAAAAAAAAAAAAAAAAAAAAw"); + without_geohash.location = Some(RadrootsListingPublicLocation { primary: "Moyobamba".to_string(), - city: None, + city: Some("Moyobamba".to_string()), region: None, country: None, - lat: Some(-6.0346), - lng: Some(-76.9714), - geohash: None, + geohash: "9q8yy".to_string(), }); let tags = listing_tags_with_options( - &no_gps, + &without_geohash, ListingTagOptions { - include_gps: false, ..ListingTagOptions::default() }, ) diff --git a/crates/events_codec/tests/structured_decode.rs b/crates/events_codec/tests/structured_decode.rs @@ -6,10 +6,8 @@ mod test_fixtures; use radroots_core::{RadrootsCoreDecimal, RadrootsCoreQuantity, RadrootsCoreUnit}; use radroots_events::coop::RadrootsCoop; use radroots_events::document::{RadrootsDocument, RadrootsDocumentSubject}; -use radroots_events::farm::{ - RadrootsFarm, RadrootsFarmRef, RadrootsGcsLocation, RadrootsGeoJsonPoint, - RadrootsGeoJsonPolygon, -}; +use radroots_events::farm::{RadrootsFarm, RadrootsFarmRef}; +use radroots_events::gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon}; use radroots_events::kinds::{ KIND_COOP, KIND_DOCUMENT, KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA, KIND_RESOURCE_HARVEST_CAP, }; diff --git a/crates/events_codec/tests/structured_encode_default.rs b/crates/events_codec/tests/structured_encode_default.rs @@ -7,10 +7,8 @@ use radroots_core::{ }; use radroots_events::coop::{RadrootsCoop, RadrootsCoopLocation, RadrootsCoopRef}; use radroots_events::document::{RadrootsDocument, RadrootsDocumentSubject}; -use radroots_events::farm::{ - RadrootsFarm, RadrootsFarmLocation, RadrootsFarmRef, RadrootsGcsLocation, RadrootsGeoJsonPoint, - RadrootsGeoJsonPolygon, -}; +use radroots_events::farm::{RadrootsFarm, RadrootsFarmPublicLocation, RadrootsFarmRef}; +use radroots_events::gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon}; use radroots_events::ids::{RadrootsDTag, RadrootsInventoryBinId}; use radroots_events::kinds::{ KIND_COOP, KIND_DOCUMENT, KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA, KIND_RESOURCE_HARVEST_CAP, @@ -162,12 +160,12 @@ fn sample_farm() -> RadrootsFarm { website: None, picture: None, banner: None, - location: Some(RadrootsFarmLocation { - primary: Some("farm".to_string()), - city: None, - region: None, + location: Some(RadrootsFarmPublicLocation { + primary: "farm".to_string(), + city: Some("Town".to_string()), + region: Some("Region".to_string()), country: None, - gcs: Some(sample_gcs()), + geohash: "9q8yy".to_string(), }), tags: Some(vec!["organic".to_string(), " ".to_string()]), } @@ -377,12 +375,12 @@ fn structured_build_tags_cover_optional_and_error_paths() { website: None, picture: None, banner: None, - location: Some(RadrootsFarmLocation { - primary: Some("farm".to_string()), - city: None, - region: None, + location: Some(RadrootsFarmPublicLocation { + primary: "farm".to_string(), + city: Some("Town".to_string()), + region: Some("Region".to_string()), country: None, - gcs: Some(sample_gcs()), + geohash: "9q8yy".to_string(), }), tags: Some(vec!["organic".to_string(), " ".to_string()]), }; @@ -396,22 +394,15 @@ fn structured_build_tags_cover_optional_and_error_paths() { assert!(farm_tags.iter().any(|tag| tag[0] == "g")); let mut invalid_farm = farm.clone(); - invalid_farm - .location - .as_mut() - .unwrap() - .gcs - .as_mut() - .unwrap() - .geohash = " ".to_string(); + invalid_farm.location.as_mut().unwrap().geohash = " ".to_string(); let err = farm_build_tags(&invalid_farm).unwrap_err(); assert!(matches!( err, - EventEncodeError::EmptyRequiredField("location.gcs.geohash") + EventEncodeError::EmptyRequiredField("location.geohash") )); let mut string_only_farm = farm.clone(); - string_only_farm.location.as_mut().unwrap().gcs = None; + string_only_farm.location = None; let string_only_tags = farm_build_tags(&string_only_farm).unwrap(); assert!( !string_only_tags diff --git a/crates/events_codec/tests/tag_builders.rs b/crates/events_codec/tests/tag_builders.rs @@ -11,11 +11,9 @@ use radroots_events::app_data::RadrootsAppData; use radroots_events::comment::RadrootsComment; use radroots_events::coop::RadrootsCoop; use radroots_events::document::{RadrootsDocument, RadrootsDocumentSubject}; -use radroots_events::farm::{ - RadrootsFarm, RadrootsFarmRef, RadrootsGcsLocation, RadrootsGeoJsonPoint, - RadrootsGeoJsonPolygon, -}; +use radroots_events::farm::{RadrootsFarm, RadrootsFarmRef}; use radroots_events::follow::{RadrootsFollow, RadrootsFollowProfile}; +use radroots_events::gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon}; use radroots_events::geochat::RadrootsGeoChat; use radroots_events::gift_wrap::{RadrootsGiftWrap, RadrootsGiftWrapRecipient}; use radroots_events::ids::{RadrootsDTag, RadrootsInventoryBinId}; @@ -30,7 +28,7 @@ use radroots_events::list::{RadrootsList, RadrootsListEntry}; use radroots_events::list_set::RadrootsListSet; use radroots_events::listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, RadrootsListingImage, - RadrootsListingImageSize, RadrootsListingLocation, RadrootsListingProduct, + RadrootsListingImageSize, RadrootsListingProduct, RadrootsListingPublicLocation, RadrootsListingStatus, }; use radroots_events::message::{RadrootsMessage, RadrootsMessageRecipient}; @@ -479,14 +477,12 @@ fn listing_and_message_builders_cover_optional_shapes() { listing.product.location = Some("Moyobamba".to_string()); listing.product.profile = Some("fruity".to_string()); listing.product.year = Some("2024".to_string()); - listing.location = Some(RadrootsListingLocation { + listing.location = Some(RadrootsListingPublicLocation { primary: "Moyobamba".to_string(), city: Some("Moyobamba".to_string()), region: Some("San Martin".to_string()), country: Some("PE".to_string()), - lat: Some(-6.03), - lng: Some(-76.97), - geohash: None, + geohash: "9q8yy".to_string(), }); listing.images = Some(vec![RadrootsListingImage { url: cdn_url("a.jpg"), @@ -542,31 +538,27 @@ fn listing_and_message_builders_cover_optional_shapes() { })); let mut listing_geohash_only = listing_with_trade.clone(); - listing_geohash_only.location = Some(RadrootsListingLocation { + listing_geohash_only.location = Some(RadrootsListingPublicLocation { primary: "Moyobamba".to_string(), - city: None, + city: Some("Moyobamba".to_string()), region: None, country: None, - lat: None, - lng: None, - geohash: Some("6gkzwgjzn".to_string()), + geohash: "6gkzw".to_string(), }); let geohash_tags = listing_tags_with_options(&listing_geohash_only, ListingTagOptions::default()).unwrap(); assert!(geohash_tags.iter().any(|tag| { tag.first().map(|v| v.as_str()) == Some("g") - && tag.get(1).map(|v| v.as_str()) == Some("6gkzwgjzn") + && tag.get(1).map(|v| v.as_str()) == Some("6gkzw") })); let mut listing_no_coordinates = listing_with_trade.clone(); - listing_no_coordinates.location = Some(RadrootsListingLocation { + listing_no_coordinates.location = Some(RadrootsListingPublicLocation { primary: "Moyobamba".to_string(), - city: None, + city: Some("Moyobamba".to_string()), region: None, country: None, - lat: None, - lng: None, - geohash: None, + geohash: "9q8yy".to_string(), }); let no_coordinates_tags = listing_tags_with_options(&listing_no_coordinates, ListingTagOptions::default()).unwrap(); @@ -576,37 +568,31 @@ fn listing_and_message_builders_cover_optional_shapes() { .any(|tag| tag.first().map(|v| v.as_str()) == Some("L")) ); - let no_gps_tags = listing_tags_with_options( + let without_private_location_tags = listing_tags_with_options( &listing_with_trade, ListingTagOptions { - include_gps: false, ..ListingTagOptions::default() }, ) .unwrap(); assert!( - !no_gps_tags + !without_private_location_tags .iter() .any(|tag| tag.first().map(|v| v.as_str()) == Some("L")) ); let mut listing_with_empty_primary_location = listing_with_trade.clone(); - listing_with_empty_primary_location.location = Some(RadrootsListingLocation { + listing_with_empty_primary_location.location = Some(RadrootsListingPublicLocation { primary: " null ".to_string(), - city: None, + city: Some("Moyobamba".to_string()), region: None, country: None, - lat: Some(-6.03), - lng: Some(-76.97), - geohash: None, + geohash: "9q8yy".to_string(), }); - let no_primary_location_tags = - listing_tags_with_options(&listing_with_empty_primary_location, trade_options).unwrap(); - assert!( - !no_primary_location_tags - .iter() - .any(|tag| tag.first().map(|v| v.as_str()) == Some("location") && tag.len() > 2) - ); + assert!(matches!( + listing_tags_with_options(&listing_with_empty_primary_location, trade_options), + Err(EventEncodeError::EmptyRequiredField("location.primary")) + )); let mut listing_with_discount_payload = listing_with_trade.clone(); listing_with_discount_payload.discounts = Some(vec![RadrootsCoreDiscount { diff --git a/crates/replica_sync/src/emit.rs b/crates/replica_sync/src/emit.rs @@ -9,11 +9,10 @@ use alloc::{ #[cfg(feature = "std")] use std::collections::BTreeMap; -use radroots_events::farm::{ - RadrootsFarm, RadrootsFarmLocation, RadrootsFarmRef, RadrootsGcsLocation, RadrootsGeoJsonPoint, - RadrootsGeoJsonPolygon, -}; +use radroots_events::farm::{RadrootsFarm, RadrootsFarmPublicLocation, RadrootsFarmRef}; +use radroots_events::gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon}; use radroots_events::kinds::{KIND_FARM, KIND_LIST_SET_GENERIC, KIND_PLOT}; +use radroots_events::location::{has_textual_locality, is_public_geohash5}; use radroots_events::plot::RadrootsPlot; use radroots_events::profile::{ RADROOTS_PROFILE_TYPE_TAG_KEY, RadrootsProfile, RadrootsProfileType, @@ -481,26 +480,48 @@ fn load_plots( fn load_farm_location( exec: &dyn SqlExecutor, farm: &Farm, -) -> Result<Option<RadrootsFarmLocation>, RadrootsReplicaEventsError> { - let gcs = load_gcs_location_for_farm(exec, &farm.id)?; - let has_strings = [ - farm.location_primary.as_deref(), - farm.location_city.as_deref(), - farm.location_region.as_deref(), - farm.location_country.as_deref(), - ] - .into_iter() - .flatten() - .any(|value| !value.trim().is_empty()); - if !has_strings && gcs.is_none() { +) -> Result<Option<RadrootsFarmPublicLocation>, RadrootsReplicaEventsError> { + let Some(gcs) = load_gcs_location_for_farm(exec, &farm.id)? else { + return Ok(None); + }; + let Some(geohash) = gcs.geohash.get(..5).map(|value| value.to_ascii_lowercase()) else { + return Ok(None); + }; + if !is_public_geohash5(&geohash) { + return Ok(None); + } + let primary = farm + .location_primary + .as_ref() + .filter(|value| !value.trim().is_empty()) + .cloned() + .or_else(|| gcs.gc_name.clone()) + .or_else(|| gcs.label.clone()) + .unwrap_or_else(|| geohash.clone()); + let city = farm.location_city.clone().or_else(|| gcs.gc_name.clone()); + let region = farm + .location_region + .clone() + .or_else(|| gcs.gc_admin1_name.clone()); + let country = farm + .location_country + .clone() + .or_else(|| gcs.gc_country_name.clone()) + .or_else(|| gcs.gc_country_id.clone()); + if !has_textual_locality( + &primary, + city.as_deref(), + region.as_deref(), + country.as_deref(), + ) { return Ok(None); } - Ok(Some(RadrootsFarmLocation { - primary: farm.location_primary.clone(), - city: farm.location_city.clone(), - region: farm.location_region.clone(), - country: farm.location_country.clone(), - gcs, + Ok(Some(RadrootsFarmPublicLocation { + primary, + city, + region, + country, + geohash, })) } @@ -1886,7 +1907,7 @@ mod tests { } #[test] - fn load_farm_location_preserves_string_only_locations() { + fn load_farm_location_omits_string_only_public_locations() { let exec = SqliteExecutor::open_memory().expect("db"); migrations::run_all_up(&exec).expect("migrations"); let farm_row = farm::create( @@ -1908,14 +1929,8 @@ mod tests { .expect("farm") .result; - let location = load_farm_location(&exec, &farm_row) - .expect("location query") - .expect("string-only location"); - assert_eq!(location.primary.as_deref(), Some("San Francisco, CA")); - assert_eq!(location.city.as_deref(), Some("San Francisco")); - assert_eq!(location.region.as_deref(), Some("CA")); - assert_eq!(location.country.as_deref(), Some("US")); - assert!(location.gcs.is_none()); + let location = load_farm_location(&exec, &farm_row).expect("location query"); + assert!(location.is_none()); } #[test] diff --git a/crates/replica_sync/src/geo.rs b/crates/replica_sync/src/geo.rs @@ -3,7 +3,7 @@ use alloc::vec; #[cfg(not(feature = "std"))] use alloc::{string::String, vec::Vec}; -use radroots_events::farm::{RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon}; +use radroots_events::gcs::{RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon}; const EARTH_RADIUS_M: f64 = 6_378_137.0; diff --git a/crates/replica_sync/src/ingest.rs b/crates/replica_sync/src/ingest.rs @@ -30,17 +30,20 @@ use radroots_events_codec::list_set::decode as list_set_decode; use radroots_events_codec::listing::decode as listing_decode; use radroots_events_codec::plot::decode as plot_decode; use radroots_events_codec::profile::decode as profile_decode; +#[cfg(test)] +use radroots_replica_db::farm_gcs_location; use radroots_replica_db::{ - farm, farm_gcs_location, farm_member, farm_member_claim, farm_tag, gcs_location, - nostr_event_head, nostr_profile, plot, plot_gcs_location, plot_tag, trade_product, + farm, farm_member, farm_member_claim, farm_tag, gcs_location, nostr_event_head, nostr_profile, + plot, plot_gcs_location, plot_tag, trade_product, }; use radroots_replica_db_schema::farm::{ FarmQueryBindValues, IFarmFields, IFarmFieldsFilter, IFarmFieldsPartial, IFarmFindMany, IFarmUpdate, }; +#[cfg(test)] use radroots_replica_db_schema::farm_gcs_location::{ - FarmGcsLocationQueryBindValues, IFarmGcsLocationDelete, IFarmGcsLocationFields, - IFarmGcsLocationFieldsFilter, IFarmGcsLocationFindMany, IFarmGcsLocationFindOneArgs, + FarmGcsLocationQueryBindValues, IFarmGcsLocationDelete, IFarmGcsLocationFieldsFilter, + IFarmGcsLocationFindMany, IFarmGcsLocationFindOneArgs, }; use radroots_replica_db_schema::farm_member::{ FarmMemberQueryBindValues, IFarmMemberDelete, IFarmMemberFields, IFarmMemberFieldsFilter, @@ -295,7 +298,7 @@ fn ingest_profile_event( fn ingest_farm_event( exec: &dyn SqlExecutor, event: &RadrootsNostrEvent, - factory: &dyn RadrootsReplicaIdFactory, + _factory: &dyn RadrootsReplicaIdFactory, ) -> Result<RadrootsReplicaIngestOutcome, RadrootsReplicaEventsError> { let farm = farm_decode::farm_from_event(event.kind, &event.tags, &event.content)?; let decision = event_head_decision(exec, event)?; @@ -370,7 +373,6 @@ fn ingest_farm_event( }; upsert_farm_tags(exec, &farm_id, farm.tags)?; - upsert_farm_location(exec, &farm_id, location, factory)?; upsert_event_head(exec, &decision)?; Ok(RadrootsReplicaIngestOutcome::Applied) @@ -1120,27 +1122,6 @@ fn upsert_plot_tags( Ok(()) } -fn upsert_farm_location( - exec: &dyn SqlExecutor, - farm_id: &str, - location: Option<radroots_events::farm::RadrootsFarmLocation>, - factory: &dyn RadrootsReplicaIdFactory, -) -> Result<(), RadrootsReplicaEventsError> { - clear_farm_locations(exec, farm_id)?; - if let Some(location) = location - && let Some(gcs) = location.gcs - { - let gcs_id = create_gcs_location(exec, gcs, factory)?; - let fields = IFarmGcsLocationFields { - farm_id: farm_id.to_string(), - gcs_location_id: gcs_id, - role: ROLE_PRIMARY.to_string(), - }; - let _ = farm_gcs_location::create(exec, &fields)?; - } - Ok(()) -} - fn upsert_plot_location( exec: &dyn SqlExecutor, plot_id: &str, @@ -1160,6 +1141,7 @@ fn upsert_plot_location( Ok(()) } +#[cfg(test)] fn clear_farm_locations( exec: &dyn SqlExecutor, farm_id: &str, @@ -1220,7 +1202,7 @@ fn clear_plot_locations( fn create_gcs_location( exec: &dyn SqlExecutor, - gcs: radroots_events::farm::RadrootsGcsLocation, + gcs: radroots_events::gcs::RadrootsGcsLocation, factory: &dyn RadrootsReplicaIdFactory, ) -> Result<String, RadrootsReplicaEventsError> { let d_tag = factory.new_d_tag(); @@ -1272,7 +1254,7 @@ fn map_gcs_polygon_serialize_error(_err: serde_json::Error) -> RadrootsReplicaEv #[cfg(test)] fn serialize_gcs_point( - point: &radroots_events::farm::RadrootsGeoJsonPoint, + point: &radroots_events::gcs::RadrootsGeoJsonPoint, ) -> Result<String, serde_json::Error> { #[cfg(test)] if failpoints::take_gcs_point_serialize_error() { @@ -1282,13 +1264,13 @@ fn serialize_gcs_point( } #[cfg(not(test))] -fn serialize_gcs_point(point: &radroots_events::farm::RadrootsGeoJsonPoint) -> String { +fn serialize_gcs_point(point: &radroots_events::gcs::RadrootsGeoJsonPoint) -> String { serde_json::to_string(point).expect("gcs.point serializes") } #[cfg(test)] fn serialize_gcs_polygon( - polygon: &radroots_events::farm::RadrootsGeoJsonPolygon, + polygon: &radroots_events::gcs::RadrootsGeoJsonPolygon, ) -> Result<String, serde_json::Error> { #[cfg(test)] if failpoints::take_gcs_polygon_serialize_error() { @@ -1298,7 +1280,7 @@ fn serialize_gcs_polygon( } #[cfg(not(test))] -fn serialize_gcs_polygon(polygon: &radroots_events::farm::RadrootsGeoJsonPolygon) -> String { +fn serialize_gcs_polygon(polygon: &radroots_events::gcs::RadrootsGeoJsonPolygon) -> String { serde_json::to_string(polygon).expect("gcs.polygon serializes") } @@ -1431,7 +1413,7 @@ enum ListSetRole { } fn unpack_farm_location_strings( - location: Option<&radroots_events::farm::RadrootsFarmLocation>, + location: Option<&radroots_events::farm::RadrootsFarmPublicLocation>, ) -> ( Option<String>, Option<String>, @@ -1440,7 +1422,7 @@ fn unpack_farm_location_strings( ) { match location { Some(location) => ( - location.primary.clone(), + Some(location.primary.clone()), location.city.clone(), location.region.clone(), location.country.clone(), @@ -1528,10 +1510,8 @@ mod tests { RadrootsCoreCurrency, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, }; - use radroots_events::farm::{ - RadrootsFarm, RadrootsFarmLocation, RadrootsFarmRef, RadrootsGcsLocation, - RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon, - }; + use radroots_events::farm::{RadrootsFarm, RadrootsFarmPublicLocation, RadrootsFarmRef}; + use radroots_events::gcs::{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; @@ -1783,7 +1763,7 @@ mod tests { created_at: u32, d_tag: &str, name: &str, - location: Option<RadrootsFarmLocation>, + location: Option<RadrootsFarmPublicLocation>, tags: Option<Vec<String>>, ) -> RadrootsNostrEvent { let farm = RadrootsFarm { @@ -2294,12 +2274,12 @@ mod tests { 10, farm_d_tag, "farm-a", - Some(RadrootsFarmLocation { - primary: Some("primary".to_string()), + Some(RadrootsFarmPublicLocation { + primary: "primary".to_string(), city: Some("city".to_string()), region: Some("region".to_string()), country: Some("country".to_string()), - gcs: Some(sample_gcs(10.0, 20.0, "s0")), + geohash: "9q8yy".to_string(), }), Some(vec![ "coffee".to_string(), @@ -2509,12 +2489,12 @@ mod tests { 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()), + let location = RadrootsFarmPublicLocation { + primary: "p".to_string(), city: Some("c".to_string()), region: Some("r".to_string()), country: Some("k".to_string()), - gcs: Some(sample_gcs(12.0, 22.0, "s2")), + geohash: "9q8yy".to_string(), }; assert_eq!( unpack_farm_location_strings(Some(&location)).0, @@ -2806,7 +2786,6 @@ mod tests { .expect("plot") .result; - let _ = upsert_farm_location(&exec, &farm_row.id, None, &FixedFactory).expect("farm none"); let _ = upsert_plot_location(&exec, &plot_row.id, None, &FixedFactory).expect("plot none"); } @@ -2856,63 +2835,6 @@ mod tests { .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: Some(sample_gcs(1.0, 2.0, "s4")), - }), - &FixedFactory, - ) - .is_ok() - ); - assert!( - upsert_farm_location( - &exec, - &farm_id, - Some(RadrootsFarmLocation { - primary: Some("manual".to_string()), - city: Some("San Francisco".to_string()), - region: Some("CA".to_string()), - country: Some("US".to_string()), - gcs: None, - }), - &FixedFactory, - ) - .is_ok() - ); - assert!( - farm_gcs_location::find_many(&exec, &IFarmGcsLocationFindMany { filter: None }) - .expect("farm locations after string-only upsert") - .results - .is_empty() - ); - assert!( - upsert_farm_location( - &exec, - &farm_id, - Some(RadrootsFarmLocation { - primary: None, - city: None, - region: None, - country: None, - gcs: Some(sample_gcs(2.0, 3.0, "s6")), - }), - &FixedFactory, - ) - .is_ok() - ); - let not_found_plot_locations = DeleteErrorExecutor { inner: &exec, table_name: "plot_gcs_location", @@ -3012,27 +2934,6 @@ mod tests { 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: Some(sample_gcs(2.0, 3.0, "s6")), - }), - &FixedFactory, - ) - .is_err() - ); - let internal_plot_locations = DeleteErrorExecutor { inner: &exec, table_name: "plot_gcs_location", @@ -3122,12 +3023,12 @@ mod tests { 51, farm_d_tag, "pass-farm", - Some(RadrootsFarmLocation { - primary: Some("primary".to_string()), + Some(RadrootsFarmPublicLocation { + primary: "primary".to_string(), city: Some("city".to_string()), region: Some("region".to_string()), country: Some("country".to_string()), - gcs: Some(sample_gcs(10.0, 20.0, "s0")), + geohash: "9q8yy".to_string(), }), Some(vec!["coffee".to_string(), "coffee".to_string()]), ); @@ -3252,12 +3153,12 @@ mod tests { 60, farm_d_tag, "wrapper-farm", - Some(RadrootsFarmLocation { - primary: Some("primary".to_string()), + Some(RadrootsFarmPublicLocation { + primary: "primary".to_string(), city: None, region: None, country: None, - gcs: Some(sample_gcs(10.0, 20.0, "s0")), + geohash: "9q8yy".to_string(), }), Some(vec!["coffee".to_string()]), ); @@ -3369,12 +3270,12 @@ mod tests { 71, farm_d_tag, "txn-farm", - Some(RadrootsFarmLocation { - primary: Some("primary".to_string()), + Some(RadrootsFarmPublicLocation { + primary: "primary".to_string(), city: None, region: None, country: None, - gcs: Some(sample_gcs(12.0, 22.0, "s2")), + geohash: "9q8yy".to_string(), }), Some(vec!["coffee".to_string()]), ); @@ -3421,21 +3322,6 @@ mod tests { create_gcs_location(&pass_txn, sample_gcs(14.0, 24.0, "s4"), &FixedFactory).is_ok() ); assert!( - upsert_farm_location( - &pass_txn, - &farm_row.id, - Some(RadrootsFarmLocation { - primary: Some("primary".to_string()), - city: None, - region: None, - country: None, - gcs: Some(sample_gcs(15.0, 25.0, "s5")), - }), - &FixedFactory, - ) - .is_ok() - ); - assert!( upsert_plot_location( &pass_txn, &plot_id, @@ -3487,21 +3373,6 @@ mod tests { assert!(clear_plot_locations(&txn, "plot-id").is_err()); assert!(create_gcs_location(&txn, sample_gcs(14.0, 24.0, "s4"), &FixedFactory).is_err()); assert!( - upsert_farm_location( - &txn, - "farm-id", - Some(RadrootsFarmLocation { - primary: Some("primary".to_string()), - city: None, - region: None, - country: None, - gcs: Some(sample_gcs(15.0, 25.0, "s5")), - }), - &FixedFactory, - ) - .is_err() - ); - assert!( upsert_plot_location( &txn, "plot-id", @@ -3617,12 +3488,12 @@ mod tests { 90, farm_d_tag, "farm-seed", - Some(RadrootsFarmLocation { - primary: Some("primary".to_string()), + Some(RadrootsFarmPublicLocation { + primary: "primary".to_string(), city: Some("city".to_string()), region: Some("region".to_string()), country: Some("country".to_string()), - gcs: Some(sample_gcs(10.0, 20.0, "s0")), + geohash: "9q8yy".to_string(), }), Some(vec!["seed".to_string()]), ); @@ -3710,12 +3581,12 @@ mod tests { 95, farm_d_tag, "farm-gcs", - Some(RadrootsFarmLocation { - primary: Some("primary".to_string()), + Some(RadrootsFarmPublicLocation { + primary: "primary".to_string(), city: None, region: None, country: None, - gcs: Some(sample_gcs(11.0, 21.0, "s1")), + geohash: "9q8yy".to_string(), }), None, ); @@ -3732,12 +3603,12 @@ mod tests { 96, farm_d_tag, "farm-rel", - Some(RadrootsFarmLocation { - primary: Some("primary".to_string()), + Some(RadrootsFarmPublicLocation { + primary: "primary".to_string(), city: None, region: None, country: None, - gcs: Some(sample_gcs(12.0, 22.0, "s2")), + geohash: "9q8yy".to_string(), }), None, ); @@ -3767,12 +3638,12 @@ mod tests { 98, farm_d_tag, "farm-bad-point", - Some(RadrootsFarmLocation { - primary: Some("primary".to_string()), + Some(RadrootsFarmPublicLocation { + primary: "primary".to_string(), city: None, region: None, country: None, - gcs: Some(bad_point), + geohash: "9q8yy".to_string(), }), None, ); @@ -3786,12 +3657,12 @@ mod tests { 99, farm_d_tag, "farm-bad-polygon", - Some(RadrootsFarmLocation { - primary: Some("primary".to_string()), + Some(RadrootsFarmPublicLocation { + primary: "primary".to_string(), city: None, region: None, country: None, - gcs: Some(bad_polygon), + geohash: "9q8yy".to_string(), }), None, ); @@ -4198,48 +4069,6 @@ mod tests { .is_err() ); - let farm_gcs_insert_fail = QueryFailExecutor { - inner: &exec, - needle: "insert into gcs_location", - err: SqlError::Internal, - }; - assert!( - upsert_farm_location( - &farm_gcs_insert_fail, - &farm_id, - Some(RadrootsFarmLocation { - primary: Some("primary".to_string()), - city: None, - region: None, - country: None, - gcs: Some(sample_gcs(31.0, 41.0, "s8")), - }), - &FixedFactory, - ) - .is_err() - ); - - let farm_rel_insert_fail = QueryFailExecutor { - inner: &exec, - needle: "insert into farm_gcs_location", - err: SqlError::Internal, - }; - assert!( - upsert_farm_location( - &farm_rel_insert_fail, - &farm_id, - Some(RadrootsFarmLocation { - primary: Some("primary".to_string()), - city: None, - region: None, - country: None, - gcs: Some(sample_gcs(32.0, 42.0, "s9")), - }), - &FixedFactory, - ) - .is_err() - ); - let plot_gcs_insert_fail = QueryFailExecutor { inner: &exec, needle: "insert into gcs_location", diff --git a/crates/replica_sync/src/tests.rs b/crates/replica_sync/src/tests.rs @@ -2,7 +2,7 @@ use crate::{ RADROOTS_REPLICA_TRANSFER_VERSION, RadrootsReplicaFarmSelector, RadrootsReplicaSyncRequest, radroots_replica_sync_all, }; -use radroots_events::farm::{RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon}; +use radroots_events::gcs::{RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon}; use radroots_events::kinds::{KIND_FARM, KIND_LIST_SET_GENERIC, KIND_PLOT, KIND_PROFILE}; use radroots_replica_db::{ farm, farm_gcs_location, farm_member, farm_member_claim, farm_tag, gcs_location, migrations, diff --git a/crates/replica_sync/tests/ingest_roundtrip.rs b/crates/replica_sync/tests/ingest_roundtrip.rs @@ -1,8 +1,6 @@ use radroots_events::RadrootsNostrEvent; -use radroots_events::farm::{ - RadrootsFarm, RadrootsFarmLocation, RadrootsFarmRef, RadrootsGcsLocation, RadrootsGeoJsonPoint, - RadrootsGeoJsonPolygon, -}; +use radroots_events::farm::{RadrootsFarm, RadrootsFarmPublicLocation, RadrootsFarmRef}; +use radroots_events::gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon}; use radroots_events::kinds::{ KIND_FARM, KIND_LIST_SET_FOLLOW, KIND_LIST_SET_GENERIC, KIND_PLOT, KIND_PROFILE, }; @@ -224,11 +222,11 @@ fn seed_source( }; let farm_row = unwrap_sql(farm::create(exec, &farm_fields), "farm").result; - let point = radroots_events::farm::RadrootsGeoJsonPoint { + let point = radroots_events::gcs::RadrootsGeoJsonPoint { r#type: "Point".to_string(), coordinates: [-122.4, 37.7], }; - let polygon = radroots_events::farm::RadrootsGeoJsonPolygon { + let polygon = radroots_events::gcs::RadrootsGeoJsonPolygon { r#type: "Polygon".to_string(), coordinates: vec![vec![ [-122.4, 37.7], @@ -818,20 +816,18 @@ fn ingest_reports_query_fail_paths_for_profile_farm_plot_and_list_sets() { 12, farm_d_tag, "farm-query", - Some(RadrootsFarmLocation { - primary: Some("farm".to_string()), + Some(RadrootsFarmPublicLocation { + primary: "farm".to_string(), city: None, region: None, country: None, - gcs: Some(sample_gcs(37.7, -122.4, "9q8yy")), + geohash: "9q8yy".to_string(), }), Some(vec!["coffee".to_string()]), ); assert_query_fail("select * from farm where", &farm_create); assert_query_fail("insert into farm", &farm_create); assert_query_fail("insert into farm_tag", &farm_create); - assert_query_fail("insert into gcs_location", &farm_create); - assert_query_fail("insert into farm_gcs_location", &farm_create); assert_eq!( radroots_replica_ingest_event(&exec, &farm_create).expect("seed farm"), RadrootsReplicaIngestOutcome::Applied @@ -1023,7 +1019,7 @@ fn farm_event( created_at: u32, d_tag: &str, name: &str, - location: Option<RadrootsFarmLocation>, + location: Option<RadrootsFarmPublicLocation>, tags: Option<Vec<String>>, ) -> RadrootsNostrEvent { let farm = RadrootsFarm { @@ -1174,12 +1170,12 @@ fn ingest_event_paths_cover_profile_farm_plot_and_list_set_variants() { let farm_pubkey = "e".repeat(64); let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAA"; - let farm_location = RadrootsFarmLocation { - primary: Some("farm-primary".to_string()), + let farm_location = RadrootsFarmPublicLocation { + primary: "farm-primary".to_string(), city: Some("city".to_string()), region: Some("region".to_string()), country: Some("country".to_string()), - gcs: Some(sample_gcs(37.7, -122.4, "9q8yy")), + geohash: "9q8yy".to_string(), }; let farm_create = farm_event( 200, diff --git a/crates/trade/src/dto.rs b/crates/trade/src/dto.rs @@ -110,7 +110,7 @@ impl Dto for RadrootsTradeListing { .with_field(field( "location", "location", - ts_ref("RadrootsListingLocation"), + ts_ref("RadrootsListingPublicLocation"), "crates/trade/src/listing/validation.rs", 38, )) diff --git a/crates/trade/src/listing/codec.rs b/crates/trade/src/listing/codec.rs @@ -13,8 +13,9 @@ use radroots_events::kinds::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA}; use radroots_events::listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingImageSize, - RadrootsListingLocation, RadrootsListingProduct, RadrootsListingStatus, + RadrootsListingProduct, RadrootsListingPublicLocation, RadrootsListingStatus, }; +use radroots_events::location::is_public_geohash5; pub(crate) use radroots_events::order::RadrootsListingParseError as ListingParseError; use radroots_events::plot::RadrootsPlotRef; use radroots_events::resource_area::RadrootsResourceAreaRef; @@ -33,6 +34,11 @@ const TAG_RADROOTS_PLOT: &str = "radroots:plot"; const TAG_LOCATION: &str = "location"; const TAG_IMAGE: &str = "image"; const TAG_GEOHASH: &str = "g"; +const TAG_LABEL: &str = "l"; +const TAG_LABEL_NS: &str = "L"; +const TAG_DD: &str = "dd"; +const TAG_DD_LAT: &str = "dd.lat"; +const TAG_DD_LON: &str = "dd.lon"; const TAG_INVENTORY: &str = "inventory"; const TAG_DELIVERY: &str = "delivery"; const TAG_RADROOTS_AVAILABILITY_START: &str = "radroots:availability_start"; @@ -41,6 +47,13 @@ const TAG_EXPIRES_AT: &str = "expires_at"; const TAG_P: &str = "p"; const TAG_A: &str = "a"; +struct ListingLocationDraft { + primary: String, + city: Option<String>, + region: Option<String>, + country: Option<String>, +} + fn parse_decimal(s: &str, field: &str) -> Result<RadrootsCoreDecimal, ListingParseError> { s.parse::<RadrootsCoreDecimal>() .map_err(|_| ListingParseError::InvalidNumber(field.to_string())) @@ -63,6 +76,40 @@ fn parse_u64_tag_value(value: Option<&String>, field: &str) -> Result<u64, Listi .map_err(|_| ListingParseError::InvalidNumber(field.to_string())) } +fn reject_private_listing_location_content(content: &str) -> Result<(), ListingParseError> { + let trimmed = content.trim(); + if !trimmed.starts_with('{') { + return Ok(()); + } + #[cfg(feature = "serde_json")] + { + let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else { + return Ok(()); + }; + let Some(location) = value.get("location").and_then(|value| value.as_object()) else { + return Ok(()); + }; + for key in [ + "lat", + "lng", + "lon", + "point", + "polygon", + "coordinates", + "accuracy", + "altitude", + "label", + "tag_0", + "gcs", + ] { + if location.contains_key(key) { + return Err(ListingParseError::InvalidJson("location".to_string())); + } + } + } + Ok(()) +} + fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, ListingParseError> { let tag = tags .iter() @@ -85,6 +132,7 @@ pub fn listing_from_event_parts( tags: &[Vec<String>], content: &str, ) -> Result<RadrootsListing, ListingParseError> { + reject_private_listing_location_content(content)?; let d_tag = parse_d_tag(tags)?; let farm_ref = parse_farm_ref(tags)?; let farm_pubkey = parse_farm_pubkey(tags)?; @@ -95,6 +143,11 @@ pub fn listing_from_event_parts( #[cfg(feature = "serde_json")] { if let Ok(mut listing) = serde_json::from_str::<RadrootsListing>(content) { + if let Some(location) = listing.location.as_ref() + && !is_public_geohash5(&location.geohash) + { + return Err(ListingParseError::InvalidTag(TAG_GEOHASH.to_string())); + } if listing.d_tag != d_tag { return Err(ListingParseError::InvalidTag(TAG_D.to_string())); } @@ -192,7 +245,7 @@ fn listing_from_tags( let mut bin_drafts: Vec<BinDraft> = Vec::new(); let mut bin_order = 0usize; let mut discounts: Vec<RadrootsCoreDiscount> = Vec::new(); - let mut location: Option<RadrootsListingLocation> = None; + let mut location: Option<ListingLocationDraft> = None; let mut inventory_available: Option<RadrootsCoreDecimal> = None; let mut availability_status: Option<RadrootsListingStatus> = None; let mut availability_start: Option<u64> = None; @@ -202,10 +255,6 @@ fn listing_from_tags( let mut geohash: Option<String> = None; let mut published_at: Option<u64> = None; - let has_structured_location = tags - .iter() - .any(|tag| tag.first().map(|k| k.as_str()) == Some(TAG_LOCATION) && tag.len() >= 3); - for tag in tags { if tag.is_empty() { continue; @@ -224,7 +273,7 @@ fn listing_from_tags( "location" => { let parse_structured_location = match tag.len() { 0 | 1 => false, - 2 => !has_structured_location && location.is_none(), + 2 => false, _ => true, }; if parse_structured_location { @@ -232,14 +281,11 @@ fn listing_from_tags( if primary.trim().is_empty() { return Err(ListingParseError::InvalidTag(TAG_LOCATION.to_string())); } - let mut loc = RadrootsListingLocation { + let mut loc = ListingLocationDraft { primary: primary.to_string(), city: None, region: None, country: None, - lat: None, - lng: None, - geohash: None, }; if let Some(city) = tag.get(2).and_then(|v| clean_value(v)) { loc.city = Some(city); @@ -368,9 +414,21 @@ fn listing_from_tags( discounts.push(discount); } TAG_GEOHASH => { - if let Some(value) = tag.get(1).and_then(|v| clean_value(v)) { - geohash = Some(value); + let value = tag + .get(1) + .and_then(|v| clean_value(v)) + .ok_or_else(|| ListingParseError::InvalidTag(TAG_GEOHASH.to_string()))?; + if !is_public_geohash5(&value) { + return Err(ListingParseError::InvalidTag(TAG_GEOHASH.to_string())); } + let value = value.to_ascii_lowercase(); + if geohash.as_ref().is_some_and(|existing| existing != &value) { + return Err(ListingParseError::InvalidTag(TAG_GEOHASH.to_string())); + } + geohash = Some(value); + } + TAG_DD | TAG_DD_LAT | TAG_DD_LON | TAG_LABEL | TAG_LABEL_NS => { + return Err(ListingParseError::InvalidTag(key.to_string())); } TAG_INVENTORY => { let value = tag @@ -439,10 +497,19 @@ fn listing_from_tags( }, }; - let location = location.map(|mut loc| { - loc.geohash = loc.geohash.or(geohash); - loc - }); + let location = location + .map(|loc| { + let geohash = + geohash.ok_or_else(|| ListingParseError::InvalidTag(TAG_GEOHASH.to_string()))?; + Ok(RadrootsListingPublicLocation { + primary: loc.primary, + city: loc.city, + region: loc.region, + country: loc.country, + geohash, + }) + }) + .transpose()?; if farm_pubkey != farm_ref.pubkey { return Err(ListingParseError::InvalidTag(TAG_P.to_string())); @@ -1100,7 +1167,7 @@ mod tests { "Region".into(), "SE".into(), ]); - tags.push(vec![TAG_GEOHASH.into(), "u6se".into()]); + tags.push(vec![TAG_GEOHASH.into(), "u6sep".into()]); tags.push(vec![TAG_INVENTORY.into(), "8".into()]); tags.push(vec![TAG_RADROOTS_AVAILABILITY_START.into(), "10".into()]); tags.push(vec![TAG_EXPIRES_AT.into(), "20".into()]); @@ -1145,8 +1212,8 @@ mod tests { "Some(Other { method: \"drone\" })" ); assert_eq!( - listing.location.as_ref().unwrap().geohash.as_deref(), - Some("u6se") + listing.location.as_ref().unwrap().geohash, + "u6sep".to_string() ); assert_eq!(listing.product.process.as_deref(), Some("washed")); assert_eq!(listing.product.lot.as_deref(), Some("lot-7")); @@ -1173,7 +1240,7 @@ mod tests { ) .expect("listing"); - assert_eq!(listing.product.location.as_deref(), Some("fallback")); + assert_eq!(listing.product.location.as_deref(), Some("Farm")); assert_eq!( format!("{:?}", listing.delivery_method), "Some(Other { method: \"parcel\" })" @@ -1242,6 +1309,7 @@ mod tests { "Town".into(), "Region".into(), ]); + tags.push(vec![TAG_GEOHASH.into(), "u6sep".into()]); tags.push(vec![TAG_LOCATION.into()]); tags.push(vec![TAG_LOCATION.into(), "fallback".into()]); let listing = listing_from_tags( @@ -2361,7 +2429,6 @@ mod tests { "1".into(), "g".into(), ], - vec![TAG_GEOHASH.into()], ]; let listing = listing_from_tags( &tags, diff --git a/crates/trade/src/listing/mutation.rs b/crates/trade/src/listing/mutation.rs @@ -157,7 +157,7 @@ mod tests { kinds::{KIND_LISTING, KIND_LISTING_DRAFT}, listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, - RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingProduct, + RadrootsListingDeliveryMethod, RadrootsListingProduct, RadrootsListingPublicLocation, RadrootsListingStatus, }, resource_area::RadrootsResourceAreaRef, @@ -231,14 +231,12 @@ mod tests { status: RadrootsListingStatus::Active, }), delivery_method: Some(RadrootsListingDeliveryMethod::Pickup), - location: Some(RadrootsListingLocation { + location: Some(RadrootsListingPublicLocation { primary: "Farm".to_string(), - city: None, - region: None, - country: None, - lat: None, - lng: None, - geohash: None, + city: Some("Town".to_string()), + region: Some("Region".to_string()), + country: Some("US".to_string()), + geohash: "9q8yy".to_string(), }), images: None, } diff --git a/crates/trade/src/listing/validation.rs b/crates/trade/src/listing/validation.rs @@ -12,8 +12,9 @@ use radroots_events::{ kinds::is_listing_kind, listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingDeliveryMethod, - RadrootsListingLocation, + RadrootsListingPublicLocation, }, + location::{has_textual_locality, is_public_geohash5}, order::RadrootsListingParseError, trade_validation::RadrootsTradeValidationListingError as TradeListingValidationError, }; @@ -35,7 +36,7 @@ pub struct RadrootsTradeListing { pub unit_price: RadrootsCoreMoney, pub inventory_available: RadrootsCoreDecimal, pub availability: RadrootsListingAvailability, - pub location: RadrootsListingLocation, + pub location: RadrootsListingPublicLocation, pub delivery_method: RadrootsListingDeliveryMethod, pub listing: RadrootsListing, } @@ -135,6 +136,20 @@ pub fn validate_listing_event( .location .clone() .ok_or(TradeListingValidationError::MissingLocation)?; + if !has_textual_locality( + &location.primary, + location.city.as_deref(), + location.region.as_deref(), + location.country.as_deref(), + ) { + return Err(TradeListingValidationError::MissingLocationLocality); + } + if location.geohash.trim().is_empty() { + return Err(TradeListingValidationError::MissingLocationGeohash); + } + if !is_public_geohash5(&location.geohash) { + return Err(TradeListingValidationError::InvalidLocationGeohash); + } let delivery_method = listing .delivery_method .clone() @@ -173,7 +188,7 @@ mod tests { kinds::{KIND_LISTING, KIND_LISTING_DRAFT}, listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, - RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingProduct, + RadrootsListingDeliveryMethod, RadrootsListingProduct, RadrootsListingPublicLocation, }, }; @@ -238,14 +253,12 @@ mod tests { status: radroots_events::listing::RadrootsListingStatus::Active, }), delivery_method: Some(RadrootsListingDeliveryMethod::Pickup), - location: Some(RadrootsListingLocation { + location: Some(RadrootsListingPublicLocation { primary: "Farm".into(), - city: None, - region: None, - country: None, - lat: None, - lng: None, - geohash: None, + city: Some("Town".into()), + region: Some("Region".into()), + country: Some("US".into()), + geohash: "9q8yy".into(), }), images: None, } @@ -491,6 +504,43 @@ mod tests { } #[test] + fn validate_listing_rejects_missing_location_locality() { + let mut listing = base_listing(); + let location = listing.location.as_mut().expect("location"); + location.city = None; + location.region = None; + location.country = None; + assert_validation_err( + listing, + TradeListingValidationError::MissingLocationLocality, + ); + } + + #[test] + fn validate_listing_rejects_missing_location_geohash() { + let mut listing = base_listing(); + listing.location.as_mut().expect("location").geohash = " ".into(); + assert_validation_err( + listing, + TradeListingValidationError::ParseError { + error: crate::listing::codec::ListingParseError::InvalidTag("g".to_string()), + }, + ); + } + + #[test] + fn validate_listing_rejects_invalid_location_geohash() { + let mut listing = base_listing(); + listing.location.as_mut().expect("location").geohash = "9q8yyz".into(); + assert_validation_err( + listing, + TradeListingValidationError::ParseError { + error: crate::listing::codec::ListingParseError::InvalidTag("g".to_string()), + }, + ); + } + + #[test] fn validate_listing_rejects_missing_delivery_method() { let mut listing = base_listing(); listing.delivery_method = None; @@ -526,6 +576,9 @@ mod tests { TradeListingValidationError::InvalidInventory, TradeListingValidationError::MissingAvailability, TradeListingValidationError::MissingLocation, + TradeListingValidationError::MissingLocationLocality, + TradeListingValidationError::MissingLocationGeohash, + TradeListingValidationError::InvalidLocationGeohash, TradeListingValidationError::MissingDeliveryMethod, ]; for error in errors {