commit b3733c51fc4686d929a1e6a370b818387a048a0a
parent 3018e3eef4d895a375df342fc1cc11b4ad3ae0c7
Author: triesap <tyson@radroots.org>
Date: Fri, 5 Jun 2026 22:05:38 -0700
core: add marketplace query model
Diffstat:
1 file changed, 970 insertions(+), 3 deletions(-)
diff --git a/crates/tangle_core/src/lib.rs b/crates/tangle_core/src/lib.rs
@@ -4,8 +4,9 @@ use core::fmt;
use std::collections::{BTreeMap, BTreeSet};
use tangle_crypto::verify_event_signature;
use tangle_nips::{
- DeletionRequest, ListingProjectionEvaluation, RelayAuthEvent, evaluate_listing_projection,
- parse_deletion_request, parse_nip50_filter_search, parse_relay_auth_event,
+ DeletionRequest, FulfillmentMethod, ListingProjectionEvaluation, ListingUnit, RelayAuthEvent,
+ evaluate_listing_projection, parse_deletion_request, parse_nip50_filter_search,
+ parse_relay_auth_event,
};
use tangle_protocol::{Event, EventId, Filter, PublicKeyHex, UnixTimestamp, event_to_value};
use tangle_store::{
@@ -1545,6 +1546,551 @@ impl fmt::Display for QueryPlanError {
impl std::error::Error for QueryPlanError {}
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MarketplaceQuerySpec {
+ pub q: Option<String>,
+ pub categories: Vec<String>,
+ pub seller: Option<PublicKeyHex>,
+ pub statuses: Vec<MarketplaceListingStatus>,
+ pub currencies: Vec<String>,
+ pub units: Vec<ListingUnit>,
+ pub min_price: Option<String>,
+ pub max_price: Option<String>,
+ pub fulfillment: Vec<FulfillmentMethod>,
+ pub delivery_only: Option<bool>,
+ pub pickup: Option<bool>,
+ pub latitude_microdegrees: Option<i32>,
+ pub longitude_microdegrees: Option<i32>,
+ pub radius_meters: Option<u64>,
+ pub near: Option<String>,
+ pub sort: MarketplaceSort,
+ pub limit: Option<u64>,
+ pub cursor: Option<MarketplaceCursor>,
+}
+
+impl Default for MarketplaceQuerySpec {
+ fn default() -> Self {
+ Self {
+ q: None,
+ categories: Vec::new(),
+ seller: None,
+ statuses: Vec::new(),
+ currencies: Vec::new(),
+ units: Vec::new(),
+ min_price: None,
+ max_price: None,
+ fulfillment: Vec::new(),
+ delivery_only: None,
+ pickup: None,
+ latitude_microdegrees: None,
+ longitude_microdegrees: None,
+ radius_meters: None,
+ near: None,
+ sort: MarketplaceSort::Relevance,
+ limit: None,
+ cursor: None,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MarketplaceQuery {
+ pub text: Option<MarketplaceSearchText>,
+ pub categories: Vec<String>,
+ pub seller: Option<PublicKeyHex>,
+ pub statuses: Vec<MarketplaceListingStatus>,
+ pub currencies: Vec<String>,
+ pub units: Vec<ListingUnit>,
+ pub min_price: Option<MarketplaceDecimal>,
+ pub max_price: Option<MarketplaceDecimal>,
+ pub fulfillment: Vec<FulfillmentMethod>,
+ pub delivery_only: Option<bool>,
+ pub pickup: Option<bool>,
+ pub location: MarketplaceLocationFilter,
+ pub sort: MarketplaceSort,
+ pub limit: u64,
+ pub cursor: Option<MarketplaceCursor>,
+}
+
+impl MarketplaceQuery {
+ pub const DEFAULT_LIMIT: u64 = 50;
+ pub const MAX_LIMIT: u64 = 100;
+
+ pub fn from_spec(
+ spec: MarketplaceQuerySpec,
+ limits: RuntimeLimits,
+ ) -> Result<Self, MarketplaceQueryError> {
+ let text = marketplace_search_text(spec.q, limits)?;
+ let min_price = spec
+ .min_price
+ .as_deref()
+ .map(|value| MarketplaceDecimal::new("min_price", value))
+ .transpose()?;
+ let max_price = spec
+ .max_price
+ .as_deref()
+ .map(|value| MarketplaceDecimal::new("max_price", value))
+ .transpose()?;
+ if let (Some(min_price), Some(max_price)) = (&min_price, &max_price)
+ && decimal_greater_than(min_price, max_price)
+ {
+ return Err(MarketplaceQueryError::new(
+ MarketplaceQueryErrorKind::InvalidPriceRange,
+ "min_price must not exceed max_price",
+ ));
+ }
+ let location = MarketplaceLocationFilter::from_spec(
+ spec.latitude_microdegrees,
+ spec.longitude_microdegrees,
+ spec.radius_meters,
+ spec.near,
+ )?;
+ if spec.sort == MarketplaceSort::Distance && !location.has_distance_reference() {
+ return Err(MarketplaceQueryError::new(
+ MarketplaceQueryErrorKind::MissingDistanceReference,
+ "distance sort requires a point or near filter",
+ ));
+ }
+ let limit = spec.limit.unwrap_or(Self::DEFAULT_LIMIT);
+ if limit == 0 || limit > Self::MAX_LIMIT {
+ return Err(MarketplaceQueryError::new(
+ MarketplaceQueryErrorKind::LimitOutOfRange,
+ format!("limit must be between 1 and {}", Self::MAX_LIMIT),
+ ));
+ }
+ if spec
+ .cursor
+ .as_ref()
+ .is_some_and(|cursor| cursor.sort != spec.sort)
+ {
+ return Err(MarketplaceQueryError::new(
+ MarketplaceQueryErrorKind::CursorSortMismatch,
+ "cursor sort must match query sort",
+ ));
+ }
+ Ok(Self {
+ text,
+ categories: normalized_text_filters("category", spec.categories)?,
+ seller: spec.seller,
+ statuses: unique_sorted(spec.statuses),
+ currencies: normalized_currencies(spec.currencies)?,
+ units: unique_listing_units(spec.units),
+ min_price,
+ max_price,
+ fulfillment: unique_sorted(spec.fulfillment),
+ delivery_only: spec.delivery_only,
+ pickup: spec.pickup,
+ location,
+ sort: spec.sort,
+ limit,
+ cursor: spec.cursor,
+ })
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MarketplaceSearchText {
+ pub raw: String,
+ pub terms: Vec<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MarketplaceDecimal {
+ pub raw: String,
+ whole: String,
+ fraction: String,
+}
+
+impl MarketplaceDecimal {
+ pub fn new(field: &'static str, value: &str) -> Result<Self, MarketplaceQueryError> {
+ let raw = value.trim();
+ let Some((whole, fraction)) = normalized_decimal_parts(raw) else {
+ return Err(MarketplaceQueryError::new(
+ MarketplaceQueryErrorKind::InvalidDecimal,
+ format!("{field} must be an exact unsigned decimal"),
+ ));
+ };
+ Ok(Self {
+ raw: raw.to_owned(),
+ whole,
+ fraction,
+ })
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MarketplaceLocationFilter {
+ pub point: Option<MarketplaceGeoPoint>,
+ pub radius_meters: Option<u64>,
+ pub near: Option<String>,
+}
+
+impl MarketplaceLocationFilter {
+ pub fn from_spec(
+ latitude_microdegrees: Option<i32>,
+ longitude_microdegrees: Option<i32>,
+ radius_meters: Option<u64>,
+ near: Option<String>,
+ ) -> Result<Self, MarketplaceQueryError> {
+ let point = match (latitude_microdegrees, longitude_microdegrees) {
+ (Some(latitude_microdegrees), Some(longitude_microdegrees)) => Some(
+ MarketplaceGeoPoint::new(latitude_microdegrees, longitude_microdegrees)?,
+ ),
+ (None, None) => None,
+ _ => {
+ return Err(MarketplaceQueryError::new(
+ MarketplaceQueryErrorKind::InvalidLocation,
+ "lat and lon must be provided together",
+ ));
+ }
+ };
+ let radius_meters = match radius_meters {
+ Some(0) => {
+ return Err(MarketplaceQueryError::new(
+ MarketplaceQueryErrorKind::InvalidLocation,
+ "radius_meters must be greater than zero",
+ ));
+ }
+ Some(_) if point.is_none() => {
+ return Err(MarketplaceQueryError::new(
+ MarketplaceQueryErrorKind::InvalidLocation,
+ "radius_meters requires lat and lon",
+ ));
+ }
+ value => value,
+ };
+ let near = normalize_optional_text("near", near)?;
+ Ok(Self {
+ point,
+ radius_meters,
+ near,
+ })
+ }
+
+ pub fn has_distance_reference(&self) -> bool {
+ self.point.is_some() || self.near.is_some()
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct MarketplaceGeoPoint {
+ pub latitude_microdegrees: i32,
+ pub longitude_microdegrees: i32,
+}
+
+impl MarketplaceGeoPoint {
+ pub fn new(
+ latitude_microdegrees: i32,
+ longitude_microdegrees: i32,
+ ) -> Result<Self, MarketplaceQueryError> {
+ if !(-90_000_000..=90_000_000).contains(&latitude_microdegrees) {
+ return Err(MarketplaceQueryError::new(
+ MarketplaceQueryErrorKind::InvalidLocation,
+ "lat must be between -90 and 90 degrees",
+ ));
+ }
+ if !(-180_000_000..=180_000_000).contains(&longitude_microdegrees) {
+ return Err(MarketplaceQueryError::new(
+ MarketplaceQueryErrorKind::InvalidLocation,
+ "lon must be between -180 and 180 degrees",
+ ));
+ }
+ Ok(Self {
+ latitude_microdegrees,
+ longitude_microdegrees,
+ })
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MarketplaceCursorSpec {
+ pub version: u16,
+ pub sort: MarketplaceSort,
+ pub score: Option<i64>,
+ pub distance_meters: Option<u64>,
+ pub price: Option<String>,
+ pub updated_at: UnixTimestamp,
+ pub event_id: EventId,
+ pub filter_hash: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MarketplaceCursor {
+ pub version: u16,
+ pub sort: MarketplaceSort,
+ pub score: Option<i64>,
+ pub distance_meters: Option<u64>,
+ pub price: Option<MarketplaceDecimal>,
+ pub updated_at: UnixTimestamp,
+ pub event_id: EventId,
+ pub filter_hash: String,
+}
+
+impl MarketplaceCursor {
+ pub fn from_spec(spec: MarketplaceCursorSpec) -> Result<Self, MarketplaceQueryError> {
+ if spec.version == 0 {
+ return Err(invalid_cursor("cursor version must be greater than zero"));
+ }
+ let filter_hash = spec.filter_hash.trim();
+ if filter_hash.is_empty() {
+ return Err(invalid_cursor("cursor filter_hash must not be empty"));
+ }
+ let price = spec
+ .price
+ .as_deref()
+ .map(|value| MarketplaceDecimal::new("cursor price", value))
+ .transpose()?;
+ match spec.sort {
+ MarketplaceSort::Relevance if spec.score.is_none() => {
+ return Err(invalid_cursor("relevance cursor requires score"));
+ }
+ MarketplaceSort::Distance if spec.distance_meters.is_none() => {
+ return Err(invalid_cursor("distance cursor requires distance"));
+ }
+ MarketplaceSort::PriceAsc | MarketplaceSort::PriceDesc if price.is_none() => {
+ return Err(invalid_cursor("price cursor requires price"));
+ }
+ _ => {}
+ }
+ Ok(Self {
+ version: spec.version,
+ sort: spec.sort,
+ score: spec.score,
+ distance_meters: spec.distance_meters,
+ price,
+ updated_at: spec.updated_at,
+ event_id: spec.event_id,
+ filter_hash: filter_hash.to_owned(),
+ })
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub enum MarketplaceListingStatus {
+ Active,
+ Sold,
+ Draft,
+ Inactive,
+ Expired,
+ Deleted,
+ Hidden,
+ Rejected,
+}
+
+impl MarketplaceListingStatus {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Active => "active",
+ Self::Sold => "sold",
+ Self::Draft => "draft",
+ Self::Inactive => "inactive",
+ Self::Expired => "expired",
+ Self::Deleted => "deleted",
+ Self::Hidden => "hidden",
+ Self::Rejected => "rejected",
+ }
+ }
+}
+
+impl fmt::Display for MarketplaceListingStatus {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(self.as_str())
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum MarketplaceSort {
+ Relevance,
+ Freshness,
+ PriceAsc,
+ PriceDesc,
+ Distance,
+ SellerTrust,
+}
+
+impl MarketplaceSort {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Relevance => "relevance",
+ Self::Freshness => "freshness",
+ Self::PriceAsc => "price_asc",
+ Self::PriceDesc => "price_desc",
+ Self::Distance => "distance",
+ Self::SellerTrust => "seller_trust",
+ }
+ }
+}
+
+impl fmt::Display for MarketplaceSort {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(self.as_str())
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MarketplaceQueryError {
+ kind: MarketplaceQueryErrorKind,
+ message: String,
+}
+
+impl MarketplaceQueryError {
+ pub fn new(kind: MarketplaceQueryErrorKind, message: impl Into<String>) -> Self {
+ Self {
+ kind,
+ message: message.into(),
+ }
+ }
+
+ pub fn runtime_limit(violation: RuntimeLimitViolation) -> Self {
+ Self::new(
+ MarketplaceQueryErrorKind::RuntimeLimit,
+ format!("runtime limit: {violation}"),
+ )
+ }
+
+ pub fn kind(&self) -> MarketplaceQueryErrorKind {
+ self.kind
+ }
+
+ pub fn message(&self) -> &str {
+ &self.message
+ }
+}
+
+impl fmt::Display for MarketplaceQueryError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(&self.message)
+ }
+}
+
+impl std::error::Error for MarketplaceQueryError {}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum MarketplaceQueryErrorKind {
+ RuntimeLimit,
+ EmptyFilterValue,
+ InvalidDecimal,
+ InvalidPriceRange,
+ InvalidLocation,
+ LimitOutOfRange,
+ MissingDistanceReference,
+ InvalidCursor,
+ CursorSortMismatch,
+}
+
+fn marketplace_search_text(
+ q: Option<String>,
+ limits: RuntimeLimits,
+) -> Result<Option<MarketplaceSearchText>, MarketplaceQueryError> {
+ let Some(q) = q else {
+ return Ok(None);
+ };
+ limits
+ .validate_search_query(&q)
+ .map_err(MarketplaceQueryError::runtime_limit)?;
+ let raw = q.trim();
+ if raw.is_empty() {
+ return Ok(None);
+ }
+ let terms = unique_sorted(
+ raw.split_whitespace()
+ .map(|term| term.to_ascii_lowercase())
+ .collect(),
+ );
+ Ok(Some(MarketplaceSearchText {
+ raw: raw.to_owned(),
+ terms,
+ }))
+}
+
+fn normalized_text_filters(
+ field: &'static str,
+ values: Vec<String>,
+) -> Result<Vec<String>, MarketplaceQueryError> {
+ let values = values
+ .into_iter()
+ .map(|value| normalize_required_text(field, value))
+ .collect::<Result<Vec<_>, _>>()?;
+ Ok(unique_sorted(values))
+}
+
+fn normalized_currencies(values: Vec<String>) -> Result<Vec<String>, MarketplaceQueryError> {
+ let values = values
+ .into_iter()
+ .map(|value| {
+ normalize_required_text("currency", value).map(|value| value.to_ascii_uppercase())
+ })
+ .collect::<Result<Vec<_>, _>>()?;
+ Ok(unique_sorted(values))
+}
+
+fn normalize_required_text(
+ field: &'static str,
+ value: String,
+) -> Result<String, MarketplaceQueryError> {
+ let normalized = value.trim().to_ascii_lowercase();
+ if normalized.is_empty() {
+ return Err(MarketplaceQueryError::new(
+ MarketplaceQueryErrorKind::EmptyFilterValue,
+ format!("{field} filter value must not be empty"),
+ ));
+ }
+ Ok(normalized)
+}
+
+fn normalize_optional_text(
+ field: &'static str,
+ value: Option<String>,
+) -> Result<Option<String>, MarketplaceQueryError> {
+ value
+ .map(|value| normalize_required_text(field, value))
+ .transpose()
+}
+
+fn unique_listing_units(mut values: Vec<ListingUnit>) -> Vec<ListingUnit> {
+ values.sort_by_key(|unit| unit.canonical());
+ values.dedup();
+ values
+}
+
+fn normalized_decimal_parts(value: &str) -> Option<(String, String)> {
+ let mut parts = value.split('.');
+ let whole = parts.next().unwrap_or_default();
+ let fraction = parts.next();
+ if parts.next().is_some()
+ || whole.is_empty()
+ || !whole.bytes().all(|byte| byte.is_ascii_digit())
+ {
+ return None;
+ }
+ let fraction = match fraction {
+ Some(value) if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) => {
+ return None;
+ }
+ Some(value) => value.trim_end_matches('0').to_owned(),
+ None => String::new(),
+ };
+ let whole = whole.trim_start_matches('0');
+ let whole = if whole.is_empty() { "0" } else { whole };
+ Some((whole.to_owned(), fraction))
+}
+
+fn decimal_greater_than(left: &MarketplaceDecimal, right: &MarketplaceDecimal) -> bool {
+ match left.whole.len().cmp(&right.whole.len()) {
+ std::cmp::Ordering::Equal => {}
+ ordering => return ordering == std::cmp::Ordering::Greater,
+ }
+ match left.whole.cmp(&right.whole) {
+ std::cmp::Ordering::Equal => {}
+ ordering => return ordering == std::cmp::Ordering::Greater,
+ }
+ left.fraction > right.fraction
+}
+
+fn invalid_cursor(message: &'static str) -> MarketplaceQueryError {
+ MarketplaceQueryError::new(MarketplaceQueryErrorKind::InvalidCursor, message)
+}
+
fn unique_sorted<T>(values: Vec<T>) -> Vec<T>
where
T: Ord,
@@ -1849,13 +2395,19 @@ mod tests {
AdmissionContext, AdmissionEffect, AdmissionEvent, AdmissionEventKind, AdmissionPolicy,
AdmissionRejectionKind, EventIngestionEffect, EventIngestionRejectionKind, EventIngestor,
EventParser, EventValidationRejection, EventValidationRejectionKind, EventValidator,
+ MarketplaceCursor, MarketplaceCursorSpec, MarketplaceDecimal, MarketplaceGeoPoint,
+ MarketplaceListingStatus, MarketplaceLocationFilter, MarketplaceQuery,
+ MarketplaceQueryErrorKind, MarketplaceQuerySpec, MarketplaceSort,
Nip50QueryCompileErrorKind, Nip50QueryCompiler, NostrFilterCompileErrorKind,
NostrFilterCompiler, ProjectionExclusionReason, QueryExecutionMode, QueryPlan,
QueryPlanBranch, QueryPlanBranchSpec, QueryPlanError, QuerySearch, QuerySort, QuerySource,
QueryTagFilter, RuntimeLimitConfigError, RuntimeLimitKind, RuntimeLimitValues,
RuntimeLimits, UnapprovedSellerAction,
};
- use tangle_nips::{ListingProjection, evaluate_listing_projection, parse_deletion_request};
+ use tangle_nips::{
+ FulfillmentMethod, ListingProjection, ListingUnit, evaluate_listing_projection,
+ parse_deletion_request,
+ };
use tangle_protocol::{
AddressCoordinate, Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp,
UnsignedEvent, filter_from_value,
@@ -3319,6 +3871,421 @@ mod tests {
}
#[test]
+ fn marketplace_query_model_normalizes_http_search_constraints() {
+ let event_id = EventId::new(&"c".repeat(EventId::HEX_LENGTH)).expect("id");
+ let cursor = MarketplaceCursor::from_spec(MarketplaceCursorSpec {
+ version: 1,
+ sort: MarketplaceSort::Distance,
+ score: None,
+ distance_meters: Some(1234),
+ price: None,
+ updated_at: UnixTimestamp::new(50),
+ event_id: event_id.clone(),
+ filter_hash: " hash ".to_owned(),
+ })
+ .expect("cursor");
+ let query = MarketplaceQuery::from_spec(
+ MarketplaceQuerySpec {
+ q: Some(" Fresh carrots fresh ".to_owned()),
+ categories: vec![
+ " Vegetables ".to_owned(),
+ "csa".to_owned(),
+ "vegetables".to_owned(),
+ ],
+ seller: Some(pubkey("1")),
+ statuses: vec![
+ MarketplaceListingStatus::Sold,
+ MarketplaceListingStatus::Active,
+ MarketplaceListingStatus::Active,
+ ],
+ currencies: vec!["usd".to_owned(), " CAD ".to_owned(), "USD".to_owned()],
+ units: vec![ListingUnit::Lb, ListingUnit::Kg, ListingUnit::Lb],
+ min_price: Some("001.500".to_owned()),
+ max_price: Some("10.0".to_owned()),
+ fulfillment: vec![
+ FulfillmentMethod::Delivery,
+ FulfillmentMethod::Pickup,
+ FulfillmentMethod::Delivery,
+ ],
+ delivery_only: Some(false),
+ pickup: Some(true),
+ latitude_microdegrees: Some(47_606_200),
+ longitude_microdegrees: Some(-122_332_100),
+ radius_meters: Some(25_000),
+ near: Some(" Ballard ".to_owned()),
+ sort: MarketplaceSort::Distance,
+ limit: Some(25),
+ cursor: Some(cursor.clone()),
+ },
+ RuntimeLimits::default(),
+ )
+ .expect("query");
+
+ assert_eq!(
+ query.text.as_ref().expect("text").raw,
+ "Fresh carrots fresh"
+ );
+ assert_eq!(
+ query.text.as_ref().expect("text").terms,
+ ["carrots".to_owned(), "fresh".to_owned()]
+ );
+ assert_eq!(
+ query.categories,
+ ["csa".to_owned(), "vegetables".to_owned()]
+ );
+ assert_eq!(query.seller, Some(pubkey("1")));
+ assert_eq!(
+ query.statuses,
+ [
+ MarketplaceListingStatus::Active,
+ MarketplaceListingStatus::Sold
+ ]
+ );
+ assert_eq!(query.currencies, ["CAD".to_owned(), "USD".to_owned()]);
+ assert_eq!(query.units, [ListingUnit::Kg, ListingUnit::Lb]);
+ assert_eq!(query.min_price.as_ref().expect("min").raw, "001.500");
+ assert_eq!(query.min_price.as_ref().expect("min").whole, "1");
+ assert_eq!(query.min_price.as_ref().expect("min").fraction, "5");
+ assert_eq!(query.max_price.as_ref().expect("max").whole, "10");
+ assert_eq!(query.max_price.as_ref().expect("max").fraction, "");
+ assert_eq!(
+ query.fulfillment,
+ [FulfillmentMethod::Pickup, FulfillmentMethod::Delivery]
+ );
+ assert_eq!(query.delivery_only, Some(false));
+ assert_eq!(query.pickup, Some(true));
+ assert_eq!(
+ query.location.point,
+ Some(MarketplaceGeoPoint {
+ latitude_microdegrees: 47_606_200,
+ longitude_microdegrees: -122_332_100,
+ })
+ );
+ assert_eq!(query.location.radius_meters, Some(25_000));
+ assert_eq!(query.location.near, Some("ballard".to_owned()));
+ assert!(query.location.has_distance_reference());
+ assert_eq!(query.sort, MarketplaceSort::Distance);
+ assert_eq!(query.limit, 25);
+ assert_eq!(query.cursor, Some(cursor));
+ assert_eq!(event_id.as_str(), &"c".repeat(EventId::HEX_LENGTH));
+ }
+
+ #[test]
+ fn marketplace_query_model_handles_defaults_labels_and_cursors() {
+ let default_query =
+ MarketplaceQuery::from_spec(MarketplaceQuerySpec::default(), RuntimeLimits::default())
+ .expect("default query");
+ let blank_query = MarketplaceQuery::from_spec(
+ MarketplaceQuerySpec {
+ q: Some(" ".to_owned()),
+ ..MarketplaceQuerySpec::default()
+ },
+ RuntimeLimits::default(),
+ )
+ .expect("blank query");
+ let relevance_cursor = MarketplaceCursor::from_spec(MarketplaceCursorSpec {
+ version: 1,
+ sort: MarketplaceSort::Relevance,
+ score: Some(9),
+ distance_meters: None,
+ price: None,
+ updated_at: UnixTimestamp::new(60),
+ event_id: EventId::new(&"d".repeat(EventId::HEX_LENGTH)).expect("id"),
+ filter_hash: "filter".to_owned(),
+ })
+ .expect("relevance cursor");
+ let price_cursor = MarketplaceCursor::from_spec(MarketplaceCursorSpec {
+ version: 1,
+ sort: MarketplaceSort::PriceAsc,
+ score: None,
+ distance_meters: None,
+ price: Some("009.9900".to_owned()),
+ updated_at: UnixTimestamp::new(61),
+ event_id: EventId::new(&"e".repeat(EventId::HEX_LENGTH)).expect("id"),
+ filter_hash: "price".to_owned(),
+ })
+ .expect("price cursor");
+ let freshness_cursor = MarketplaceCursor::from_spec(MarketplaceCursorSpec {
+ version: 1,
+ sort: MarketplaceSort::Freshness,
+ score: None,
+ distance_meters: None,
+ price: None,
+ updated_at: UnixTimestamp::new(62),
+ event_id: EventId::new(&"f".repeat(EventId::HEX_LENGTH)).expect("id"),
+ filter_hash: "freshness".to_owned(),
+ })
+ .expect("freshness cursor");
+ let zero_decimal = MarketplaceDecimal::new("price", "000").expect("zero");
+ let fraction_decimal = MarketplaceDecimal::new("price", "1.2300").expect("fraction");
+
+ assert_eq!(default_query.text, None);
+ assert_eq!(default_query.limit, MarketplaceQuery::DEFAULT_LIMIT);
+ assert_eq!(default_query.sort, MarketplaceSort::Relevance);
+ assert_eq!(blank_query.text, None);
+ assert_eq!(relevance_cursor.score, Some(9));
+ assert_eq!(price_cursor.price.as_ref().expect("price").whole, "9");
+ assert_eq!(price_cursor.price.as_ref().expect("price").fraction, "99");
+ assert_eq!(freshness_cursor.filter_hash, "freshness");
+ assert_eq!(zero_decimal.whole, "0");
+ assert_eq!(zero_decimal.fraction, "");
+ assert_eq!(fraction_decimal.whole, "1");
+ assert_eq!(fraction_decimal.fraction, "23");
+ assert_eq!(
+ [
+ MarketplaceListingStatus::Active.as_str(),
+ MarketplaceListingStatus::Sold.as_str(),
+ MarketplaceListingStatus::Draft.as_str(),
+ MarketplaceListingStatus::Inactive.as_str(),
+ MarketplaceListingStatus::Expired.as_str(),
+ MarketplaceListingStatus::Deleted.as_str(),
+ MarketplaceListingStatus::Hidden.as_str(),
+ MarketplaceListingStatus::Rejected.as_str(),
+ ],
+ [
+ "active", "sold", "draft", "inactive", "expired", "deleted", "hidden", "rejected",
+ ]
+ );
+ assert_eq!(
+ [
+ MarketplaceSort::Relevance.as_str(),
+ MarketplaceSort::Freshness.as_str(),
+ MarketplaceSort::PriceAsc.as_str(),
+ MarketplaceSort::PriceDesc.as_str(),
+ MarketplaceSort::Distance.as_str(),
+ MarketplaceSort::SellerTrust.as_str(),
+ ],
+ [
+ "relevance",
+ "freshness",
+ "price_asc",
+ "price_desc",
+ "distance",
+ "seller_trust",
+ ]
+ );
+ assert_eq!(MarketplaceListingStatus::Hidden.to_string(), "hidden");
+ assert_eq!(MarketplaceSort::SellerTrust.to_string(), "seller_trust");
+ }
+
+ #[test]
+ fn marketplace_query_model_rejects_invalid_constraints() {
+ let runtime_limit = MarketplaceQuery::from_spec(
+ MarketplaceQuerySpec {
+ q: Some("fresh carrots".to_owned()),
+ ..MarketplaceQuerySpec::default()
+ },
+ limits_with(|values| values.max_search_tokens = 1),
+ )
+ .expect_err("runtime");
+ let empty_category = MarketplaceQuery::from_spec(
+ MarketplaceQuerySpec {
+ categories: vec![" ".to_owned()],
+ ..MarketplaceQuerySpec::default()
+ },
+ RuntimeLimits::default(),
+ )
+ .expect_err("category");
+ let empty_near =
+ MarketplaceLocationFilter::from_spec(None, None, None, Some(" ".to_owned()))
+ .expect_err("near");
+ let invalid_decimal = MarketplaceDecimal::new("min_price", "1..2").expect_err("decimal");
+ let empty_whole = MarketplaceDecimal::new("min_price", ".2").expect_err("decimal");
+ let bad_whole = MarketplaceDecimal::new("min_price", "a.2").expect_err("decimal");
+ let empty_fraction = MarketplaceDecimal::new("min_price", "1.").expect_err("decimal");
+ let bad_fraction = MarketplaceDecimal::new("min_price", "1.a").expect_err("decimal");
+ let invalid_price_range = MarketplaceQuery::from_spec(
+ MarketplaceQuerySpec {
+ min_price: Some("2".to_owned()),
+ max_price: Some("1.99".to_owned()),
+ ..MarketplaceQuerySpec::default()
+ },
+ RuntimeLimits::default(),
+ )
+ .expect_err("price range");
+ let invalid_fraction_range = MarketplaceQuery::from_spec(
+ MarketplaceQuerySpec {
+ min_price: Some("1.2".to_owned()),
+ max_price: Some("1.10".to_owned()),
+ ..MarketplaceQuerySpec::default()
+ },
+ RuntimeLimits::default(),
+ )
+ .expect_err("fraction range");
+ let missing_lon =
+ MarketplaceLocationFilter::from_spec(Some(1), None, None, None).expect_err("location");
+ let query_missing_lon = MarketplaceQuery::from_spec(
+ MarketplaceQuerySpec {
+ latitude_microdegrees: Some(1),
+ ..MarketplaceQuerySpec::default()
+ },
+ RuntimeLimits::default(),
+ )
+ .expect_err("query location");
+ let zero_radius = MarketplaceLocationFilter::from_spec(Some(1), Some(2), Some(0), None)
+ .expect_err("radius");
+ let radius_without_point = MarketplaceLocationFilter::from_spec(None, None, Some(1), None)
+ .expect_err("radius point");
+ let bad_latitude = MarketplaceGeoPoint::new(90_000_001, 0).expect_err("lat");
+ let bad_longitude = MarketplaceGeoPoint::new(0, 180_000_001).expect_err("lon");
+ let missing_distance_reference = MarketplaceQuery::from_spec(
+ MarketplaceQuerySpec {
+ sort: MarketplaceSort::Distance,
+ ..MarketplaceQuerySpec::default()
+ },
+ RuntimeLimits::default(),
+ )
+ .expect_err("distance");
+ let bad_limit = MarketplaceQuery::from_spec(
+ MarketplaceQuerySpec {
+ limit: Some(MarketplaceQuery::MAX_LIMIT + 1),
+ ..MarketplaceQuerySpec::default()
+ },
+ RuntimeLimits::default(),
+ )
+ .expect_err("limit");
+ let cursor_sort = MarketplaceCursor::from_spec(MarketplaceCursorSpec {
+ version: 1,
+ sort: MarketplaceSort::Relevance,
+ score: Some(1),
+ distance_meters: None,
+ price: None,
+ updated_at: UnixTimestamp::new(70),
+ event_id: EventId::new(&"a".repeat(EventId::HEX_LENGTH)).expect("id"),
+ filter_hash: "cursor".to_owned(),
+ })
+ .expect("cursor");
+ let cursor_sort_mismatch = MarketplaceQuery::from_spec(
+ MarketplaceQuerySpec {
+ sort: MarketplaceSort::Freshness,
+ cursor: Some(cursor_sort),
+ ..MarketplaceQuerySpec::default()
+ },
+ RuntimeLimits::default(),
+ )
+ .expect_err("cursor sort");
+
+ assert_eq!(
+ runtime_limit.kind(),
+ MarketplaceQueryErrorKind::RuntimeLimit
+ );
+ assert!(runtime_limit.message().starts_with("runtime limit:"));
+ assert_eq!(runtime_limit.to_string(), runtime_limit.message());
+ assert_eq!(
+ empty_category.kind(),
+ MarketplaceQueryErrorKind::EmptyFilterValue
+ );
+ assert_eq!(
+ empty_category.message(),
+ "category filter value must not be empty"
+ );
+ assert_eq!(
+ empty_near.kind(),
+ MarketplaceQueryErrorKind::EmptyFilterValue
+ );
+ assert_eq!(empty_near.message(), "near filter value must not be empty");
+ for error in [
+ invalid_decimal,
+ empty_whole,
+ bad_whole,
+ empty_fraction,
+ bad_fraction,
+ ] {
+ assert_eq!(error.kind(), MarketplaceQueryErrorKind::InvalidDecimal);
+ assert_eq!(
+ error.message(),
+ "min_price must be an exact unsigned decimal"
+ );
+ }
+ assert_eq!(
+ invalid_price_range.kind(),
+ MarketplaceQueryErrorKind::InvalidPriceRange
+ );
+ assert_eq!(
+ invalid_fraction_range.kind(),
+ MarketplaceQueryErrorKind::InvalidPriceRange
+ );
+ for error in [
+ missing_lon,
+ query_missing_lon,
+ zero_radius,
+ radius_without_point,
+ bad_latitude,
+ bad_longitude,
+ ] {
+ assert_eq!(error.kind(), MarketplaceQueryErrorKind::InvalidLocation);
+ }
+ assert_eq!(
+ missing_distance_reference.kind(),
+ MarketplaceQueryErrorKind::MissingDistanceReference
+ );
+ assert_eq!(bad_limit.kind(), MarketplaceQueryErrorKind::LimitOutOfRange);
+ assert_eq!(
+ cursor_sort_mismatch.kind(),
+ MarketplaceQueryErrorKind::CursorSortMismatch
+ );
+ }
+
+ #[test]
+ fn marketplace_cursor_model_rejects_invalid_payloads() {
+ let base = || MarketplaceCursorSpec {
+ version: 1,
+ sort: MarketplaceSort::Freshness,
+ score: None,
+ distance_meters: None,
+ price: None,
+ updated_at: UnixTimestamp::new(80),
+ event_id: EventId::new(&"b".repeat(EventId::HEX_LENGTH)).expect("id"),
+ filter_hash: "filter".to_owned(),
+ };
+ let zero_version = MarketplaceCursor::from_spec(MarketplaceCursorSpec {
+ version: 0,
+ ..base()
+ })
+ .expect_err("version");
+ let empty_hash = MarketplaceCursor::from_spec(MarketplaceCursorSpec {
+ filter_hash: " ".to_owned(),
+ ..base()
+ })
+ .expect_err("hash");
+ let missing_score = MarketplaceCursor::from_spec(MarketplaceCursorSpec {
+ sort: MarketplaceSort::Relevance,
+ ..base()
+ })
+ .expect_err("score");
+ let missing_distance = MarketplaceCursor::from_spec(MarketplaceCursorSpec {
+ sort: MarketplaceSort::Distance,
+ ..base()
+ })
+ .expect_err("distance");
+ let missing_price = MarketplaceCursor::from_spec(MarketplaceCursorSpec {
+ sort: MarketplaceSort::PriceDesc,
+ ..base()
+ })
+ .expect_err("price");
+ let invalid_price = MarketplaceCursor::from_spec(MarketplaceCursorSpec {
+ sort: MarketplaceSort::PriceAsc,
+ price: Some("bad".to_owned()),
+ ..base()
+ })
+ .expect_err("price decimal");
+
+ for error in [
+ zero_version,
+ empty_hash,
+ missing_score,
+ missing_distance,
+ missing_price,
+ ] {
+ assert_eq!(error.kind(), MarketplaceQueryErrorKind::InvalidCursor);
+ }
+ assert_eq!(
+ invalid_price.kind(),
+ MarketplaceQueryErrorKind::InvalidDecimal
+ );
+ }
+
+ #[test]
fn nostr_filter_compiler_builds_search_backed_query_plans() {
let filter = filter_from_value(&serde_json::json!({
"ids": ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"],