lib

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

commit 240d9b7f2df0218dd40c2b81c8990ab983cffc89
parent dc867c0d5d86e90328d968e49773063736b367d9
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Jul 2026 22:02:41 +0000

event: consolidate operational listing decoding

- make canonical tags authoritative for every operational consumer
- preserve typed parser errors across serde and no-serde lanes
- execute exact envelope and error-taxonomy conformance vectors
- remove the duplicate trade decoder

Diffstat:
MCHANGELOG.md | 6++++++
Mcontracts/conformance/vectors/listing/parse_event.v1.json | 211++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcontracts/operations.toml | 3++-
Mcontracts/releases/1.0.0-alpha.1.toml | 10++++++++++
Mcrates/event_codec/src/listing/decode.rs | 295++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Acrates/event_codec/tests/fixtures/listing_parse_event.v1.json | 215+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/tests/listing.rs | 212+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Dcrates/trade/src/listing/codec.rs | 2668-------------------------------------------------------------------------------
Mcrates/trade/src/listing/mod.rs | 8++------
Mcrates/trade/src/listing/validation.rs | 174+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mtools/xtask/src/contract.rs | 6+++++-
11 files changed, 976 insertions(+), 2832 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -9,6 +9,10 @@ publish policy both pass for the same source revision. ### Changed +- Operational listing decoding now has one tag-authoritative implementation in + `radroots_event_codec`. The typed parts decoder reports the established + listing error taxonomy, and JSON content can no longer override canonical + product, inventory, or bin tags in trade and replica consumers. - Generic NIP-01 identifier and signature verification is now independent of knowledge decoding, and every dynamic Nostr kind conversion rejects values above `65535` instead of truncating them. Canonical-length author keys that @@ -65,6 +69,8 @@ publish policy both pass for the same source revision. ### Removed +- The duplicate private operational listing parser in `radroots_trade` was + removed; trade validation now consumes the canonical event codec. - Legacy calendar event models and permissive calendar tag-builder authoring paths were removed. - Direct `RadrootsProfile` draft encoding and the identity profile publisher were diff --git a/contracts/conformance/vectors/listing/parse_event.v1.json b/contracts/conformance/vectors/listing/parse_event.v1.json @@ -1,19 +1,214 @@ { - "suite": "listing", + "suite": "operational_listing_parse_event", "contract_version": "1.0.0", "vectors": [ { - "id": "listing_parse_event_minimal_001", - "kind": "listing.parse_event", + "id": "operational_listing_tags_override_json_content_001", + "kind": "listing.parse_event.valid", "input": { - "event": { - "kind": "listing", - "content_shape": "listing_json_or_markdown", - "tags_shape": "listing_tags_full" + "event_kind": 30402, + "content": "{\"product\":{\"title\":\"Content override\",\"category\":\"Content category\"},\"inventory_available\":\"99\"}", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"], + ["price", "10", "USD"] + ] + }, + "expected": { + "listing": { + "d_tag": "AAAAAAAAAAAAAAAAAAAAAg", + "title": "Widget", + "category": "Tools", + "primary_bin_id": "bin-1", + "quantity_amount": "1", + "quantity_unit": "each", + "price_amount": "10", + "price_currency": "USD", + "inventory_available": null } + } + }, + { + "id": "operational_listing_missing_d_001", + "kind": "listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "# Widget", + "tags": [ + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"] + ] + }, + "expected": { + "error": {"MissingTag": "d"} + } + }, + { + "id": "operational_listing_invalid_d_001", + "kind": "listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "# Widget", + "tags": [ + ["d", "bad d"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"] + ] + }, + "expected": { + "error": {"InvalidTag": "d"} + } + }, + { + "id": "operational_listing_invalid_number_001", + "kind": "listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "# Widget", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "bad", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"] + ] + }, + "expected": { + "error": {"InvalidNumber": "radroots:bin"} + } + }, + { + "id": "operational_listing_invalid_unit_001", + "kind": "listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "# Widget", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "bad"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"] + ] + }, + "expected": { + "error": "InvalidUnit" + } + }, + { + "id": "operational_listing_invalid_currency_001", + "kind": "listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "# Widget", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "US", "1", "each"] + ] + }, + "expected": { + "error": "InvalidCurrency" + } + }, + { + "id": "operational_listing_invalid_discount_001", + "kind": "listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "# Widget", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"], + ["radroots:discount", "{"] + ] + }, + "expected": { + "error": {"InvalidDiscount": "radroots:discount"} + } + }, + { + "id": "operational_listing_private_json_location_001", + "kind": "listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "{\"location\":{\"coordinates\":[1,2]}}", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"] + ] + }, + "expected": { + "error": {"InvalidJson": "location"} + } + }, + { + "id": "operational_listing_invalid_kind_001", + "kind": "listing.parse_event.invalid", + "input": { + "event_kind": 0, + "content": "# Widget", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"] + ] }, "expected": { - "listing_shape": "radroots_listing" + "error": {"InvalidKind": 0} } } ] diff --git a/contracts/operations.toml b/contracts/operations.toml @@ -569,9 +569,10 @@ signing = "native" transport = "native" [operations.listing_parse_event.implementation] -rust_modules = ["crates/trade/src/listing/codec.rs"] +rust_modules = ["crates/event_codec/src/listing/decode.rs"] rust_types = [ "radroots_event::RadrootsEventEnvelope", + "radroots_event::order::RadrootsListingParseError", "radroots_event::listing::RadrootsListing", ] diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml @@ -108,3 +108,13 @@ semver_impacts = [ "change_exported_algorithm_behavior", ] summary = "Replace permissive kind-1 post authoring with strict Update, PhotoUpdate, and Ask states plus signature-gated tolerant product admission." + +[[changes]] +id = "operational-listing-decoder-authority" +classification = "breaking" +semver_impacts = [ + "add_conformance_vector", + "change_exported_function_signature", + "change_exported_algorithm_behavior", +] +summary = "Consolidate operational listing decoding in radroots_event_codec, return the typed listing error contract, and make canonical tags authoritative over JSON content." diff --git a/crates/event_codec/src/listing/decode.rs b/crates/event_codec/src/listing/decode.rs @@ -1,5 +1,3 @@ -#![cfg(feature = "serde_json")] - #[cfg(not(feature = "std"))] use alloc::{ string::{String, ToString}, @@ -21,6 +19,7 @@ use radroots_event::{ RadrootsListingProduct, RadrootsListingPublicLocation, RadrootsListingStatus, }, location::is_public_geohash5, + order::RadrootsListingParseError, plot::RadrootsPlotRef, resource_area::RadrootsResourceAreaRef, tags::{TAG_D, TAG_PUBLISHED_AT}, @@ -54,6 +53,50 @@ const TAG_RADROOTS_AVAILABILITY_START: &str = "radroots:availability_start"; const TAG_STATUS: &str = "status"; const TAG_EXPIRES_AT: &str = "expires_at"; +#[derive(Clone, Debug, PartialEq, Eq)] +enum ListingDecodeError { + MissingTag(&'static str), + InvalidTag(&'static str), + InvalidNumber(&'static str), + InvalidUnit(&'static str), + InvalidCurrency(&'static str), + InvalidJson(&'static str), + #[cfg(feature = "serde_json")] + InvalidDiscount(&'static str), +} + +impl ListingDecodeError { + fn into_listing_parse_error(self) -> RadrootsListingParseError { + match self { + Self::MissingTag(field) => RadrootsListingParseError::MissingTag(field.to_string()), + Self::InvalidTag(field) => RadrootsListingParseError::InvalidTag(field.to_string()), + Self::InvalidNumber(field) => { + RadrootsListingParseError::InvalidNumber(field.to_string()) + } + Self::InvalidUnit(_) => RadrootsListingParseError::InvalidUnit, + Self::InvalidCurrency(_) => RadrootsListingParseError::InvalidCurrency, + Self::InvalidJson(field) => RadrootsListingParseError::InvalidJson(field.to_string()), + #[cfg(feature = "serde_json")] + Self::InvalidDiscount(field) => { + RadrootsListingParseError::InvalidDiscount(field.to_string()) + } + } + } + + fn into_event_parse_error(self) -> EventParseError { + match self { + Self::MissingTag(field) => EventParseError::MissingTag(field), + Self::InvalidTag(field) + | Self::InvalidNumber(field) + | Self::InvalidUnit(field) + | Self::InvalidCurrency(field) => EventParseError::InvalidTag(field), + #[cfg(feature = "serde_json")] + Self::InvalidDiscount(field) => EventParseError::InvalidTag(field), + Self::InvalidJson(_) => EventParseError::InvalidJson("content"), + } + } +} + struct ListingLocationDraft { primary: String, city: Option<String>, @@ -61,38 +104,44 @@ struct ListingLocationDraft { country: Option<String>, } -fn parse_decimal(value: &str, field: &'static str) -> Result<RadrootsCoreDecimal, EventParseError> { +fn parse_decimal( + value: &str, + field: &'static str, +) -> Result<RadrootsCoreDecimal, ListingDecodeError> { value .parse::<RadrootsCoreDecimal>() - .map_err(|_| EventParseError::InvalidTag(field)) + .map_err(|_| ListingDecodeError::InvalidNumber(field)) } -fn reject_private_listing_location_content(content: &str) -> Result<(), EventParseError> { +fn reject_private_listing_location_content(content: &str) -> Result<(), ListingDecodeError> { 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")); + #[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(ListingDecodeError::InvalidJson("location")); + } } } Ok(()) @@ -101,44 +150,45 @@ fn reject_private_listing_location_content(content: &str) -> Result<(), EventPar fn parse_currency( value: &str, field: &'static str, -) -> Result<RadrootsCoreCurrency, EventParseError> { +) -> Result<RadrootsCoreCurrency, ListingDecodeError> { let upper = value.trim().to_ascii_uppercase(); - RadrootsCoreCurrency::from_str_upper(&upper).map_err(|_| EventParseError::InvalidTag(field)) + RadrootsCoreCurrency::from_str_upper(&upper) + .map_err(|_| ListingDecodeError::InvalidCurrency(field)) } -fn parse_unit(value: &str, field: &'static str) -> Result<RadrootsCoreUnit, EventParseError> { +fn parse_unit(value: &str, field: &'static str) -> Result<RadrootsCoreUnit, ListingDecodeError> { value .parse::<RadrootsCoreUnit>() - .map_err(|_| EventParseError::InvalidTag(field)) + .map_err(|_| ListingDecodeError::InvalidUnit(field)) } fn parse_u64_tag_value( value: Option<&String>, field: &'static str, -) -> Result<u64, EventParseError> { +) -> Result<u64, ListingDecodeError> { value - .ok_or(EventParseError::InvalidTag(field))? + .ok_or(ListingDecodeError::InvalidTag(field))? .parse::<u64>() - .map_err(|_| EventParseError::InvalidTag(field)) + .map_err(|_| ListingDecodeError::InvalidNumber(field)) } -fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, EventParseError> { +fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, ListingDecodeError> { let tag = tags .iter() .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_D)) - .ok_or(EventParseError::MissingTag(TAG_D))?; + .ok_or(ListingDecodeError::MissingTag(TAG_D))?; let value = tag .get(1) .map(|value| value.to_string()) - .ok_or(EventParseError::InvalidTag(TAG_D))?; + .ok_or(ListingDecodeError::InvalidTag(TAG_D))?; if value.trim().is_empty() { - return Err(EventParseError::InvalidTag(TAG_D)); + return Err(ListingDecodeError::InvalidTag(TAG_D)); } - validate_d_tag_tag(&value, TAG_D)?; + validate_d_tag_tag(&value, TAG_D).map_err(|_| ListingDecodeError::InvalidTag(TAG_D))?; Ok(value) } -fn parse_farm_ref(tags: &[Vec<String>]) -> Result<RadrootsFarmRef, EventParseError> { +fn parse_farm_ref(tags: &[Vec<String>]) -> Result<RadrootsFarmRef, ListingDecodeError> { for tag in tags .iter() .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A)) @@ -146,50 +196,50 @@ fn parse_farm_ref(tags: &[Vec<String>]) -> Result<RadrootsFarmRef, EventParseErr let value = tag .get(1) .map(|value| value.to_string()) - .ok_or(EventParseError::InvalidTag(TAG_A))?; + .ok_or(ListingDecodeError::InvalidTag(TAG_A))?; let mut parts = value.splitn(3, ':'); let kind = parts .next() .and_then(|raw| raw.parse::<u32>().ok()) - .ok_or(EventParseError::InvalidTag(TAG_A))?; + .ok_or(ListingDecodeError::InvalidTag(TAG_A))?; if kind != KIND_FARM { continue; } let pubkey = parts .next() - .ok_or(EventParseError::InvalidTag(TAG_A))? + .ok_or(ListingDecodeError::InvalidTag(TAG_A))? .to_string(); let d_tag = parts .next() - .ok_or(EventParseError::InvalidTag(TAG_A))? + .ok_or(ListingDecodeError::InvalidTag(TAG_A))? .to_string(); if pubkey.trim().is_empty() || d_tag.trim().is_empty() { - return Err(EventParseError::InvalidTag(TAG_A)); + return Err(ListingDecodeError::InvalidTag(TAG_A)); } - validate_d_tag_tag(&d_tag, TAG_A)?; + validate_d_tag_tag(&d_tag, TAG_A).map_err(|_| ListingDecodeError::InvalidTag(TAG_A))?; return Ok(RadrootsFarmRef { pubkey, d_tag }); } - Err(EventParseError::MissingTag(TAG_A)) + Err(ListingDecodeError::MissingTag(TAG_A)) } -fn parse_farm_pubkey(tags: &[Vec<String>]) -> Result<String, EventParseError> { +fn parse_farm_pubkey(tags: &[Vec<String>]) -> Result<String, ListingDecodeError> { let tag = tags .iter() .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_P)) - .ok_or(EventParseError::MissingTag(TAG_P))?; + .ok_or(ListingDecodeError::MissingTag(TAG_P))?; let value = tag .get(1) .map(|value| value.to_string()) - .ok_or(EventParseError::InvalidTag(TAG_P))?; + .ok_or(ListingDecodeError::InvalidTag(TAG_P))?; if value.trim().is_empty() { - return Err(EventParseError::InvalidTag(TAG_P)); + return Err(ListingDecodeError::InvalidTag(TAG_P)); } Ok(value) } fn parse_resource_area( tags: &[Vec<String>], -) -> Result<Option<RadrootsResourceAreaRef>, EventParseError> { +) -> Result<Option<RadrootsResourceAreaRef>, ListingDecodeError> { let tag = tags .iter() .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_RADROOTS_RESOURCE_AREA)); @@ -199,31 +249,32 @@ fn parse_resource_area( let value = tag .get(1) .map(|value| value.to_string()) - .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?; + .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?; let mut parts = value.splitn(3, ':'); let kind = parts .next() .and_then(|raw| raw.parse::<u32>().ok()) - .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?; + .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?; if kind != KIND_RESOURCE_AREA { - return Err(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA)); + return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA)); } let pubkey = parts .next() - .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))? + .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))? .to_string(); let d_tag = parts .next() - .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))? + .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))? .to_string(); if pubkey.trim().is_empty() || d_tag.trim().is_empty() { - return Err(EventParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA)); + return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA)); } - validate_d_tag_tag(&d_tag, TAG_RADROOTS_RESOURCE_AREA)?; + validate_d_tag_tag(&d_tag, TAG_RADROOTS_RESOURCE_AREA) + .map_err(|_| ListingDecodeError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA))?; Ok(Some(RadrootsResourceAreaRef { pubkey, d_tag })) } -fn parse_plot_ref(tags: &[Vec<String>]) -> Result<Option<RadrootsPlotRef>, EventParseError> { +fn parse_plot_ref(tags: &[Vec<String>]) -> Result<Option<RadrootsPlotRef>, ListingDecodeError> { let tag = tags .iter() .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_RADROOTS_PLOT)); @@ -233,27 +284,28 @@ fn parse_plot_ref(tags: &[Vec<String>]) -> Result<Option<RadrootsPlotRef>, Event let value = tag .get(1) .map(|value| value.to_string()) - .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PLOT))?; + .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?; let mut parts = value.splitn(3, ':'); let kind = parts .next() .and_then(|raw| raw.parse::<u32>().ok()) - .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PLOT))?; + .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?; if kind != KIND_PLOT { - return Err(EventParseError::InvalidTag(TAG_RADROOTS_PLOT)); + return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT)); } let pubkey = parts .next() - .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PLOT))? + .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))? .to_string(); let d_tag = parts .next() - .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PLOT))? + .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))? .to_string(); if pubkey.trim().is_empty() || d_tag.trim().is_empty() { - return Err(EventParseError::InvalidTag(TAG_RADROOTS_PLOT)); + return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT)); } - validate_d_tag_tag(&d_tag, TAG_RADROOTS_PLOT)?; + validate_d_tag_tag(&d_tag, TAG_RADROOTS_PLOT) + .map_err(|_| ListingDecodeError::InvalidTag(TAG_RADROOTS_PLOT))?; Ok(Some(RadrootsPlotRef { pubkey, d_tag })) } @@ -268,13 +320,31 @@ pub fn listing_from_event( got: kind, }); } - listing_from_event_parts(tags, content) + decode_listing_from_event_parts(tags, content) + .map_err(ListingDecodeError::into_event_parse_error) } pub fn listing_from_event_parts( tags: &[Vec<String>], content: &str, -) -> Result<RadrootsListing, EventParseError> { +) -> Result<RadrootsListing, RadrootsListingParseError> { + decode_listing_from_event_parts(tags, content) + .map_err(ListingDecodeError::into_listing_parse_error) +} + +pub fn listing_from_nostr_event( + event: &RadrootsEventEnvelope, +) -> Result<RadrootsListing, RadrootsListingParseError> { + if !is_listing_kind(event.kind_u32()) { + return Err(RadrootsListingParseError::InvalidKind(event.kind_u32())); + } + listing_from_event_parts(&event.tags_as_vec(), event.content()) +} + +fn decode_listing_from_event_parts( + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsListing, ListingDecodeError> { reject_private_listing_location_content(content)?; let d_tag = parse_d_tag(tags)?; let farm_ref = parse_farm_ref(tags)?; @@ -327,7 +397,7 @@ pub fn listing_from_event_parts( let primary = tag .get(1) .and_then(|value| clean_value(value)) - .ok_or(EventParseError::InvalidTag(TAG_LOCATION))?; + .ok_or(ListingDecodeError::InvalidTag(TAG_LOCATION))?; let mut parsed = ListingLocationDraft { primary, city: None, @@ -355,10 +425,10 @@ pub fn listing_from_event_parts( let value = tag .get(1) .and_then(|value| clean_value(value)) - .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN))?; + .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN))?; if let Some(existing) = primary_bin_id.as_ref() { if existing != &value { - return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN)); + return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN)); } } else { primary_bin_id = Some(value); @@ -366,20 +436,20 @@ pub fn listing_from_event_parts( } TAG_RADROOTS_BIN => { if tag.len() < 4 || tag.len() > 7 { - return Err(EventParseError::InvalidTag(TAG_RADROOTS_BIN)); + return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_BIN)); } let bin_id = tag .get(1) .and_then(|value| clean_value(value)) - .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_BIN))?; + .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_BIN))?; let amount = parse_decimal(&tag[2], TAG_RADROOTS_BIN)?; let unit = parse_unit(&tag[3], TAG_RADROOTS_BIN)?; if unit != unit.canonical_unit() { - return Err(EventParseError::InvalidTag(TAG_RADROOTS_BIN)); + return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_BIN)); } let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order); if bin.quantity.is_some() { - return Err(EventParseError::InvalidTag(TAG_RADROOTS_BIN)); + return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_BIN)); } bin.quantity = Some(RadrootsCoreQuantity::new(amount, unit)); @@ -394,18 +464,20 @@ pub fn listing_from_event_parts( bin.display_label = clean_value(label); } } - [_, _, _, _, _] => return Err(EventParseError::InvalidTag(TAG_RADROOTS_BIN)), + [_, _, _, _, _] => { + return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_BIN)); + } _ => {} } } TAG_RADROOTS_PRICE => { if tag.len() < 6 || tag.len() > 8 { - return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRICE)); + return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE)); } let bin_id = tag .get(1) .and_then(|value| clean_value(value)) - .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_PRICE))?; + .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE))?; let amount = parse_decimal(&tag[2], TAG_RADROOTS_PRICE)?; let currency = parse_currency(&tag[3], TAG_RADROOTS_PRICE)?; let per_amount = parse_decimal(&tag[4], TAG_RADROOTS_PRICE)?; @@ -415,17 +487,17 @@ pub fn listing_from_event_parts( RadrootsCoreQuantity::new(per_amount, per_unit), ); if !price_per_canonical_unit.is_price_per_canonical_unit() { - return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRICE)); + return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE)); } let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order); if bin.price_per_canonical_unit.is_some() { - return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRICE)); + return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE)); } bin.price_per_canonical_unit = Some(price_per_canonical_unit); match tag.as_slice() { [_, _, _, _, _, _, _] => { - return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRICE)); + return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE)); } [_, _, _, _, _, _, display_price_raw, display_unit_raw] => { let display_price = parse_decimal(display_price_raw, TAG_RADROOTS_PRICE)?; @@ -439,32 +511,32 @@ pub fn listing_from_event_parts( TAG_RADROOTS_DISCOUNT => { let payload = tag .get(1) - .ok_or(EventParseError::InvalidTag(TAG_RADROOTS_DISCOUNT))?; + .ok_or(ListingDecodeError::InvalidTag(TAG_RADROOTS_DISCOUNT))?; discounts.push(parse_discount(payload)?); } TAG_GEOHASH => { let value = tag .get(1) .and_then(|value| clean_value(value)) - .ok_or(EventParseError::InvalidTag(TAG_GEOHASH))?; + .ok_or(ListingDecodeError::InvalidTag(TAG_GEOHASH))?; if !is_public_geohash5(&value) { - return Err(EventParseError::InvalidTag(TAG_GEOHASH)); + return Err(ListingDecodeError::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)); + return Err(ListingDecodeError::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_DD => return Err(ListingDecodeError::InvalidTag(TAG_DD)), + TAG_DD_LAT => return Err(ListingDecodeError::InvalidTag(TAG_DD_LAT)), + TAG_DD_LON => return Err(ListingDecodeError::InvalidTag(TAG_DD_LON)), + TAG_LABEL => return Err(ListingDecodeError::InvalidTag(TAG_LABEL)), + TAG_LABEL_NS => return Err(ListingDecodeError::InvalidTag(TAG_LABEL_NS)), TAG_INVENTORY => { let value = tag .get(1) - .ok_or(EventParseError::InvalidTag(TAG_INVENTORY))?; + .ok_or(ListingDecodeError::InvalidTag(TAG_INVENTORY))?; inventory_available = Some(parse_decimal(value, TAG_INVENTORY)?); } TAG_RADROOTS_AVAILABILITY_START => { @@ -504,7 +576,9 @@ pub fn listing_from_event_parts( }); } TAG_IMAGE => { - let url = tag.get(1).ok_or(EventParseError::InvalidTag(TAG_IMAGE))?; + let url = tag + .get(1) + .ok_or(ListingDecodeError::InvalidTag(TAG_IMAGE))?; if url.trim().is_empty() { continue; } @@ -527,8 +601,8 @@ pub fn listing_from_event_parts( let location = location .map(|location| { - let geohash = geohash.ok_or(EventParseError::InvalidTag(TAG_GEOHASH))?; - Ok::<RadrootsListingPublicLocation, EventParseError>(RadrootsListingPublicLocation { + let geohash = geohash.ok_or(ListingDecodeError::InvalidTag(TAG_GEOHASH))?; + Ok::<RadrootsListingPublicLocation, ListingDecodeError>(RadrootsListingPublicLocation { primary: location.primary, city: location.city, region: location.region, @@ -539,19 +613,19 @@ pub fn listing_from_event_parts( .transpose()?; if farm_pubkey != farm_ref.pubkey { - return Err(EventParseError::InvalidTag(TAG_P)); + return Err(ListingDecodeError::InvalidTag(TAG_P)); } let primary_bin_id = - primary_bin_id.ok_or(EventParseError::MissingTag(TAG_RADROOTS_PRIMARY_BIN))?; + primary_bin_id.ok_or(ListingDecodeError::MissingTag(TAG_RADROOTS_PRIMARY_BIN))?; let bins = build_bins(bin_drafts)?; if !bins.iter().any(|bin| bin.bin_id == primary_bin_id) { - return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN)); + return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN)); } - let d_tag = RadrootsDTag::parse(&d_tag).map_err(|_| EventParseError::InvalidTag(TAG_D))?; + let d_tag = RadrootsDTag::parse(&d_tag).map_err(|_| ListingDecodeError::InvalidTag(TAG_D))?; let primary_bin_id = RadrootsInventoryBinId::parse(&primary_bin_id) - .map_err(|_| EventParseError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN))?; + .map_err(|_| ListingDecodeError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN))?; Ok(RadrootsListing { d_tag, @@ -695,8 +769,17 @@ fn parse_image_size(value: &str) -> Option<RadrootsListingImageSize> { Some(RadrootsListingImageSize { w, h }) } -fn parse_discount(payload: &str) -> Result<RadrootsCoreDiscount, EventParseError> { - serde_json::from_str(payload).map_err(|_| EventParseError::InvalidTag(TAG_RADROOTS_DISCOUNT)) +fn parse_discount(payload: &str) -> Result<RadrootsCoreDiscount, ListingDecodeError> { + #[cfg(feature = "serde_json")] + { + serde_json::from_str(payload) + .map_err(|_| ListingDecodeError::InvalidDiscount(TAG_RADROOTS_DISCOUNT)) + } + #[cfg(not(feature = "serde_json"))] + { + let _ = payload; + Err(ListingDecodeError::InvalidJson("discount")) + } } #[derive(Clone, Debug)] @@ -736,22 +819,22 @@ fn upsert_bin<'a>( &mut bins[index] } -fn build_bins(mut drafts: Vec<BinDraft>) -> Result<Vec<RadrootsListingBin>, EventParseError> { +fn build_bins(mut drafts: Vec<BinDraft>) -> Result<Vec<RadrootsListingBin>, ListingDecodeError> { drafts.sort_by_key(|draft| draft.order_index); let mut bins = Vec::with_capacity(drafts.len()); for draft in drafts { let quantity = draft .quantity - .ok_or(EventParseError::MissingTag(TAG_RADROOTS_BIN))?; + .ok_or(ListingDecodeError::MissingTag(TAG_RADROOTS_BIN))?; let price = draft .price_per_canonical_unit - .ok_or(EventParseError::MissingTag(TAG_RADROOTS_PRICE))?; + .ok_or(ListingDecodeError::MissingTag(TAG_RADROOTS_PRICE))?; if quantity.unit != price.quantity.unit { - return Err(EventParseError::InvalidTag(TAG_RADROOTS_PRICE)); + return Err(ListingDecodeError::InvalidTag(TAG_RADROOTS_PRICE)); } bins.push(RadrootsListingBin { bin_id: RadrootsInventoryBinId::parse(&draft.bin_id) - .map_err(|_| EventParseError::InvalidTag(TAG_RADROOTS_BIN))?, + .map_err(|_| ListingDecodeError::InvalidTag(TAG_RADROOTS_BIN))?, quantity, price_per_canonical_unit: price, display_amount: draft.display_amount, diff --git a/crates/event_codec/tests/fixtures/listing_parse_event.v1.json b/crates/event_codec/tests/fixtures/listing_parse_event.v1.json @@ -0,0 +1,215 @@ +{ + "suite": "operational_listing_parse_event", + "contract_version": "1.0.0", + "vectors": [ + { + "id": "operational_listing_tags_override_json_content_001", + "kind": "listing.parse_event.valid", + "input": { + "event_kind": 30402, + "content": "{\"product\":{\"title\":\"Content override\",\"category\":\"Content category\"},\"inventory_available\":\"99\"}", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"], + ["price", "10", "USD"] + ] + }, + "expected": { + "listing": { + "d_tag": "AAAAAAAAAAAAAAAAAAAAAg", + "title": "Widget", + "category": "Tools", + "primary_bin_id": "bin-1", + "quantity_amount": "1", + "quantity_unit": "each", + "price_amount": "10", + "price_currency": "USD", + "inventory_available": null + } + } + }, + { + "id": "operational_listing_missing_d_001", + "kind": "listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "# Widget", + "tags": [ + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"] + ] + }, + "expected": { + "error": {"MissingTag": "d"} + } + }, + { + "id": "operational_listing_invalid_d_001", + "kind": "listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "# Widget", + "tags": [ + ["d", "bad d"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"] + ] + }, + "expected": { + "error": {"InvalidTag": "d"} + } + }, + { + "id": "operational_listing_invalid_number_001", + "kind": "listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "# Widget", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "bad", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"] + ] + }, + "expected": { + "error": {"InvalidNumber": "radroots:bin"} + } + }, + { + "id": "operational_listing_invalid_unit_001", + "kind": "listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "# Widget", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "bad"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"] + ] + }, + "expected": { + "error": "InvalidUnit" + } + }, + { + "id": "operational_listing_invalid_currency_001", + "kind": "listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "# Widget", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "US", "1", "each"] + ] + }, + "expected": { + "error": "InvalidCurrency" + } + }, + { + "id": "operational_listing_invalid_discount_001", + "kind": "listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "# Widget", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"], + ["radroots:discount", "{"] + ] + }, + "expected": { + "error": {"InvalidDiscount": "radroots:discount"} + } + }, + { + "id": "operational_listing_private_json_location_001", + "kind": "listing.parse_event.invalid", + "input": { + "event_kind": 30402, + "content": "{\"location\":{\"coordinates\":[1,2]}}", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"] + ] + }, + "expected": { + "error": {"InvalidJson": "location"} + } + }, + { + "id": "operational_listing_invalid_kind_001", + "kind": "listing.parse_event.invalid", + "input": { + "event_kind": 0, + "content": "# Widget", + "tags": [ + ["d", "AAAAAAAAAAAAAAAAAAAAAg"], + ["p", "farm_pubkey"], + ["a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA"], + ["key", "sku"], + ["title", "Widget"], + ["category", "Tools"], + ["radroots:primary_bin", "bin-1"], + ["radroots:bin", "bin-1", "1", "each"], + ["radroots:price", "bin-1", "10", "USD", "1", "each"] + ] + }, + "expected": { + "error": {"InvalidKind": 0} + } + } + ] +} diff --git a/crates/event_codec/tests/listing.rs b/crates/event_codec/tests/listing.rs @@ -15,14 +15,15 @@ use radroots_event::{ RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingImageSize, RadrootsListingProduct, RadrootsListingPublicLocation, RadrootsListingStatus, }, + order::RadrootsListingParseError, plot::RadrootsPlotRef, resource_area::RadrootsResourceAreaRef, tags::{TAG_D, TAG_PUBLISHED_AT}, }; use radroots_event_codec::error::{EventEncodeError, EventParseError}; use radroots_event_codec::listing::decode::{ - data_from_event, data_from_nostr_event, listing_from_event, parsed_from_event, - parsed_from_nostr_event, + data_from_event, data_from_nostr_event, listing_from_event, listing_from_event_parts, + listing_from_nostr_event, parsed_from_event, parsed_from_nostr_event, }; use radroots_event_codec::listing::encode::{ listing_build_tags, to_json_wire_parts_with_kind, to_wire_parts, to_wire_parts_with_kind, @@ -30,7 +31,9 @@ use radroots_event_codec::listing::encode::{ use radroots_event_codec::listing::tags::{ ListingTagOptions, listing_tags_full, listing_tags_with_options, }; -use std::str::FromStr; +use std::{borrow::Cow, collections::BTreeSet, fs, path::Path, str::FromStr}; + +use serde_json::Value; const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; @@ -38,6 +41,10 @@ const EVENT_SIG: &str = concat!( "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" ); +const PACKAGED_PARSE_VECTORS: &str = include_str!("fixtures/listing_parse_event.v1.json"); +const WORKSPACE_PARSE_VECTOR_PATH: &str = + "../../contracts/conformance/vectors/listing/parse_event.v1.json"; +const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml"; fn listing_d_tag(raw: &str) -> RadrootsDTag { raw.parse().unwrap() @@ -138,6 +145,205 @@ fn sample_listing(d_tag: &str) -> RadrootsListing { } } +#[test] +fn listing_from_event_parts_preserves_listing_error_taxonomy() { + let mut tags = sample_listing_tags(); + remove_tags(&mut tags, TAG_D); + assert_eq!( + listing_from_event_parts(&tags, "").unwrap_err(), + RadrootsListingParseError::MissingTag(TAG_D.to_string()) + ); + + let mut tags = sample_listing_tags(); + replace_first_tag(&mut tags, TAG_D, vec![TAG_D, "bad d"]); + assert_eq!( + listing_from_event_parts(&tags, "").unwrap_err(), + RadrootsListingParseError::InvalidTag(TAG_D.to_string()) + ); + + let mut tags = sample_listing_tags(); + replace_first_tag( + &mut tags, + "radroots:bin", + vec!["radroots:bin", "bin-1", "bad", "each"], + ); + assert_eq!( + listing_from_event_parts(&tags, "").unwrap_err(), + RadrootsListingParseError::InvalidNumber("radroots:bin".to_string()) + ); + + let mut tags = sample_listing_tags(); + replace_first_tag( + &mut tags, + "radroots:bin", + vec!["radroots:bin", "bin-1", "1", "bad"], + ); + assert_eq!( + listing_from_event_parts(&tags, "").unwrap_err(), + RadrootsListingParseError::InvalidUnit + ); + + let mut tags = sample_listing_tags(); + replace_first_tag( + &mut tags, + "radroots:price", + vec!["radroots:price", "bin-1", "10", "not-currency", "1", "each"], + ); + assert_eq!( + listing_from_event_parts(&tags, "").unwrap_err(), + RadrootsListingParseError::InvalidCurrency + ); + + let mut tags = sample_listing_tags(); + tags.push(vec!["radroots:discount".to_string(), "{".to_string()]); + assert_eq!( + listing_from_event_parts(&tags, "").unwrap_err(), + RadrootsListingParseError::InvalidDiscount("radroots:discount".to_string()) + ); + + assert_eq!( + listing_from_event_parts( + &sample_listing_tags(), + r#"{"location":{"coordinates":[1,2]}}"#, + ) + .unwrap_err(), + RadrootsListingParseError::InvalidJson("location".to_string()) + ); +} + +#[test] +fn listing_from_event_parts_does_not_allow_json_content_to_override_tags() { + let tags = sample_listing_tags(); + let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); + listing.product.title = "Content override".to_string(); + listing.product.category = "Content category".to_string(); + listing.inventory_available = Some(RadrootsCoreDecimal::from(99u32)); + listing.bins[0].quantity.amount = RadrootsCoreDecimal::from(42u32); + + let decoded = listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap()) + .expect("decode tag-authoritative listing"); + + assert_eq!(decoded.product.title, "Widget"); + assert_eq!(decoded.product.category, "Tools"); + assert_eq!(decoded.inventory_available, None); + assert_eq!( + decoded.bins[0].quantity.amount, + RadrootsCoreDecimal::from(1u32) + ); +} + +#[test] +fn checked_in_listing_parse_vectors_execute_against_the_typed_decoder() { + let vectors = listing_parse_vectors(); + let suite: Value = serde_json::from_str(&vectors).expect("listing parse vectors must parse"); + assert_eq!(suite["suite"], "operational_listing_parse_event"); + assert_eq!(suite["contract_version"], "1.0.0"); + let vectors = suite["vectors"].as_array().expect("listing parse vectors"); + assert_eq!(vectors.len(), 9, "listing parse vector inventory drifted"); + + let mut ids = BTreeSet::new(); + for vector in vectors { + let id = vector["id"].as_str().expect("vector id"); + assert!(ids.insert(id), "duplicate listing parse vector id {id}"); + execute_listing_parse_vector(vector, id); + } +} + +fn listing_parse_vectors() -> Cow<'static, str> { + let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(WORKSPACE_PARSE_VECTOR_PATH); + match fs::read_to_string(&workspace_path) { + Ok(canonical) => { + assert_eq!( + canonical, + PACKAGED_PARSE_VECTORS, + "packaged listing parse vectors must match {}", + workspace_path.display() + ); + Cow::Owned(canonical) + } + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + && !Path::new(env!("CARGO_MANIFEST_DIR")) + .join(WORKSPACE_CONTRACT_MARKER_PATH) + .is_file() => + { + Cow::Borrowed(PACKAGED_PARSE_VECTORS) + } + Err(error) => panic!("failed to read {}: {error}", workspace_path.display()), + } +} + +fn execute_listing_parse_vector(vector: &Value, id: &str) { + let input = &vector["input"]; + let tags: Vec<Vec<String>> = + serde_json::from_value(input["tags"].clone()).expect("vector tags"); + let content = input["content"].as_str().expect("vector content"); + let event_kind = input["event_kind"].as_u64().expect("vector event kind") as u32; + let event = event_envelope(event_kind, tags, content.to_string()); + + match vector["kind"].as_str().expect("vector kind") { + "listing.parse_event.valid" => { + let listing = listing_from_nostr_event(&event) + .unwrap_or_else(|error| panic!("{id} failed: {error}")); + let expected = &vector["expected"]["listing"]; + assert_eq!(listing.d_tag.as_str(), expected["d_tag"], "{id}"); + assert_eq!(listing.product.title, expected["title"], "{id}"); + assert_eq!(listing.product.category, expected["category"], "{id}"); + assert_eq!( + listing.primary_bin_id.as_str(), + expected["primary_bin_id"], + "{id}" + ); + let primary = listing + .bins + .iter() + .find(|bin| bin.bin_id == listing.primary_bin_id) + .expect("primary listing bin"); + assert_eq!( + primary.quantity.amount.to_string(), + expected["quantity_amount"], + "{id}" + ); + assert_eq!( + primary.quantity.unit.code(), + expected["quantity_unit"], + "{id}" + ); + assert_eq!( + primary.price_per_canonical_unit.amount.amount.to_string(), + expected["price_amount"], + "{id}" + ); + assert_eq!( + primary.price_per_canonical_unit.amount.currency.as_str(), + expected["price_currency"], + "{id}" + ); + assert_eq!( + serde_json::to_value( + listing + .inventory_available + .as_ref() + .map(ToString::to_string) + ) + .expect("inventory projection"), + expected["inventory_available"], + "{id}" + ); + } + "listing.parse_event.invalid" => { + let error = listing_from_nostr_event(&event) + .expect_err("invalid listing parse vector must fail"); + assert_eq!( + serde_json::to_value(error).expect("listing parse error"), + vector["expected"]["error"], + "{id}" + ); + } + kind => panic!("{id} uses unsupported listing parse vector kind {kind}"), + } +} + fn sample_listing_full(d_tag: &str) -> RadrootsListing { let qty_amount = RadrootsCoreDecimal::from_str("1000").unwrap(); let price_amount = RadrootsCoreDecimal::from_str("0.01").unwrap(); diff --git a/crates/trade/src/listing/codec.rs b/crates/trade/src/listing/codec.rs @@ -1,2668 +0,0 @@ -#![forbid(unsafe_code)] - -#[cfg(not(feature = "std"))] -use alloc::{string::String, vec::Vec}; - -use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreMoney, - RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, -}; -use radroots_event::farm::RadrootsFarmRef; -use radroots_event::ids::{RadrootsDTag, RadrootsInventoryBinId}; -use radroots_event::kinds::{KIND_FARM, KIND_PLOT, KIND_RESOURCE_AREA}; -use radroots_event::listing::{ - RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, - RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingImageSize, - RadrootsListingProduct, RadrootsListingPublicLocation, RadrootsListingStatus, -}; -use radroots_event::location::is_public_geohash5; -pub(crate) use radroots_event::order::RadrootsListingParseError as ListingParseError; -use radroots_event::plot::RadrootsPlotRef; -use radroots_event::resource_area::RadrootsResourceAreaRef; -use radroots_event::tags::{TAG_D, TAG_PUBLISHED_AT}; -use radroots_event_codec::d_tag::is_d_tag_base64url; - -const TAG_PRICE: &str = "price"; -const TAG_RADROOTS_BIN: &str = "radroots:bin"; -const TAG_RADROOTS_PRICE: &str = "radroots:price"; -const TAG_RADROOTS_DISCOUNT: &str = "radroots:discount"; -const TAG_RADROOTS_PRIMARY_BIN: &str = "radroots:primary_bin"; -const TAG_RADROOTS_RESOURCE_AREA: &str = "radroots:resource_area"; -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"; -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())) -} - -fn parse_currency(s: &str) -> Result<RadrootsCoreCurrency, ListingParseError> { - let upper = s.trim().to_ascii_uppercase(); - RadrootsCoreCurrency::from_str_upper(&upper).map_err(|_| ListingParseError::InvalidCurrency) -} - -fn parse_unit(s: &str) -> Result<RadrootsCoreUnit, ListingParseError> { - s.parse::<RadrootsCoreUnit>() - .map_err(|_| ListingParseError::InvalidUnit) -} - -fn parse_u64_tag_value(value: Option<&String>, field: &str) -> Result<u64, ListingParseError> { - value - .ok_or_else(|| ListingParseError::InvalidTag(field.to_string()))? - .parse::<u64>() - .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() - .find(|t| t.first().map(|s| s.as_str()) == Some(TAG_D)) - .ok_or_else(|| ListingParseError::MissingTag(TAG_D.to_string()))?; - let value = tag - .get(1) - .map(|s| s.to_string()) - .ok_or_else(|| ListingParseError::InvalidTag(TAG_D.to_string()))?; - if value.trim().is_empty() { - return Err(ListingParseError::InvalidTag(TAG_D.to_string())); - } - if !is_d_tag_base64url(&value) { - return Err(ListingParseError::InvalidTag(TAG_D.to_string())); - } - Ok(value) -} - -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)?; - let resource_area = parse_resource_area(tags)?; - let plot = parse_plot_ref(tags)?; - - if !content.trim().is_empty() { - #[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())); - } - if listing.farm.pubkey.trim().is_empty() || listing.farm.d_tag.trim().is_empty() { - listing.farm = farm_ref; - } else if listing.farm.pubkey != farm_ref.pubkey - || listing.farm.d_tag != farm_ref.d_tag - { - return Err(ListingParseError::InvalidTag(TAG_A.to_string())); - } - if listing.farm.pubkey != farm_pubkey { - return Err(ListingParseError::InvalidTag(TAG_P.to_string())); - } - if let Some(tag_area) = resource_area { - match listing.resource_area.as_ref() { - None => listing.resource_area = Some(tag_area), - Some(area) => { - if area.pubkey != tag_area.pubkey || area.d_tag != tag_area.d_tag { - return Err(ListingParseError::InvalidTag( - TAG_RADROOTS_RESOURCE_AREA.to_string(), - )); - } - } - } - } - if let Some(tag_plot) = plot { - match listing.plot.as_ref() { - None => listing.plot = Some(tag_plot), - Some(existing) => { - if existing.pubkey != tag_plot.pubkey - || existing.d_tag != tag_plot.d_tag - { - return Err(ListingParseError::InvalidTag( - TAG_RADROOTS_PLOT.to_string(), - )); - } - } - } - } - return Ok(listing); - } - } - } - - listing_from_tags(tags, d_tag, farm_ref, farm_pubkey, resource_area, plot) -} - -fn listing_from_tags( - tags: &[Vec<String>], - d_tag: String, - farm_ref: RadrootsFarmRef, - farm_pubkey: String, - resource_area: Option<RadrootsResourceAreaRef>, - plot: Option<RadrootsPlotRef>, -) -> Result<RadrootsListing, ListingParseError> { - if !is_d_tag_base64url(&d_tag) { - return Err(ListingParseError::InvalidTag(TAG_D.to_string())); - } - let d_tag = RadrootsDTag::parse(&d_tag).expect("base64url d tag parses"); - let mut product = RadrootsListingProduct { - key: String::new(), - title: String::new(), - category: String::new(), - summary: None, - process: None, - lot: None, - location: None, - profile: None, - year: None, - }; - - let mut primary_bin_id: Option<String> = None; - let mut bin_drafts: Vec<BinDraft> = Vec::new(); - let mut bin_order = 0usize; - let mut discounts: Vec<RadrootsCoreDiscount> = Vec::new(); - 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; - let mut availability_end: Option<u64> = None; - let mut delivery_method: Option<RadrootsListingDeliveryMethod> = None; - let mut images: Vec<RadrootsListingImage> = Vec::new(); - let mut geohash: Option<String> = None; - let mut published_at: Option<u64> = None; - - for tag in tags { - if tag.is_empty() { - continue; - } - let key = tag[0].as_str(); - match key { - "key" => set_if_empty(&mut product.key, tag.get(1)), - "title" => set_if_empty(&mut product.title, tag.get(1)), - "category" => set_if_empty(&mut product.category, tag.get(1)), - "summary" => set_optional(&mut product.summary, tag.get(1)), - TAG_PUBLISHED_AT => { - published_at = Some(parse_u64_tag_value(tag.get(1), TAG_PUBLISHED_AT)?); - } - "process" => set_optional(&mut product.process, tag.get(1)), - "lot" => set_optional(&mut product.lot, tag.get(1)), - "location" => { - let parse_structured_location = !matches!(tag.len(), 0..=2); - if parse_structured_location { - let primary = &tag[1]; - if primary.trim().is_empty() { - return Err(ListingParseError::InvalidTag(TAG_LOCATION.to_string())); - } - let mut loc = ListingLocationDraft { - primary: primary.to_string(), - city: None, - region: None, - country: None, - }; - if let Some(city) = tag.get(2).and_then(|v| clean_value(v)) { - loc.city = Some(city); - } - if let Some(region) = tag.get(3).and_then(|v| clean_value(v)) { - loc.region = Some(region); - } - if let Some(country) = tag.get(4).and_then(|v| clean_value(v)) { - loc.country = Some(country); - } - location = Some(loc); - } else { - set_optional(&mut product.location, tag.get(1)); - } - } - "profile" => set_optional(&mut product.profile, tag.get(1)), - "year" => set_optional(&mut product.year, tag.get(1)), - TAG_PRICE => { - let _ = tag; - } - TAG_RADROOTS_PRIMARY_BIN => { - let value = tag.get(1).and_then(|v| clean_value(v)).ok_or_else(|| { - ListingParseError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN.to_string()) - })?; - if let Some(existing) = primary_bin_id.as_ref() { - if existing != &value { - return Err(ListingParseError::InvalidTag( - TAG_RADROOTS_PRIMARY_BIN.to_string(), - )); - } - } else { - primary_bin_id = Some(value); - } - } - TAG_RADROOTS_BIN => { - if tag.len() < 4 { - return Err(ListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string())); - } - if tag.len() > 7 { - return Err(ListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string())); - } - let bin_id = clean_value(&tag[1]) - .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()))?; - let amount = parse_decimal(&tag[2], TAG_RADROOTS_BIN)?; - let unit = parse_unit(&tag[3])?; - if unit != unit.canonical_unit() { - return Err(ListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string())); - } - let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order); - if bin.quantity.is_some() { - return Err(ListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string())); - } - bin.quantity = Some(RadrootsCoreQuantity::new(amount, unit)); - - match tag.as_slice() { - [_, _, _, _, display_amount_raw, display_unit_raw] - | [_, _, _, _, display_amount_raw, display_unit_raw, _] => { - let display_amount = parse_decimal(display_amount_raw, TAG_RADROOTS_BIN)?; - let display_unit = parse_unit(display_unit_raw)?; - bin.display_amount = Some(display_amount); - bin.display_unit = Some(display_unit); - if let [_, _, _, _, _, _, label] = tag.as_slice() { - bin.display_label = clean_value(label); - } - } - [_, _, _, _, _] => { - return Err(ListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string())); - } - _ => {} - } - } - TAG_RADROOTS_PRICE => { - if tag.len() < 6 { - return Err(ListingParseError::InvalidTag( - TAG_RADROOTS_PRICE.to_string(), - )); - } - if tag.len() > 8 { - return Err(ListingParseError::InvalidTag( - TAG_RADROOTS_PRICE.to_string(), - )); - } - let bin_id = clean_value(&tag[1]) - .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_PRICE.to_string()))?; - let amount = parse_decimal(&tag[2], TAG_RADROOTS_PRICE)?; - let currency = parse_currency(&tag[3])?; - let per_amount = parse_decimal(&tag[4], TAG_RADROOTS_PRICE)?; - let per_unit = parse_unit(&tag[5])?; - let price_per_canonical_unit = RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(amount, currency), - RadrootsCoreQuantity::new(per_amount, per_unit), - ); - if !price_per_canonical_unit.is_price_per_canonical_unit() { - return Err(ListingParseError::InvalidTag( - TAG_RADROOTS_PRICE.to_string(), - )); - } - let bin = upsert_bin(&mut bin_drafts, &bin_id, &mut bin_order); - if bin.price_per_canonical_unit.is_some() { - return Err(ListingParseError::InvalidTag( - TAG_RADROOTS_PRICE.to_string(), - )); - } - bin.price_per_canonical_unit = Some(price_per_canonical_unit); - - match tag.as_slice() { - [_, _, _, _, _, _, _] => { - return Err(ListingParseError::InvalidTag( - TAG_RADROOTS_PRICE.to_string(), - )); - } - [_, _, _, _, _, _, display_price_raw, display_unit_raw] => { - let display_price = parse_decimal(display_price_raw, TAG_RADROOTS_PRICE)?; - let display_unit = parse_unit(display_unit_raw)?; - bin.display_price = Some(RadrootsCoreMoney::new(display_price, currency)); - bin.display_price_unit = Some(display_unit); - } - _ => {} - } - } - TAG_RADROOTS_DISCOUNT => { - let payload = tag.get(1).ok_or_else(|| { - ListingParseError::InvalidTag(TAG_RADROOTS_DISCOUNT.to_string()) - })?; - let discount = parse_discount(payload)?; - discounts.push(discount); - } - TAG_GEOHASH => { - 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 - .get(1) - .ok_or_else(|| ListingParseError::InvalidTag(TAG_INVENTORY.to_string()))?; - inventory_available = Some(parse_decimal(value, TAG_INVENTORY)?); - } - TAG_RADROOTS_AVAILABILITY_START => { - let value = tag.get(1).ok_or_else(|| { - ListingParseError::InvalidTag(TAG_RADROOTS_AVAILABILITY_START.to_string()) - })?; - availability_start = Some(value.parse::<u64>().map_err(|_| { - ListingParseError::InvalidNumber(TAG_RADROOTS_AVAILABILITY_START.to_string()) - })?); - } - TAG_EXPIRES_AT => { - let value = tag - .get(1) - .ok_or_else(|| ListingParseError::InvalidTag(TAG_EXPIRES_AT.to_string()))?; - availability_end = - Some(value.parse::<u64>().map_err(|_| { - ListingParseError::InvalidNumber(TAG_EXPIRES_AT.to_string()) - })?); - } - TAG_STATUS => { - let status = tag.get(1).and_then(|v| clean_value(v)).unwrap_or_default(); - availability_status = Some(parse_status(&status)); - } - TAG_DELIVERY => { - let method = tag.get(1).and_then(|v| clean_value(v)).unwrap_or_default(); - delivery_method = Some(match method.as_str() { - "pickup" => RadrootsListingDeliveryMethod::Pickup, - "local_delivery" => RadrootsListingDeliveryMethod::LocalDelivery, - "shipping" => RadrootsListingDeliveryMethod::Shipping, - "other" => { - let detail = tag.get(2).and_then(|v| clean_value(v)).unwrap_or_default(); - RadrootsListingDeliveryMethod::Other { method: detail } - } - other => RadrootsListingDeliveryMethod::Other { - method: other.to_string(), - }, - }); - } - TAG_IMAGE => { - let url = tag - .get(1) - .ok_or_else(|| ListingParseError::InvalidTag(TAG_IMAGE.to_string()))?; - if url.trim().is_empty() { - continue; - } - let size = tag.get(2).and_then(|s| parse_image_size(s)); - images.push(RadrootsListingImage { - url: url.to_string(), - size, - }); - } - _ => {} - } - } - - let availability = match availability_status { - Some(status) => Some(RadrootsListingAvailability::Status { status }), - None => match (availability_start, availability_end) { - (None, None) => None, - (start, end) => Some(RadrootsListingAvailability::Window { start, end }), - }, - }; - - 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())); - } - - let primary_bin_id = primary_bin_id - .and_then(|v| clean_value(&v)) - .ok_or_else(|| ListingParseError::MissingTag(TAG_RADROOTS_PRIMARY_BIN.to_string()))?; - let primary_bin_id = RadrootsInventoryBinId::parse(&primary_bin_id) - .map_err(|_| ListingParseError::InvalidTag(TAG_RADROOTS_PRIMARY_BIN.to_string()))?; - let bins = build_bins(bin_drafts)?; - if !bins.iter().any(|bin| bin.bin_id == primary_bin_id) { - return Err(ListingParseError::InvalidTag( - TAG_RADROOTS_PRIMARY_BIN.to_string(), - )); - } - - Ok(RadrootsListing { - d_tag, - published_at, - farm: farm_ref, - product, - primary_bin_id, - bins, - resource_area, - plot, - discounts: if discounts.is_empty() { - None - } else { - Some(discounts) - }, - inventory_available, - availability, - delivery_method, - location, - images: if images.is_empty() { - None - } else { - Some(images) - }, - }) -} - -fn parse_farm_ref(tags: &[Vec<String>]) -> Result<RadrootsFarmRef, ListingParseError> { - for tag in tags - .iter() - .filter(|t| t.first().map(|s| s.as_str()) == Some(TAG_A)) - { - let value = tag - .get(1) - .map(|s| s.to_string()) - .ok_or_else(|| ListingParseError::InvalidTag(TAG_A.to_string()))?; - let mut parts = value.splitn(3, ':'); - let kind = parts - .next() - .and_then(|v| v.parse::<u32>().ok()) - .ok_or_else(|| ListingParseError::InvalidTag(TAG_A.to_string()))?; - if kind != KIND_FARM { - continue; - } - let pubkey = parts - .next() - .ok_or_else(|| ListingParseError::InvalidTag(TAG_A.to_string()))? - .to_string(); - let d_tag = parts - .next() - .ok_or_else(|| ListingParseError::InvalidTag(TAG_A.to_string()))? - .to_string(); - if pubkey.trim().is_empty() || d_tag.trim().is_empty() { - return Err(ListingParseError::InvalidTag(TAG_A.to_string())); - } - if !is_d_tag_base64url(&d_tag) { - return Err(ListingParseError::InvalidTag(TAG_A.to_string())); - } - return Ok(RadrootsFarmRef { pubkey, d_tag }); - } - Err(ListingParseError::MissingTag(TAG_A.to_string())) -} - -fn parse_farm_pubkey(tags: &[Vec<String>]) -> Result<String, ListingParseError> { - let tag = tags - .iter() - .find(|t| t.first().map(|s| s.as_str()) == Some(TAG_P)) - .ok_or_else(|| ListingParseError::MissingTag(TAG_P.to_string()))?; - let value = tag - .get(1) - .map(|s| s.to_string()) - .ok_or_else(|| ListingParseError::InvalidTag(TAG_P.to_string()))?; - if value.trim().is_empty() { - return Err(ListingParseError::InvalidTag(TAG_P.to_string())); - } - Ok(value) -} - -fn parse_resource_area( - tags: &[Vec<String>], -) -> Result<Option<RadrootsResourceAreaRef>, ListingParseError> { - let tag = tags - .iter() - .find(|t| t.first().map(|s| s.as_str()) == Some(TAG_RADROOTS_RESOURCE_AREA)); - let Some(tag) = tag else { - return Ok(None); - }; - let value = tag - .get(1) - .map(|s| s.to_string()) - .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA.to_string()))?; - let mut parts = value.splitn(3, ':'); - let kind = parts - .next() - .and_then(|v| v.parse::<u32>().ok()) - .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA.to_string()))?; - if kind != KIND_RESOURCE_AREA { - return Err(ListingParseError::InvalidTag( - TAG_RADROOTS_RESOURCE_AREA.to_string(), - )); - } - let pubkey = parts - .next() - .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA.to_string()))? - .to_string(); - let d_tag = parts - .next() - .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_RESOURCE_AREA.to_string()))? - .to_string(); - if pubkey.trim().is_empty() || d_tag.trim().is_empty() { - return Err(ListingParseError::InvalidTag( - TAG_RADROOTS_RESOURCE_AREA.to_string(), - )); - } - if !is_d_tag_base64url(&d_tag) { - return Err(ListingParseError::InvalidTag( - TAG_RADROOTS_RESOURCE_AREA.to_string(), - )); - } - Ok(Some(RadrootsResourceAreaRef { pubkey, d_tag })) -} - -fn parse_plot_ref(tags: &[Vec<String>]) -> Result<Option<RadrootsPlotRef>, ListingParseError> { - let tag = tags - .iter() - .find(|t| t.first().map(|s| s.as_str()) == Some(TAG_RADROOTS_PLOT)); - let Some(tag) = tag else { - return Ok(None); - }; - let value = tag - .get(1) - .map(|s| s.to_string()) - .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string()))?; - let mut parts = value.splitn(3, ':'); - let kind = parts - .next() - .and_then(|v| v.parse::<u32>().ok()) - .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string()))?; - if kind != KIND_PLOT { - return Err(ListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string())); - } - let pubkey = parts - .next() - .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string()))? - .to_string(); - let d_tag = parts - .next() - .ok_or_else(|| ListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string()))? - .to_string(); - if pubkey.trim().is_empty() || d_tag.trim().is_empty() { - return Err(ListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string())); - } - if !is_d_tag_base64url(&d_tag) { - return Err(ListingParseError::InvalidTag(TAG_RADROOTS_PLOT.to_string())); - } - Ok(Some(RadrootsPlotRef { pubkey, d_tag })) -} - -#[cfg(test)] -mod tests { - use super::*; - use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountScope, - RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreMoney, - RadrootsCorePercent, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, - }; - use radroots_event::farm::RadrootsFarmRef; - use radroots_event::listing::RadrootsListing; - use radroots_event_codec::error::EventEncodeError; - use radroots_event_codec::listing::tags::listing_tags_full; - - fn listing_tags_build( - listing: &RadrootsListing, - ) -> Result<Vec<Vec<String>>, ListingParseError> { - listing_tags_full(listing).map_err(map_listing_tags_error) - } - - fn map_listing_tags_error(err: EventEncodeError) -> ListingParseError { - match err { - EventEncodeError::EmptyRequiredField(field) => { - ListingParseError::MissingTag(field.to_string()) - } - EventEncodeError::InvalidField(field) => { - ListingParseError::InvalidTag(field.to_string()) - } - EventEncodeError::Json => ListingParseError::InvalidJson("discount".to_string()), - EventEncodeError::InvalidKind(kind) => ListingParseError::InvalidKind(kind), - } - } - - fn farm_ref() -> RadrootsFarmRef { - RadrootsFarmRef { - pubkey: "seller".to_string(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), - } - } - - fn listing_d_tag() -> String { - "AAAAAAAAAAAAAAAAAAAAAg".to_string() - } - - fn d_tag(raw: &str) -> RadrootsDTag { - RadrootsDTag::parse(raw).expect("d tag") - } - - fn base_event_tags() -> Vec<Vec<String>> { - vec![ - vec![TAG_D.into(), listing_d_tag()], - vec![TAG_P.into(), "seller".into()], - vec![ - TAG_A.into(), - format!("{KIND_FARM}:seller:{}", farm_ref().d_tag), - ], - ] - } - - fn base_trade_tags() -> Vec<Vec<String>> { - vec![ - vec!["key".into(), "coffee".into()], - vec!["title".into(), "Coffee".into()], - vec!["category".into(), "coffee".into()], - vec!["summary".into(), "Single origin".into()], - vec![TAG_RADROOTS_PRIMARY_BIN.into(), "bin-1".into()], - vec![ - TAG_RADROOTS_BIN.into(), - "bin-1".into(), - "1000".into(), - "g".into(), - "1".into(), - "kg".into(), - "bag".into(), - ], - vec![ - TAG_RADROOTS_PRICE.into(), - "bin-1".into(), - "0.01".into(), - "USD".into(), - "1".into(), - "g".into(), - "10".into(), - "kg".into(), - ], - ] - } - - fn parse_base_listing_from_tags() -> RadrootsListing { - listing_from_tags( - &base_trade_tags(), - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .expect("listing") - } - - fn parse_error_tag(error: ListingParseError) -> String { - match error { - ListingParseError::InvalidKind(_) => "kind".to_string(), - ListingParseError::MissingTag(tag) => tag, - ListingParseError::InvalidTag(tag) => tag, - ListingParseError::InvalidNumber(field) => field, - ListingParseError::InvalidUnit => "unit".to_string(), - ListingParseError::InvalidCurrency => "currency".to_string(), - ListingParseError::InvalidJson(field) => field, - ListingParseError::InvalidDiscount(kind) => kind, - } - } - - #[test] - fn production_source_has_no_dead_code_allowance() { - let forbidden = ["#[allow(", "dead_code", ")]"].concat(); - assert!(!include_str!("codec.rs").contains(forbidden.as_str())); - } - - #[test] - fn listing_parses_radroots_bins() { - let tags = base_trade_tags(); - - let listing = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .expect("listing"); - - assert_eq!(listing.primary_bin_id, "bin-1"); - assert_eq!(listing.bins.len(), 1); - assert_eq!(listing.bins[0].quantity.unit, RadrootsCoreUnit::MassG); - assert_eq!( - listing.bins[0].price_per_canonical_unit.quantity.unit, - RadrootsCoreUnit::MassG - ); - assert_eq!( - listing.bins[0].display_unit.expect("display unit").code(), - "kg" - ); - } - - #[test] - fn listing_from_tags_roundtrips_published_at_tag() { - let mut tags = base_trade_tags(); - tags.push(vec![TAG_PUBLISHED_AT.into(), "1781895600".into()]); - - let listing = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .expect("listing"); - - assert_eq!(listing.published_at, Some(1_781_895_600)); - - let published_at = tags - .iter_mut() - .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_PUBLISHED_AT)) - .expect("published_at tag"); - published_at[1] = "bad".to_string(); - - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - - assert_eq!(parse_error_tag(err), TAG_PUBLISHED_AT.to_string()); - - let mut missing_value = base_trade_tags(); - missing_value.push(vec![TAG_PUBLISHED_AT.into()]); - let err = listing_from_tags( - &missing_value, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_PUBLISHED_AT.to_string()); - } - - #[test] - fn listing_from_tags_rejects_invalid_d_tag() { - let tags = base_trade_tags(); - - let err = listing_from_tags( - &tags, - "invalid:tag".to_string(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - - assert_eq!(parse_error_tag(err), TAG_D.to_string()); - } - - #[test] - fn parse_scalar_helpers_cover_success_and_error_paths() { - assert_eq!( - parse_decimal("1.5", "f").unwrap(), - "1.5".parse::<RadrootsCoreDecimal>().unwrap() - ); - assert_eq!( - parse_error_tag(parse_decimal("x", "f").unwrap_err()), - "f".to_string() - ); - assert_eq!(parse_currency(" usd ").unwrap(), RadrootsCoreCurrency::USD); - assert_eq!( - parse_error_tag(parse_currency("12").unwrap_err()), - "currency".to_string() - ); - assert_eq!(parse_unit("g").unwrap(), RadrootsCoreUnit::MassG); - assert_eq!( - parse_error_tag(parse_unit("not-unit").unwrap_err()), - "unit".to_string() - ); - } - - #[test] - fn parse_error_display_covers_all_variants() { - let errors = [ - ListingParseError::MissingTag("d".into()), - ListingParseError::InvalidTag("a".into()), - ListingParseError::InvalidNumber("n".into()), - ListingParseError::InvalidUnit, - ListingParseError::InvalidCurrency, - ListingParseError::InvalidJson("j".into()), - ListingParseError::InvalidDiscount("x".into()), - ]; - for error in errors { - assert!(!error.to_string().trim().is_empty()); - } - } - - #[test] - fn parse_d_tag_covers_all_paths() { - assert_eq!( - parse_error_tag(parse_d_tag(&[]).unwrap_err()), - TAG_D.to_string() - ); - assert_eq!( - parse_error_tag(parse_d_tag(&[vec![TAG_D.into()]]).unwrap_err()), - TAG_D.to_string() - ); - assert_eq!( - parse_error_tag(parse_d_tag(&[vec![TAG_D.into(), " ".into()]]).unwrap_err()), - TAG_D.to_string() - ); - assert_eq!( - parse_error_tag(parse_d_tag(&[vec![TAG_D.into(), "invalid".into()]]).unwrap_err()), - TAG_D.to_string() - ); - assert_eq!( - parse_d_tag(&[vec![TAG_D.into(), listing_d_tag()]]).unwrap(), - listing_d_tag() - ); - } - - #[test] - fn listing_from_event_parts_rejects_private_location_json_fields() { - let err = - listing_from_event_parts(&base_event_tags(), r#"{"location":{"lat":1}}"#).unwrap_err(); - - assert_eq!(parse_error_tag(err), "location".to_string()); - } - - #[test] - fn listing_from_tags_rejects_private_coordinate_and_geohash_edges() { - let mut invalid_geohash = base_trade_tags(); - invalid_geohash.push(vec![ - TAG_LOCATION.into(), - "Farm".into(), - "Town".into(), - "Region".into(), - "US".into(), - ]); - invalid_geohash.push(vec![TAG_GEOHASH.into(), "9q8yyz".into()]); - let err = listing_from_tags( - &invalid_geohash, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_GEOHASH.to_string()); - - let mut missing_geohash_value = base_trade_tags(); - missing_geohash_value.push(vec![TAG_GEOHASH.into()]); - let err = listing_from_tags( - &missing_geohash_value, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_GEOHASH.to_string()); - - let mut duplicate_geohash = base_trade_tags(); - duplicate_geohash.push(vec![ - TAG_LOCATION.into(), - "Farm".into(), - "Town".into(), - "Region".into(), - "US".into(), - ]); - duplicate_geohash.push(vec![TAG_GEOHASH.into(), "9q8yy".into()]); - duplicate_geohash.push(vec![TAG_GEOHASH.into(), "u6sep".into()]); - let err = listing_from_tags( - &duplicate_geohash, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_GEOHASH.to_string()); - - let mut private_coordinates = base_trade_tags(); - private_coordinates.push(vec![TAG_DD.into(), "1.0".into()]); - let err = listing_from_tags( - &private_coordinates, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_DD.to_string()); - - let mut blank_optional_locality = base_trade_tags(); - blank_optional_locality.push(vec![ - TAG_LOCATION.into(), - "Farm".into(), - " ".into(), - " ".into(), - "US".into(), - ]); - blank_optional_locality.push(vec![TAG_GEOHASH.into(), "9q8yy".into()]); - let listing = listing_from_tags( - &blank_optional_locality, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .expect("listing with blank optional locality"); - let location = listing.location.expect("location"); - assert_eq!(location.city, None); - assert_eq!(location.region, None); - assert_eq!(location.country.as_deref(), Some("US")); - } - - #[test] - fn listing_from_event_parts_uses_json_content_and_backfills_tags() { - let mut listing = parse_base_listing_from_tags(); - listing.farm.pubkey = String::new(); - listing.farm.d_tag = String::new(); - listing.resource_area = None; - listing.plot = None; - - let mut tags = base_event_tags(); - tags.push(vec![ - TAG_RADROOTS_RESOURCE_AREA.into(), - format!("{KIND_RESOURCE_AREA}:seller:AAAAAAAAAAAAAAAAAAAAAQ"), - ]); - tags.push(vec![ - TAG_RADROOTS_PLOT.into(), - format!("{KIND_PLOT}:seller:AAAAAAAAAAAAAAAAAAAAAw"), - ]); - - let parsed = listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap()) - .expect("event listing"); - assert_eq!(parsed.d_tag, listing_d_tag()); - assert_eq!(parsed.farm.pubkey, farm_ref().pubkey); - assert_eq!(parsed.farm.d_tag, farm_ref().d_tag); - assert_eq!( - parsed.resource_area.unwrap().d_tag, - "AAAAAAAAAAAAAAAAAAAAAQ" - ); - assert_eq!(parsed.plot.unwrap().d_tag, "AAAAAAAAAAAAAAAAAAAAAw"); - } - - #[test] - fn listing_from_event_parts_rejects_conflicting_content_values() { - let tags = base_event_tags(); - - let mut listing = parse_base_listing_from_tags(); - listing.d_tag = d_tag("AAAAAAAAAAAAAAAAAAAAAw"); - let err = - listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap()).unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_D.to_string()); - - let mut listing = parse_base_listing_from_tags(); - listing.farm.pubkey = "other".into(); - let err = - listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap()).unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_A.to_string()); - - let mut listing = parse_base_listing_from_tags(); - listing.farm.d_tag = "AAAAAAAAAAAAAAAAAAAAAw".into(); - let err = - listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap()).unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_A.to_string()); - - let mut listing = parse_base_listing_from_tags(); - listing.farm.d_tag = String::new(); - let parsed = listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap()) - .expect("backfill empty farm d_tag"); - assert_eq!(parsed.farm.d_tag, farm_ref().d_tag); - - let listing = parse_base_listing_from_tags(); - let mut mismatched_pubkey_tags = tags.clone(); - mismatched_pubkey_tags[1][1] = "other".into(); - let err = listing_from_event_parts( - &mismatched_pubkey_tags, - &serde_json::to_string(&listing).unwrap(), - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_P.to_string()); - - let mut listing = parse_base_listing_from_tags(); - listing.resource_area = Some(RadrootsResourceAreaRef { - pubkey: "seller".into(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".into(), - }); - let mut resource_tags = tags.clone(); - resource_tags.push(vec![ - TAG_RADROOTS_RESOURCE_AREA.into(), - format!("{KIND_RESOURCE_AREA}:seller:AAAAAAAAAAAAAAAAAAAAAw"), - ]); - let err = - listing_from_event_parts(&resource_tags, &serde_json::to_string(&listing).unwrap()) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_RESOURCE_AREA.to_string()); - - let mut listing = parse_base_listing_from_tags(); - listing.resource_area = Some(RadrootsResourceAreaRef { - pubkey: "other".into(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAw".into(), - }); - let mut resource_tags = tags.clone(); - resource_tags.push(vec![ - TAG_RADROOTS_RESOURCE_AREA.into(), - format!("{KIND_RESOURCE_AREA}:seller:AAAAAAAAAAAAAAAAAAAAAw"), - ]); - let err = - listing_from_event_parts(&resource_tags, &serde_json::to_string(&listing).unwrap()) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_RESOURCE_AREA.to_string()); - - let mut listing = parse_base_listing_from_tags(); - listing.plot = Some(RadrootsPlotRef { - pubkey: "seller".into(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".into(), - }); - let mut plot_tags = tags.clone(); - plot_tags.push(vec![ - TAG_RADROOTS_PLOT.into(), - format!("{KIND_PLOT}:seller:AAAAAAAAAAAAAAAAAAAAAw"), - ]); - let err = listing_from_event_parts(&plot_tags, &serde_json::to_string(&listing).unwrap()) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_PLOT.to_string()); - - let mut listing = parse_base_listing_from_tags(); - listing.plot = Some(RadrootsPlotRef { - pubkey: "other".into(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAw".into(), - }); - let mut plot_tags = tags.clone(); - plot_tags.push(vec![ - TAG_RADROOTS_PLOT.into(), - format!("{KIND_PLOT}:seller:AAAAAAAAAAAAAAAAAAAAAw"), - ]); - let err = listing_from_event_parts(&plot_tags, &serde_json::to_string(&listing).unwrap()) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_PLOT.to_string()); - } - - #[test] - fn listing_from_event_parts_accepts_matching_resource_and_plot_refs() { - let mut listing = parse_base_listing_from_tags(); - listing.resource_area = Some(RadrootsResourceAreaRef { - pubkey: "seller".into(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".into(), - }); - listing.plot = Some(RadrootsPlotRef { - pubkey: "seller".into(), - d_tag: "AAAAAAAAAAAAAAAAAAAAAw".into(), - }); - let mut tags = base_event_tags(); - tags.push(vec![ - TAG_RADROOTS_RESOURCE_AREA.into(), - format!("{KIND_RESOURCE_AREA}:seller:AAAAAAAAAAAAAAAAAAAAAQ"), - ]); - tags.push(vec![ - TAG_RADROOTS_PLOT.into(), - format!("{KIND_PLOT}:seller:AAAAAAAAAAAAAAAAAAAAAw"), - ]); - let parsed = listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap()) - .expect("matching refs"); - assert_eq!( - parsed.resource_area.unwrap().d_tag, - "AAAAAAAAAAAAAAAAAAAAAQ" - ); - assert_eq!(parsed.plot.unwrap().d_tag, "AAAAAAAAAAAAAAAAAAAAAw"); - } - - #[test] - fn listing_from_event_parts_rejects_invalid_plot_tag_shapes() { - let mut tags = base_event_tags(); - tags.push(vec![TAG_RADROOTS_PLOT.into()]); - let err = listing_from_event_parts(&tags, "").unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_PLOT.to_string()); - - let mut tags = base_event_tags(); - tags.push(vec![TAG_RADROOTS_PLOT.into(), "bad".into()]); - let err = listing_from_event_parts(&tags, "").unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_PLOT.to_string()); - } - - #[test] - fn listing_from_event_parts_falls_back_to_tag_parser() { - let mut tags = base_event_tags(); - tags.extend(base_trade_tags()); - let listing = - listing_from_event_parts(&tags, "{invalid-json").expect("fallback tags parse"); - assert_eq!(listing.primary_bin_id, "bin-1"); - assert_eq!(listing.bins.len(), 1); - } - - #[test] - fn listing_from_event_parts_rejects_missing_reference_tags() { - let mut missing_farm_ref = base_event_tags(); - missing_farm_ref.extend(base_trade_tags()); - missing_farm_ref.retain(|tag| tag.first().map(|v| v.as_str()) != Some(TAG_A)); - let err = listing_from_event_parts(&missing_farm_ref, "").unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_A.to_string()); - - let mut missing_farm_pubkey = base_event_tags(); - missing_farm_pubkey.extend(base_trade_tags()); - missing_farm_pubkey.retain(|tag| tag.first().map(|v| v.as_str()) != Some(TAG_P)); - let err = listing_from_event_parts(&missing_farm_pubkey, "").unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_P.to_string()); - - let mut invalid_resource_area = base_event_tags(); - invalid_resource_area.extend(base_trade_tags()); - invalid_resource_area.push(vec![TAG_RADROOTS_RESOURCE_AREA.into(), "bad".into()]); - let err = listing_from_event_parts(&invalid_resource_area, "").unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_RESOURCE_AREA.to_string()); - } - - #[test] - fn listing_tags_build_and_error_mapping_cover_paths() { - let listing = parse_base_listing_from_tags(); - let built = listing_tags_build(&listing).expect("build tags"); - assert!( - built - .iter() - .any(|tag| tag.first().map(|v| v.as_str()) == Some(TAG_RADROOTS_PRIMARY_BIN)) - ); - - let mapped = map_listing_tags_error(EventEncodeError::EmptyRequiredField("d")); - assert_eq!(parse_error_tag(mapped), "d".to_string()); - let mapped = map_listing_tags_error(EventEncodeError::InvalidField("f")); - assert_eq!(parse_error_tag(mapped), "f".to_string()); - let mapped = map_listing_tags_error(EventEncodeError::Json); - assert_eq!(parse_error_tag(mapped), "discount".to_string()); - let mapped = map_listing_tags_error(EventEncodeError::InvalidKind(1)); - assert_eq!(parse_error_tag(mapped), "kind".to_string()); - } - - #[test] - fn listing_from_tags_parses_trade_specific_optional_fields() { - let mut tags = base_trade_tags(); - tags.push(Vec::new()); - tags.push(vec![TAG_PRICE.into(), "ignored".into()]); - tags.push(vec!["process".into(), "washed".into()]); - tags.push(vec!["lot".into(), "lot-7".into()]); - tags.push(vec!["profile".into(), "fruity".into()]); - tags.push(vec!["year".into(), "2024".into()]); - tags.push(vec![TAG_RADROOTS_PRIMARY_BIN.into(), "bin-1".into()]); - tags.push(vec![ - TAG_LOCATION.into(), - "Farm".into(), - "Town".into(), - "Region".into(), - "SE".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()]); - tags.push(vec![TAG_DELIVERY.into(), "other".into(), "drone".into()]); - tags.push(vec![ - TAG_IMAGE.into(), - "https://cdn/image.png".into(), - "100x200".into(), - ]); - tags.push(vec![TAG_IMAGE.into(), " ".into()]); - let discount = RadrootsCoreDiscount { - scope: RadrootsCoreDiscountScope::Bin, - threshold: RadrootsCoreDiscountThreshold::BinCount { - bin_id: "bin-1".into(), - min: 2, - }, - value: RadrootsCoreDiscountValue::Percent(RadrootsCorePercent::new( - "5".parse::<RadrootsCoreDecimal>().unwrap(), - )), - }; - tags.push(vec![ - TAG_RADROOTS_DISCOUNT.into(), - serde_json::to_string(&discount).unwrap(), - ]); - - let listing = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .expect("listing"); - - assert_eq!( - format!("{:?}", listing.availability), - "Some(Window { start: Some(10), end: Some(20) })" - ); - assert_eq!( - format!("{:?}", listing.delivery_method), - "Some(Other { method: \"drone\" })" - ); - assert_eq!( - 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")); - assert_eq!(listing.product.profile.as_deref(), Some("fruity")); - assert_eq!(listing.product.year.as_deref(), Some("2024")); - assert_eq!(listing.images.as_ref().unwrap().len(), 1); - assert_eq!(listing.discounts.as_ref().unwrap().len(), 1); - } - - #[test] - fn listing_from_tags_uses_unstructured_location_and_custom_delivery() { - let mut tags = base_trade_tags(); - tags.push(vec![TAG_LOCATION.into(), "Farm".into()]); - tags.push(vec![TAG_LOCATION.into(), "fallback".into()]); - tags.push(vec![TAG_DELIVERY.into(), "parcel".into()]); - - let listing = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .expect("listing"); - - assert_eq!(listing.product.location.as_deref(), Some("Farm")); - assert_eq!( - format!("{:?}", listing.delivery_method), - "Some(Other { method: \"parcel\" })" - ); - } - - #[test] - fn listing_from_tags_parses_delivery_enum_variants() { - let mut local_delivery = base_trade_tags(); - local_delivery.push(vec![TAG_DELIVERY.into(), "local_delivery".into()]); - let listing = listing_from_tags( - &local_delivery, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .expect("local delivery parse"); - assert_eq!( - format!("{:?}", listing.delivery_method), - "Some(LocalDelivery)" - ); - - let mut shipping = base_trade_tags(); - shipping.push(vec![TAG_DELIVERY.into(), "shipping".into()]); - let listing = listing_from_tags( - &shipping, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .expect("shipping parse"); - assert_eq!(format!("{:?}", listing.delivery_method), "Some(Shipping)"); - } - - #[test] - fn listing_from_tags_rejects_empty_structured_location_primary() { - let mut tags = base_trade_tags(); - tags.push(vec![ - TAG_LOCATION.into(), - " ".into(), - "Town".into(), - "Region".into(), - ]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_LOCATION.to_string()); - } - - #[test] - fn listing_from_tags_handles_short_location_tags_when_structured_present() { - let mut tags = base_trade_tags(); - tags.push(vec![ - TAG_LOCATION.into(), - "Farm".into(), - "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( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .expect("listing"); - assert_eq!(listing.location.unwrap().primary, "Farm".to_string()); - } - - #[test] - fn listing_from_tags_rejects_invalid_tag_forms() { - let mut tags = base_trade_tags(); - tags.push(vec![TAG_RADROOTS_PRIMARY_BIN.into(), "other".into()]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRIMARY_BIN.to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![TAG_RADROOTS_BIN.into(), "bin-1".into(), "1000".into()]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![ - TAG_RADROOTS_PRICE.into(), - "bin-1".into(), - "1".into(), - "USD".into(), - "1".into(), - ]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![ - TAG_RADROOTS_BIN.into(), - "bin-1".into(), - "1000".into(), - "g".into(), - "1".into(), - "kg".into(), - "label".into(), - "extra".into(), - ]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![ - TAG_RADROOTS_BIN.into(), - " ".into(), - "1000".into(), - "g".into(), - ]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![ - TAG_RADROOTS_BIN.into(), - "bin-1".into(), - "1000".into(), - "kg".into(), - ]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![ - TAG_RADROOTS_BIN.into(), - "bin-1".into(), - "1000".into(), - "g".into(), - "1".into(), - ]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![ - TAG_RADROOTS_PRICE.into(), - " ".into(), - "1".into(), - "USD".into(), - "1".into(), - "g".into(), - ]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![ - TAG_RADROOTS_PRICE.into(), - "bin-1".into(), - "1".into(), - "USD".into(), - "1".into(), - "kg".into(), - ]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![ - TAG_RADROOTS_PRICE.into(), - "bin-1".into(), - "1".into(), - "USD".into(), - "1".into(), - "g".into(), - "9".into(), - ]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![ - TAG_RADROOTS_PRICE.into(), - "bin-1".into(), - "1".into(), - "USD".into(), - "1".into(), - "g".into(), - "9".into(), - "bad".into(), - ]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![ - TAG_RADROOTS_PRICE.into(), - "bin-1".into(), - "1".into(), - "USD".into(), - "1".into(), - "g".into(), - "9".into(), - "kg".into(), - "x".into(), - ]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![TAG_RADROOTS_PRIMARY_BIN.into()]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRIMARY_BIN.to_string()); - } - - #[test] - fn listing_from_tags_rejects_trade_field_parse_failures() { - let mut tags = base_trade_tags(); - tags.push(vec![TAG_RADROOTS_DISCOUNT.into(), "{".into()]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_DISCOUNT.to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![TAG_INVENTORY.into()]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_INVENTORY.to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![TAG_RADROOTS_AVAILABILITY_START.into(), "bad".into()]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!( - parse_error_tag(err), - TAG_RADROOTS_AVAILABILITY_START.to_string() - ); - - let mut tags = base_trade_tags(); - tags.push(vec![TAG_EXPIRES_AT.into(), "bad".into()]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_EXPIRES_AT.to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![TAG_RADROOTS_DISCOUNT.into()]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_DISCOUNT.to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![TAG_RADROOTS_AVAILABILITY_START.into()]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!( - parse_error_tag(err), - TAG_RADROOTS_AVAILABILITY_START.to_string() - ); - - let mut tags = base_trade_tags(); - tags.push(vec![TAG_EXPIRES_AT.into()]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_EXPIRES_AT.to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![TAG_IMAGE.into()]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_IMAGE.to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![TAG_INVENTORY.into(), "bad".into()]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_INVENTORY.to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![ - TAG_RADROOTS_BIN.into(), - "bin-1".into(), - "bad".into(), - "g".into(), - ]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![ - TAG_RADROOTS_BIN.into(), - "bin-1".into(), - "500".into(), - "bad".into(), - ]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), "unit".to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![ - TAG_RADROOTS_BIN.into(), - "bin-2".into(), - "500".into(), - "g".into(), - "bad".into(), - "g".into(), - ]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![ - TAG_RADROOTS_BIN.into(), - "bin-2".into(), - "500".into(), - "g".into(), - "1".into(), - "bad".into(), - ]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), "unit".to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![ - TAG_RADROOTS_PRICE.into(), - "bin-1".into(), - "bad".into(), - "USD".into(), - "1".into(), - "g".into(), - ]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![ - TAG_RADROOTS_PRICE.into(), - "bin-1".into(), - "10".into(), - "US".into(), - "1".into(), - "g".into(), - ]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), "currency".to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![ - TAG_RADROOTS_PRICE.into(), - "bin-1".into(), - "10".into(), - "USD".into(), - "bad".into(), - "g".into(), - ]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![ - TAG_RADROOTS_PRICE.into(), - "bin-1".into(), - "10".into(), - "USD".into(), - "1".into(), - "bad".into(), - ]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), "unit".to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![ - TAG_RADROOTS_PRICE.into(), - "bin-2".into(), - "10".into(), - "USD".into(), - "1".into(), - "g".into(), - "bad".into(), - "g".into(), - ]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string()); - - let mut tags = base_trade_tags(); - tags.push(vec![ - TAG_RADROOTS_PRICE.into(), - "bin-2".into(), - "10".into(), - "USD".into(), - "1".into(), - "g".into(), - "12".into(), - "bad".into(), - ]); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), "unit".to_string()); - } - - #[test] - fn listing_from_tags_covers_bin_display_and_price_shape_edges() { - let tags = vec![ - vec!["key".into(), "coffee".into()], - vec!["title".into(), "Coffee".into()], - vec!["category".into(), "coffee".into()], - vec![TAG_RADROOTS_PRIMARY_BIN.into(), "bin-2".into()], - vec![ - TAG_RADROOTS_BIN.into(), - "bin-2".into(), - "500".into(), - "g".into(), - "1".into(), - ], - vec![ - TAG_RADROOTS_PRICE.into(), - "bin-2".into(), - "0.02".into(), - "USD".into(), - "1".into(), - "g".into(), - "10".into(), - ], - ]; - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_BIN.to_string()); - - let tags = vec![ - vec!["key".into(), "coffee".into()], - vec!["title".into(), "Coffee".into()], - vec!["category".into(), "coffee".into()], - vec![TAG_RADROOTS_PRIMARY_BIN.into(), "bin-2".into()], - vec![ - TAG_RADROOTS_BIN.into(), - "bin-2".into(), - "500".into(), - "g".into(), - "1".into(), - "kg".into(), - ], - vec![ - TAG_RADROOTS_PRICE.into(), - "bin-2".into(), - "0.02".into(), - "USD".into(), - "1".into(), - "g".into(), - "10".into(), - ], - ]; - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string()); - } - - #[test] - fn listing_from_tags_rejects_missing_primary_bin_and_invalid_seller() { - let mut tags = base_trade_tags(); - tags.retain(|tag| tag[0] != TAG_RADROOTS_PRIMARY_BIN); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRIMARY_BIN.to_string()); - - let err = listing_from_tags( - &base_trade_tags(), - listing_d_tag(), - farm_ref(), - "other".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_P.to_string()); - - let mut tags = base_trade_tags(); - tags[4][1] = "missing".into(); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRIMARY_BIN.to_string()); - - let mut tags = base_trade_tags(); - tags[4][1] = "bad id".into(); - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRIMARY_BIN.to_string()); - } - - #[test] - fn listing_from_tags_rejects_incomplete_bin_draft() { - let tags = vec![ - vec!["key".into(), "coffee".into()], - vec!["title".into(), "Coffee".into()], - vec!["category".into(), "coffee".into()], - vec![TAG_RADROOTS_PRIMARY_BIN.into(), "bin-1".into()], - vec![ - TAG_RADROOTS_BIN.into(), - "bin-1".into(), - "500".into(), - "g".into(), - ], - ]; - let err = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .unwrap_err(); - assert_eq!(parse_error_tag(err), TAG_RADROOTS_PRICE.to_string()); - } - - #[test] - fn parse_farm_and_reference_helpers_cover_all_paths() { - let valid_farm_tags = vec![vec![ - TAG_A.into(), - format!("{KIND_FARM}:seller:AAAAAAAAAAAAAAAAAAAAAA"), - ]]; - let farm = parse_farm_ref(&valid_farm_tags).unwrap(); - assert_eq!(farm.pubkey, farm_ref().pubkey); - assert_eq!(farm.d_tag, farm_ref().d_tag); - assert_eq!( - parse_error_tag(parse_farm_ref(&[]).unwrap_err()), - TAG_A.to_string() - ); - assert_eq!( - parse_error_tag(parse_farm_ref(&[vec![TAG_A.into()]]).unwrap_err()), - TAG_A.to_string() - ); - assert_eq!( - parse_error_tag(parse_farm_ref(&[vec![TAG_A.into(), "bad".into()]]).unwrap_err()), - TAG_A.to_string() - ); - assert_eq!( - parse_error_tag( - parse_farm_ref(&[vec![TAG_A.into(), format!("1:seller:{}", farm_ref().d_tag)]]) - .unwrap_err() - ), - TAG_A.to_string() - ); - assert_eq!( - parse_error_tag( - parse_farm_ref(&[vec![ - TAG_A.into(), - format!("{KIND_FARM}: :{}", farm_ref().d_tag) - ]]) - .unwrap_err() - ), - TAG_A.to_string() - ); - assert_eq!( - parse_error_tag( - parse_farm_ref(&[vec![TAG_A.into(), format!("{KIND_FARM}:seller:")]]).unwrap_err() - ), - TAG_A.to_string() - ); - assert_eq!( - parse_error_tag( - parse_farm_ref(&[vec![TAG_A.into(), format!("{KIND_FARM}")]]).unwrap_err() - ), - TAG_A.to_string() - ); - assert_eq!( - parse_error_tag( - parse_farm_ref(&[vec![TAG_A.into(), format!("{KIND_FARM}:seller")]]).unwrap_err() - ), - TAG_A.to_string() - ); - assert_eq!( - parse_error_tag( - parse_farm_ref(&[vec![TAG_A.into(), format!("{KIND_FARM}:seller:not-base64")]]) - .unwrap_err() - ), - TAG_A.to_string() - ); - - assert_eq!( - parse_farm_pubkey(&[vec![TAG_P.into(), "seller".into()]]).unwrap(), - "seller".to_string() - ); - assert_eq!( - parse_error_tag(parse_farm_pubkey(&[]).unwrap_err()), - TAG_P.to_string() - ); - assert_eq!( - parse_error_tag(parse_farm_pubkey(&[vec![TAG_P.into()]]).unwrap_err()), - TAG_P.to_string() - ); - assert_eq!( - parse_error_tag(parse_farm_pubkey(&[vec![TAG_P.into(), " ".into()]]).unwrap_err()), - TAG_P.to_string() - ); - - assert!(parse_resource_area(&[]).unwrap().is_none()); - let area_tag = vec![vec![ - TAG_RADROOTS_RESOURCE_AREA.into(), - format!("{KIND_RESOURCE_AREA}:seller:AAAAAAAAAAAAAAAAAAAAAQ"), - ]]; - assert!(parse_resource_area(&area_tag).unwrap().is_some()); - let missing_area = vec![vec![TAG_RADROOTS_RESOURCE_AREA.into()]]; - assert_eq!( - parse_error_tag(parse_resource_area(&missing_area).unwrap_err()), - TAG_RADROOTS_RESOURCE_AREA.to_string() - ); - let invalid_area_kind = vec![vec![TAG_RADROOTS_RESOURCE_AREA.into(), "bad".into()]]; - assert_eq!( - parse_error_tag(parse_resource_area(&invalid_area_kind).unwrap_err()), - TAG_RADROOTS_RESOURCE_AREA.to_string() - ); - let missing_area_pubkey = vec![vec![ - TAG_RADROOTS_RESOURCE_AREA.into(), - format!("{KIND_RESOURCE_AREA}"), - ]]; - assert_eq!( - parse_error_tag(parse_resource_area(&missing_area_pubkey).unwrap_err()), - TAG_RADROOTS_RESOURCE_AREA.to_string() - ); - let missing_area_d = vec![vec![ - TAG_RADROOTS_RESOURCE_AREA.into(), - format!("{KIND_RESOURCE_AREA}:seller"), - ]]; - assert_eq!( - parse_error_tag(parse_resource_area(&missing_area_d).unwrap_err()), - TAG_RADROOTS_RESOURCE_AREA.to_string() - ); - let bad_area = vec![vec![ - TAG_RADROOTS_RESOURCE_AREA.into(), - "1:seller:bad".into(), - ]]; - assert_eq!( - parse_error_tag(parse_resource_area(&bad_area).unwrap_err()), - TAG_RADROOTS_RESOURCE_AREA.to_string() - ); - let empty_area = vec![vec![ - TAG_RADROOTS_RESOURCE_AREA.into(), - format!("{KIND_RESOURCE_AREA}: :{}", listing_d_tag()), - ]]; - assert_eq!( - parse_error_tag(parse_resource_area(&empty_area).unwrap_err()), - TAG_RADROOTS_RESOURCE_AREA.to_string() - ); - let empty_area_d = vec![vec![ - TAG_RADROOTS_RESOURCE_AREA.into(), - format!("{KIND_RESOURCE_AREA}:seller:"), - ]]; - assert_eq!( - parse_error_tag(parse_resource_area(&empty_area_d).unwrap_err()), - TAG_RADROOTS_RESOURCE_AREA.to_string() - ); - let invalid_area_d = vec![vec![ - TAG_RADROOTS_RESOURCE_AREA.into(), - format!("{KIND_RESOURCE_AREA}:seller:not-base64"), - ]]; - assert_eq!( - parse_error_tag(parse_resource_area(&invalid_area_d).unwrap_err()), - TAG_RADROOTS_RESOURCE_AREA.to_string() - ); - - assert!(parse_plot_ref(&[]).unwrap().is_none()); - let plot_tag = vec![vec![ - TAG_RADROOTS_PLOT.into(), - format!("{KIND_PLOT}:seller:AAAAAAAAAAAAAAAAAAAAAQ"), - ]]; - assert!(parse_plot_ref(&plot_tag).unwrap().is_some()); - let missing_plot = vec![vec![TAG_RADROOTS_PLOT.into()]]; - assert_eq!( - parse_error_tag(parse_plot_ref(&missing_plot).unwrap_err()), - TAG_RADROOTS_PLOT.to_string() - ); - let missing_plot_pubkey = vec![vec![TAG_RADROOTS_PLOT.into(), format!("{KIND_PLOT}")]]; - assert_eq!( - parse_error_tag(parse_plot_ref(&missing_plot_pubkey).unwrap_err()), - TAG_RADROOTS_PLOT.to_string() - ); - let missing_plot_d = vec![vec![ - TAG_RADROOTS_PLOT.into(), - format!("{KIND_PLOT}:seller"), - ]]; - assert_eq!( - parse_error_tag(parse_plot_ref(&missing_plot_d).unwrap_err()), - TAG_RADROOTS_PLOT.to_string() - ); - let bad_plot = vec![vec![TAG_RADROOTS_PLOT.into(), "1:seller:bad".into()]]; - assert_eq!( - parse_error_tag(parse_plot_ref(&bad_plot).unwrap_err()), - TAG_RADROOTS_PLOT.to_string() - ); - let empty_plot = vec![vec![ - TAG_RADROOTS_PLOT.into(), - format!("{KIND_PLOT}: :{}", listing_d_tag()), - ]]; - assert_eq!( - parse_error_tag(parse_plot_ref(&empty_plot).unwrap_err()), - TAG_RADROOTS_PLOT.to_string() - ); - let empty_plot_d = vec![vec![ - TAG_RADROOTS_PLOT.into(), - format!("{KIND_PLOT}:seller:"), - ]]; - assert_eq!( - parse_error_tag(parse_plot_ref(&empty_plot_d).unwrap_err()), - TAG_RADROOTS_PLOT.to_string() - ); - let invalid_plot_d = vec![vec![ - TAG_RADROOTS_PLOT.into(), - format!("{KIND_PLOT}:seller:not-base64"), - ]]; - assert_eq!( - parse_error_tag(parse_plot_ref(&invalid_plot_d).unwrap_err()), - TAG_RADROOTS_PLOT.to_string() - ); - } - - #[test] - fn helper_functions_cover_assigners_and_classifiers() { - assert_eq!(clean_value(" value "), Some("value".into())); - assert_eq!(clean_value(" "), None); - assert_eq!(clean_value("null"), None); - - let mut s = String::new(); - let val = "one".to_string(); - set_if_empty(&mut s, Some(&val)); - assert_eq!(s, "one"); - let next = "two".to_string(); - set_if_empty(&mut s, Some(&next)); - assert_eq!(s, "one"); - let mut empty = String::new(); - let nullish = "null".to_string(); - set_if_empty(&mut empty, Some(&nullish)); - assert_eq!(empty, ""); - - let mut opt = None; - let v = "set".to_string(); - set_optional(&mut opt, Some(&v)); - assert_eq!(opt.as_deref(), Some("set")); - let w = "skip".to_string(); - set_optional(&mut opt, Some(&w)); - assert_eq!(opt.as_deref(), Some("set")); - let mut opt_none = None; - let blank = " ".to_string(); - set_optional(&mut opt_none, Some(&blank)); - assert_eq!(opt_none, None); - - assert_eq!(format!("{:?}", parse_status("ACTIVE")), "Active"); - assert_eq!(format!("{:?}", parse_status("sold")), "Sold"); - assert_eq!( - format!("{:?}", parse_status("queued")), - "Other { value: \"queued\" }" - ); - - assert_eq!(parse_image_size("100x200").unwrap().w, 100); - assert!(parse_image_size("100").is_none()); - assert!(parse_image_size("invalid").is_none()); - assert!(parse_image_size("badx100").is_none()); - assert!(parse_image_size("100xbad").is_none()); - } - - #[test] - fn parse_discount_and_bin_helpers_cover_error_paths() { - let discount = RadrootsCoreDiscount { - scope: RadrootsCoreDiscountScope::OrderTotal, - threshold: RadrootsCoreDiscountThreshold::OrderQuantity { - min: RadrootsCoreQuantity::new("1".parse().unwrap(), RadrootsCoreUnit::MassG), - }, - value: RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new( - "1".parse().unwrap(), - RadrootsCoreCurrency::USD, - )), - }; - let payload = serde_json::to_string(&discount).unwrap(); - assert!(parse_discount(&payload).is_ok()); - assert_eq!( - parse_error_tag(parse_discount("{").unwrap_err()), - TAG_RADROOTS_DISCOUNT.to_string() - ); - assert_eq!( - parse_error_tag(ListingParseError::InvalidJson("x".into())), - "x".to_string() - ); - - let mut drafts = Vec::new(); - let mut order_index = 0usize; - let first = upsert_bin(&mut drafts, "a", &mut order_index); - first.quantity = Some(RadrootsCoreQuantity::new( - "1".parse().unwrap(), - RadrootsCoreUnit::MassG, - )); - first.price_per_canonical_unit = Some(RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new("1".parse().unwrap(), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new("1".parse().unwrap(), RadrootsCoreUnit::MassG), - )); - - let second = upsert_bin(&mut drafts, "a", &mut order_index); - assert_eq!(second.order_index, 0); - assert_eq!(order_index, 1); - assert!(build_bins(drafts).is_ok()); - - let draft_missing_qty = BinDraft { - bin_id: "b".into(), - order_index: 0, - quantity: None, - display_amount: None, - display_unit: None, - display_label: None, - price_per_canonical_unit: Some(RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new("1".parse().unwrap(), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new("1".parse().unwrap(), RadrootsCoreUnit::MassG), - )), - display_price: None, - display_price_unit: None, - }; - assert_eq!( - parse_error_tag(build_bins(vec![draft_missing_qty]).unwrap_err()), - TAG_RADROOTS_BIN.to_string() - ); - - let draft_missing_price = BinDraft { - bin_id: "b".into(), - order_index: 0, - quantity: Some(RadrootsCoreQuantity::new( - "1".parse().unwrap(), - RadrootsCoreUnit::MassG, - )), - display_amount: None, - display_unit: None, - display_label: None, - price_per_canonical_unit: None, - display_price: None, - display_price_unit: None, - }; - assert_eq!( - parse_error_tag(build_bins(vec![draft_missing_price]).unwrap_err()), - TAG_RADROOTS_PRICE.to_string() - ); - - let draft_mismatch = BinDraft { - bin_id: "b".into(), - order_index: 0, - quantity: Some(RadrootsCoreQuantity::new( - "1".parse().unwrap(), - RadrootsCoreUnit::MassG, - )), - display_amount: None, - display_unit: None, - display_label: None, - price_per_canonical_unit: Some(RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new("1".parse().unwrap(), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new("1".parse().unwrap(), RadrootsCoreUnit::Each), - )), - display_price: None, - display_price_unit: None, - }; - assert_eq!( - parse_error_tag(build_bins(vec![draft_mismatch]).unwrap_err()), - TAG_RADROOTS_PRICE.to_string() - ); - - let draft_invalid_bin = BinDraft { - bin_id: "bad id".into(), - order_index: 0, - quantity: Some(RadrootsCoreQuantity::new( - "1".parse().unwrap(), - RadrootsCoreUnit::MassG, - )), - display_amount: None, - display_unit: None, - display_label: None, - price_per_canonical_unit: Some(RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new("1".parse().unwrap(), RadrootsCoreCurrency::USD), - RadrootsCoreQuantity::new("1".parse().unwrap(), RadrootsCoreUnit::MassG), - )), - display_price: None, - display_price_unit: None, - }; - assert_eq!( - parse_error_tag(build_bins(vec![draft_invalid_bin]).unwrap_err()), - TAG_RADROOTS_BIN.to_string() - ); - - let tags = vec![ - vec!["key".into(), "coffee".into()], - vec!["title".into(), "Coffee".into()], - vec!["category".into(), "coffee".into()], - vec![TAG_RADROOTS_PRIMARY_BIN.into(), "bin-2".into()], - vec![ - TAG_RADROOTS_BIN.into(), - "bin-2".into(), - "500".into(), - "g".into(), - ], - vec![ - TAG_RADROOTS_PRICE.into(), - "bin-2".into(), - "0.02".into(), - "USD".into(), - "1".into(), - "g".into(), - ], - ]; - let listing = listing_from_tags( - &tags, - listing_d_tag(), - farm_ref(), - "seller".to_string(), - None, - None, - ) - .expect("compact listing"); - assert_eq!(listing.primary_bin_id, "bin-2"); - } -} - -fn clean_value(value: &str) -> Option<String> { - let trimmed = value.trim(); - if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("null") { - None - } else { - Some(trimmed.to_string()) - } -} - -fn set_if_empty(target: &mut String, value: Option<&String>) { - if target.trim().is_empty() - && let Some(v) = value.and_then(|v| clean_value(v)) - { - *target = v; - } -} - -fn set_optional(target: &mut Option<String>, value: Option<&String>) { - if target.is_none() - && let Some(v) = value.and_then(|v| clean_value(v)) - { - *target = Some(v); - } -} - -fn parse_status(value: &str) -> RadrootsListingStatus { - match value.trim().to_ascii_lowercase().as_str() { - "active" => RadrootsListingStatus::Active, - "sold" => RadrootsListingStatus::Sold, - other => RadrootsListingStatus::Other { - value: other.to_string(), - }, - } -} - -fn parse_image_size(value: &str) -> Option<RadrootsListingImageSize> { - let (w_raw, h_raw) = value.split_once('x')?; - let w = w_raw.parse::<u32>().ok()?; - let h = h_raw.parse::<u32>().ok()?; - Some(RadrootsListingImageSize { w, h }) -} - -fn parse_discount(payload: &str) -> Result<RadrootsCoreDiscount, ListingParseError> { - #[cfg(feature = "serde_json")] - { - serde_json::from_str(payload) - .map_err(|_| ListingParseError::InvalidDiscount(TAG_RADROOTS_DISCOUNT.to_string())) - } - #[cfg(not(feature = "serde_json"))] - { - let _ = payload; - Err(ListingParseError::InvalidJson("discount".to_string())) - } -} - -#[derive(Clone, Debug)] -struct BinDraft { - bin_id: String, - order_index: usize, - quantity: Option<RadrootsCoreQuantity>, - display_amount: Option<RadrootsCoreDecimal>, - display_unit: Option<RadrootsCoreUnit>, - display_label: Option<String>, - price_per_canonical_unit: Option<RadrootsCoreQuantityPrice>, - display_price: Option<RadrootsCoreMoney>, - display_price_unit: Option<RadrootsCoreUnit>, -} - -fn upsert_bin<'a>( - bins: &'a mut Vec<BinDraft>, - bin_id: &str, - order_index: &mut usize, -) -> &'a mut BinDraft { - if let Some(pos) = bins.iter().position(|bin| bin.bin_id == bin_id) { - return &mut bins[pos]; - } - let draft = BinDraft { - bin_id: bin_id.to_string(), - order_index: *order_index, - quantity: None, - display_amount: None, - display_unit: None, - display_label: None, - price_per_canonical_unit: None, - display_price: None, - display_price_unit: None, - }; - bins.push(draft); - *order_index += 1; - let idx = bins.len() - 1; - &mut bins[idx] -} - -fn build_bins(mut drafts: Vec<BinDraft>) -> Result<Vec<RadrootsListingBin>, ListingParseError> { - drafts.sort_by_key(|draft| draft.order_index); - let mut bins = Vec::with_capacity(drafts.len()); - for draft in drafts { - let quantity = draft - .quantity - .ok_or_else(|| ListingParseError::MissingTag(TAG_RADROOTS_BIN.to_string()))?; - let price = draft - .price_per_canonical_unit - .ok_or_else(|| ListingParseError::MissingTag(TAG_RADROOTS_PRICE.to_string()))?; - if quantity.unit != price.quantity.unit { - return Err(ListingParseError::InvalidTag( - TAG_RADROOTS_PRICE.to_string(), - )); - } - let bin = RadrootsListingBin { - bin_id: RadrootsInventoryBinId::parse(&draft.bin_id) - .map_err(|_| ListingParseError::InvalidTag(TAG_RADROOTS_BIN.to_string()))?, - quantity, - price_per_canonical_unit: price, - display_amount: draft.display_amount, - display_unit: draft.display_unit, - display_label: draft.display_label, - display_price: draft.display_price, - display_price_unit: draft.display_price_unit, - }; - bins.push(bin); - } - Ok(bins) -} diff --git a/crates/trade/src/listing/mod.rs b/crates/trade/src/listing/mod.rs @@ -1,4 +1,3 @@ -mod codec; pub mod draft; pub mod model; pub mod mutation; @@ -14,6 +13,7 @@ use radroots_event::{ kinds::is_listing_kind, listing::RadrootsListing, }; +use radroots_event_codec::listing::decode::listing_from_nostr_event; use thiserror::Error; pub use self::draft::{ @@ -120,11 +120,7 @@ fn ensure_listing_kind(kind: u32) -> Result<(), RadrootsListingAddressError> { pub fn parse_listing_event( event: &RadrootsEventEnvelope, ) -> Result<RadrootsListing, ListingParseError> { - if !is_listing_kind(event.kind_u32()) { - return Err(ListingParseError::InvalidKind(event.kind_u32())); - } - let tags = event.tags_as_vec(); - self::codec::listing_from_event_parts(&tags, event.content()) + listing_from_nostr_event(event) } #[cfg(test)] diff --git a/crates/trade/src/listing/validation.rs b/crates/trade/src/listing/validation.rs @@ -1,7 +1,7 @@ #![forbid(unsafe_code)] #[cfg(not(feature = "std"))] -use alloc::{format, string::String, vec::Vec}; +use alloc::{format, string::String}; use radroots_core::{ RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreUnit, @@ -18,7 +18,7 @@ use radroots_event::{ trade_validation::RadrootsTradeValidationListingError as TradeListingValidationError, }; -use crate::listing::codec::listing_from_event_parts; +use radroots_event_codec::listing::decode::listing_from_nostr_event; #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug)] @@ -49,8 +49,7 @@ pub fn validate_listing_event( }); } - let tags = event.tags_as_vec(); - let listing = listing_from_event_parts(&tags, event.content()) + let listing = listing_from_nostr_event(event) .map_err(|error| TradeListingValidationError::ParseError { error })?; let listing_id = listing.d_tag.trim().to_string(); @@ -271,19 +270,102 @@ mod tests { } fn base_event(listing: &RadrootsListing) -> RadrootsEventEnvelope { - event_with_parts( - SELLER, - KIND_LISTING, + let mut tags = vec![ + vec!["d".into(), listing.d_tag.to_string()], + vec!["p".into(), listing.farm.pubkey.clone()], vec![ - vec!["d".into(), listing.d_tag.to_string()], - vec!["p".into(), listing.farm.pubkey.clone()], - vec![ - "a".into(), - format!("30340:{}:{}", listing.farm.pubkey, listing.farm.d_tag), - ], + "a".into(), + format!("30340:{}:{}", listing.farm.pubkey, listing.farm.d_tag), + ], + vec!["key".into(), listing.product.key.clone()], + vec!["title".into(), listing.product.title.clone()], + vec!["category".into(), listing.product.category.clone()], + vec![ + "summary".into(), + listing.product.summary.clone().unwrap_or_default(), + ], + vec![ + "radroots:primary_bin".into(), + listing.primary_bin_id.to_string(), ], - serde_json::to_string(listing).unwrap(), - ) + ]; + for bin in &listing.bins { + tags.push(vec![ + "radroots:bin".into(), + bin.bin_id.to_string(), + bin.quantity.amount.to_string(), + bin.quantity.unit.code().to_string(), + ]); + tags.push(vec![ + "radroots:price".into(), + bin.bin_id.to_string(), + bin.price_per_canonical_unit.amount.amount.to_string(), + bin.price_per_canonical_unit + .amount + .currency + .as_str() + .to_string(), + bin.price_per_canonical_unit.quantity.amount.to_string(), + bin.price_per_canonical_unit + .quantity + .unit + .code() + .to_string(), + ]); + } + if let Some(inventory) = listing.inventory_available { + tags.push(vec!["inventory".into(), inventory.to_string()]); + } + if let Some(availability) = &listing.availability { + match availability { + RadrootsListingAvailability::Status { status } => tags.push(vec![ + "status".into(), + match status { + radroots_event::listing::RadrootsListingStatus::Active => "active".into(), + radroots_event::listing::RadrootsListingStatus::Sold => "sold".into(), + radroots_event::listing::RadrootsListingStatus::Other { value } => { + value.clone() + } + }, + ]), + RadrootsListingAvailability::Window { start, end } => { + if let Some(start) = start { + tags.push(vec![ + "radroots:availability_start".into(), + start.to_string(), + ]); + } + if let Some(end) = end { + tags.push(vec!["expires_at".into(), end.to_string()]); + } + } + } + } + if let Some(delivery) = &listing.delivery_method { + let mut tag = vec!["delivery".into()]; + match delivery { + RadrootsListingDeliveryMethod::Pickup => tag.push("pickup".into()), + RadrootsListingDeliveryMethod::LocalDelivery => tag.push("local_delivery".into()), + RadrootsListingDeliveryMethod::Shipping => tag.push("shipping".into()), + RadrootsListingDeliveryMethod::Other { method } => { + tag.push("other".into()); + tag.push(method.clone()); + } + } + tags.push(tag); + } + if let Some(location) = &listing.location { + tags.push(vec![ + "location".into(), + location.primary.clone(), + location.city.clone().unwrap_or_default(), + location.region.clone().unwrap_or_default(), + location.country.clone().unwrap_or_default(), + ]); + tags.push(vec!["g".into(), location.geohash.clone()]); + } + + event_with_parts(SELLER, KIND_LISTING, tags, String::new()) } fn event_with_parts( @@ -324,7 +406,7 @@ mod tests { SELLER, 30403, base_event(&listing).tags_as_vec(), - serde_json::to_string(&listing).unwrap(), + String::new(), ); let err = validate_listing_event(&event).unwrap_err(); assert_eq!( @@ -335,18 +417,12 @@ mod tests { #[test] fn validate_listing_rejects_missing_d_tag() { - let listing = base_listing(); - let event = event_with_parts( - SELLER, - KIND_LISTING, - Vec::new(), - serde_json::to_string(&listing).unwrap(), - ); + let event = event_with_parts(SELLER, KIND_LISTING, Vec::new(), String::new()); let err = validate_listing_event(&event).unwrap_err(); assert_eq!( err, TradeListingValidationError::ParseError { - error: crate::listing::codec::ListingParseError::MissingTag("d".to_string()) + error: crate::listing::ListingParseError::MissingTag("d".to_string()) } ); } @@ -400,7 +476,7 @@ mod tests { OTHER_SELLER, KIND_LISTING, base_event(&listing).tags_as_vec(), - serde_json::to_string(&listing).unwrap(), + String::new(), ); let err = validate_listing_event(&event).unwrap_err(); assert_eq!(err, TradeListingValidationError::InvalidSeller); @@ -418,12 +494,7 @@ mod tests { #[test] fn validate_listing_rejects_invalid_kind() { let listing = base_listing(); - let event = event_with_parts( - SELLER, - 0, - base_event(&listing).tags_as_vec(), - serde_json::to_string(&listing).unwrap(), - ); + let event = event_with_parts(SELLER, 0, base_event(&listing).tags_as_vec(), String::new()); let err = validate_listing_event(&event).unwrap_err(); assert_eq!(err, TradeListingValidationError::InvalidKind { kind: 0 }); } @@ -454,7 +525,12 @@ mod tests { fn validate_listing_rejects_missing_bins() { let mut listing = base_listing(); listing.bins.clear(); - assert_validation_err(listing, TradeListingValidationError::MissingBins); + assert_validation_err( + listing, + TradeListingValidationError::ParseError { + error: crate::listing::ListingParseError::InvalidTag("radroots:primary_bin".into()), + }, + ); } #[test] @@ -466,7 +542,12 @@ mod tests { fn validate_listing_rejects_primary_bin_not_found() { let mut listing = base_listing(); listing.primary_bin_id = bin_id("missing"); - assert_validation_err(listing, TradeListingValidationError::MissingPrimaryBin); + assert_validation_err( + listing, + TradeListingValidationError::ParseError { + error: crate::listing::ListingParseError::InvalidTag("radroots:primary_bin".into()), + }, + ); } #[test] @@ -480,14 +561,24 @@ mod tests { fn validate_listing_rejects_non_canonical_quantity() { let mut listing = base_listing(); listing.bins[0].quantity.unit = RadrootsCoreUnit::MassKg; - assert_validation_err(listing, TradeListingValidationError::InvalidBin); + assert_validation_err( + listing, + TradeListingValidationError::ParseError { + error: crate::listing::ListingParseError::InvalidTag("radroots:bin".into()), + }, + ); } #[test] fn validate_listing_rejects_non_canonical_price_quantity() { let mut listing = base_listing(); listing.bins[0].price_per_canonical_unit.quantity.unit = RadrootsCoreUnit::MassKg; - assert_validation_err(listing, TradeListingValidationError::InvalidPrice); + assert_validation_err( + listing, + TradeListingValidationError::ParseError { + error: crate::listing::ListingParseError::InvalidTag("radroots:price".into()), + }, + ); } #[test] @@ -501,7 +592,12 @@ mod tests { fn validate_listing_rejects_price_unit_mismatch() { let mut listing = base_listing(); listing.bins[0].price_per_canonical_unit.quantity.unit = RadrootsCoreUnit::Each; - assert_validation_err(listing, TradeListingValidationError::InvalidPrice); + assert_validation_err( + listing, + TradeListingValidationError::ParseError { + error: crate::listing::ListingParseError::InvalidTag("radroots:price".into()), + }, + ); } #[test] @@ -545,7 +641,7 @@ mod tests { assert_validation_err( listing, TradeListingValidationError::ParseError { - error: crate::listing::codec::ListingParseError::InvalidTag("g".to_string()), + error: crate::listing::ListingParseError::InvalidTag("g".to_string()), }, ); } @@ -557,7 +653,7 @@ mod tests { assert_validation_err( listing, TradeListingValidationError::ParseError { - error: crate::listing::codec::ListingParseError::InvalidTag("g".to_string()), + error: crate::listing::ListingParseError::InvalidTag("g".to_string()), }, ); } @@ -581,7 +677,7 @@ mod tests { listing_addr: "addr".into(), }, TradeListingValidationError::ParseError { - error: crate::listing::codec::ListingParseError::InvalidTag("d".into()), + error: crate::listing::ListingParseError::InvalidTag("d".into()), }, TradeListingValidationError::InvalidSeller, TradeListingValidationError::MissingFarmProfile, diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs @@ -30,7 +30,7 @@ const REPLICA_CONTRACT_NAME: &str = "radroots_replica_contract"; const REPLICA_TRANSFER_CONSTANT: &str = "RADROOTS_REPLICA_TRANSFER_VERSION"; const REPLICA_TRANSFER_VERSION: u32 = 2; const VENDORED_WORKSPACE_MEMBER_RELATIVE: &str = "crates/libsqlite3_sys_3_53_3"; -const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 8] = [ +const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 9] = [ ( "contracts/conformance/vectors/blossom/bud11_claims.v1.json", "crates/blossom/tests/fixtures/bud11_claims.v1.json", @@ -52,6 +52,10 @@ const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 8] = [ "crates/event_codec/tests/fixtures/calendar_radroots_profile.v1.json", ), ( + "contracts/conformance/vectors/listing/parse_event.v1.json", + "crates/event_codec/tests/fixtures/listing_parse_event.v1.json", + ), + ( "contracts/conformance/vectors/profile/metadata.v1.json", "crates/event_codec/tests/fixtures/profile_metadata.v1.json", ),