lib

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

validation.rs (36820B)


      1 #![forbid(unsafe_code)]
      2 
      3 use std::{
      4     format,
      5     string::{String, ToString},
      6 };
      7 
      8 use radroots_core::{Decimal, Money, Quantity, Unit};
      9 use radroots_event::{
     10     envelope::kind::{KIND_CLASSIFIED_LISTING, is_classified_listing_kind},
     11     farm::location::{has_textual_locality, is_public_geohash5},
     12     id::ClassifiedListingAddress,
     13     listing::classified::{ClassifiedListingPartition, classify_classified_listing_tags},
     14     listing::operational::{
     15         OperationalListing, OperationalListingAvailability, OperationalListingBin,
     16         OperationalListingDeliveryMethod, OperationalListingPublicLocation,
     17     },
     18     trade::validation::OperationalListingValidationError,
     19 };
     20 use radroots_identity::PublicKey;
     21 
     22 use radroots_event_codec::{
     23     decode::operational_listing::operational_listing_from_nostr_event,
     24     verify::RadrootsSignatureVerifiedEvent,
     25 };
     26 
     27 #[derive(Clone, Debug)]
     28 pub struct RadrootsOperationalListingTradeProjection {
     29     pub listing_id: String,
     30     pub listing_addr: ClassifiedListingAddress,
     31     pub seller_pubkey: String,
     32     pub title: String,
     33     pub description: String,
     34     pub product_type: String,
     35     pub primary_bin_id: String,
     36     pub bin_quantity: Quantity,
     37     pub unit: Unit,
     38     pub unit_price: Money,
     39     pub inventory_available: Decimal,
     40     pub availability: OperationalListingAvailability,
     41     pub location: OperationalListingPublicLocation,
     42     pub delivery_method: OperationalListingDeliveryMethod,
     43     pub listing: OperationalListing,
     44 }
     45 
     46 /// Validates a signature-verified Operational Listing event.
     47 ///
     48 /// A plain envelope cannot cross this boundary:
     49 ///
     50 /// ```compile_fail
     51 /// use radroots_event::envelope::EventEnvelope;
     52 /// use radroots_sdk::listing::validate_operational_listing_event;
     53 ///
     54 /// fn validate_unverified(event: &EventEnvelope) {
     55 ///     let _ = validate_operational_listing_event(event);
     56 /// }
     57 /// ```
     58 pub fn validate_operational_listing_event(
     59     verified_event: &RadrootsSignatureVerifiedEvent,
     60 ) -> Result<RadrootsOperationalListingTradeProjection, OperationalListingValidationError> {
     61     let event = verified_event.event();
     62     if !is_classified_listing_kind(event.kind_u32()) {
     63         return Err(OperationalListingValidationError::InvalidKind {
     64             kind: event.kind_u32(),
     65         });
     66     }
     67     if classify_classified_listing_tags(event.tags())
     68         != ClassifiedListingPartition::OperationalListing
     69     {
     70         return Err(OperationalListingValidationError::InvalidProfile);
     71     }
     72 
     73     let listing = operational_listing_from_nostr_event(event)
     74         .map_err(|error| OperationalListingValidationError::ParseError { error })?;
     75     validate_operational_listing_model(listing, event.author())
     76 }
     77 
     78 /// Validates the trade semantics of an unsigned Operational Listing model.
     79 ///
     80 /// The seller is typed independently from the model because it is the
     81 /// authority against which the listing's farm identity is checked. This
     82 /// function does not perform event-kind, profile, decoding, or signature
     83 /// checks; callers handling Nostr events must use
     84 /// [`validate_operational_listing_event`] instead.
     85 pub fn validate_operational_listing_model(
     86     listing: OperationalListing,
     87     seller_pubkey: &PublicKey,
     88 ) -> Result<RadrootsOperationalListingTradeProjection, OperationalListingValidationError> {
     89     let listing_id = listing.d_tag.as_str().trim().to_string();
     90 
     91     if listing.farm.pubkey != seller_pubkey.to_hex() {
     92         return Err(OperationalListingValidationError::InvalidSeller);
     93     }
     94     let listing_addr_raw = format!("{KIND_CLASSIFIED_LISTING}:{}:{listing_id}", seller_pubkey);
     95     let listing_addr = ClassifiedListingAddress::parse(&listing_addr_raw)
     96         .expect("validated listing identity must form a listing address");
     97 
     98     let title = listing.product.title.trim().to_string();
     99     if title.is_empty() {
    100         return Err(OperationalListingValidationError::MissingTitle);
    101     }
    102 
    103     let description = listing
    104         .product
    105         .summary
    106         .as_ref()
    107         .map(|s| s.trim().to_string())
    108         .unwrap_or_default();
    109     if description.is_empty() {
    110         return Err(OperationalListingValidationError::MissingDescription);
    111     }
    112 
    113     let product_type = if !listing.product.category.trim().is_empty() {
    114         listing.product.category.trim().to_string()
    115     } else {
    116         listing.product.key.trim().to_string()
    117     };
    118     if product_type.is_empty() {
    119         return Err(OperationalListingValidationError::MissingProductType);
    120     }
    121 
    122     if listing.bins.is_empty() {
    123         return Err(OperationalListingValidationError::MissingBins);
    124     }
    125     let primary_bin_id = listing.primary_bin_id.as_str().trim().to_string();
    126     let primary_bin_index = listing
    127         .bins
    128         .iter()
    129         .position(|bin| bin.bin_id.as_str() == primary_bin_id)
    130         .ok_or(OperationalListingValidationError::MissingPrimaryBin)?;
    131     for (index, bin) in listing.bins.iter().enumerate() {
    132         if listing.bins[..index]
    133             .iter()
    134             .any(|seen| seen.bin_id == bin.bin_id)
    135         {
    136             return Err(OperationalListingValidationError::InvalidBin);
    137         }
    138     }
    139     let primary_bin = &listing.bins[primary_bin_index];
    140 
    141     validate_listing_bin(primary_bin)?;
    142     for (index, bin) in listing.bins.iter().enumerate() {
    143         if index != primary_bin_index {
    144             validate_listing_bin(bin)?;
    145         }
    146     }
    147 
    148     let inventory_available = listing
    149         .inventory_available
    150         .ok_or(OperationalListingValidationError::MissingInventory)?;
    151     if inventory_available.is_sign_negative() {
    152         return Err(OperationalListingValidationError::InvalidInventory);
    153     }
    154 
    155     let availability = listing
    156         .availability
    157         .clone()
    158         .ok_or(OperationalListingValidationError::MissingAvailability)?;
    159     let location = listing
    160         .location
    161         .clone()
    162         .ok_or(OperationalListingValidationError::MissingLocation)?;
    163     if !has_textual_locality(
    164         &location.primary,
    165         location.city.as_deref(),
    166         location.region.as_deref(),
    167         location.country.as_deref(),
    168     ) {
    169         return Err(OperationalListingValidationError::MissingLocationLocality);
    170     }
    171     validate_listing_location_geohash(&location.geohash)?;
    172     let delivery_method = listing
    173         .delivery_method
    174         .clone()
    175         .ok_or(OperationalListingValidationError::MissingDeliveryMethod)?;
    176 
    177     Ok(RadrootsOperationalListingTradeProjection {
    178         listing_id,
    179         listing_addr,
    180         seller_pubkey: seller_pubkey.to_hex(),
    181         title,
    182         description,
    183         product_type,
    184         primary_bin_id: primary_bin_id.clone(),
    185         bin_quantity: primary_bin.quantity.clone(),
    186         unit: primary_bin.quantity.unit(),
    187         unit_price: primary_bin.price_per_canonical_unit.amount().clone(),
    188         inventory_available,
    189         availability,
    190         location,
    191         delivery_method,
    192         listing,
    193     })
    194 }
    195 
    196 fn validate_listing_bin(
    197     bin: &OperationalListingBin,
    198 ) -> Result<(), OperationalListingValidationError> {
    199     if bin.quantity.amount().is_sign_negative() || !bin.quantity.is_canonical() {
    200         return Err(OperationalListingValidationError::InvalidBin);
    201     }
    202     if !bin.price_per_canonical_unit.is_price_per_canonical_unit()
    203         || bin
    204             .price_per_canonical_unit
    205             .amount()
    206             .amount()
    207             .is_sign_negative()
    208         || bin.price_per_canonical_unit.quantity().unit() != bin.quantity.unit()
    209     {
    210         return Err(OperationalListingValidationError::InvalidPrice);
    211     }
    212     Ok(())
    213 }
    214 
    215 fn validate_listing_location_geohash(
    216     geohash: &str,
    217 ) -> Result<(), OperationalListingValidationError> {
    218     if geohash.trim().is_empty() {
    219         return Err(OperationalListingValidationError::MissingLocationGeohash);
    220     }
    221     if !is_public_geohash5(geohash) {
    222         return Err(OperationalListingValidationError::InvalidLocationGeohash);
    223     }
    224     Ok(())
    225 }
    226 
    227 #[cfg(all(test, feature = "local-signing"))]
    228 mod tests {
    229     const FIXTURE_ALICE_SECRET_KEY_HEX: &str =
    230         "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5";
    231     const FIXTURE_ALICE_PUBLIC_KEY_HEX: &str =
    232         "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df";
    233     const FIXTURE_BOB_SECRET_KEY_HEX: &str =
    234         "59392e9068f66431b12f70218fb61281cb6b433d7f27c55d61f1a63fe1a96ff8";
    235     const FIXTURE_BOB_PUBLIC_KEY_HEX: &str =
    236         "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af";
    237     use super::{
    238         OperationalListingValidationError, validate_operational_listing_event,
    239         validate_operational_listing_model,
    240     };
    241     use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
    242     use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit};
    243     use radroots_event::{
    244         envelope::EventEnvelope,
    245         envelope::EventEnvelopeParts,
    246         envelope::kind::KIND_CLASSIFIED_LISTING,
    247         farm::FarmRef,
    248         id::{DTag, InventoryBinId},
    249         listing::operational::{
    250             OperationalListing, OperationalListingAvailability, OperationalListingBin,
    251             OperationalListingDeliveryMethod, OperationalListingProduct,
    252             OperationalListingPublicLocation,
    253         },
    254         wire::Nip01EventWire,
    255     };
    256     use radroots_event_codec::verify::{RadrootsSignatureVerifiedEvent, verify_nip01_event};
    257     use radroots_identity::PublicKey;
    258 
    259     const SELLER: &str = FIXTURE_ALICE_PUBLIC_KEY_HEX;
    260     const OTHER_SELLER: &str = FIXTURE_BOB_PUBLIC_KEY_HEX;
    261 
    262     fn d_tag(raw: &str) -> DTag {
    263         DTag::parse(raw).expect("d tag")
    264     }
    265 
    266     fn bin_id(raw: &str) -> InventoryBinId {
    267         InventoryBinId::parse(raw).expect("bin id")
    268     }
    269 
    270     fn seller_pubkey() -> PublicKey {
    271         PublicKey::from_hex(SELLER).expect("seller pubkey")
    272     }
    273 
    274     fn other_seller_pubkey() -> PublicKey {
    275         PublicKey::from_hex(OTHER_SELLER).expect("other seller pubkey")
    276     }
    277 
    278     fn base_listing() -> OperationalListing {
    279         OperationalListing {
    280             d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"),
    281             published_at: None,
    282             farm: FarmRef {
    283                 pubkey: SELLER.into(),
    284                 d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(),
    285             },
    286             product: OperationalListingProduct {
    287                 key: "coffee".into(),
    288                 title: "Coffee".into(),
    289                 category: "coffee".into(),
    290                 summary: Some("Single origin coffee".into()),
    291                 process: None,
    292                 lot: None,
    293                 location: None,
    294                 profile: None,
    295                 year: None,
    296             },
    297             primary_bin_id: bin_id("bin-1"),
    298             bins: vec![OperationalListingBin {
    299                 bin_id: bin_id("bin-1"),
    300                 quantity: Quantity::try_new(Decimal::from(1000u32), Unit::MassG).unwrap(),
    301                 price_per_canonical_unit: QuantityPrice::try_new(
    302                     Money::try_new(Decimal::from(20u32), Currency::USD).unwrap(),
    303                     Quantity::try_new(Decimal::from(1u32), Unit::MassG).unwrap(),
    304                 )
    305                 .unwrap(),
    306                 display_amount: None,
    307                 display_unit: None,
    308                 display_label: None,
    309                 display_price: None,
    310                 display_price_unit: None,
    311             }],
    312             resource_area: None,
    313             plot: None,
    314             discounts: None,
    315             inventory_available: Some(Decimal::from(5u32)),
    316             availability: Some(OperationalListingAvailability::Status {
    317                 status: radroots_event::listing::operational::OperationalListingStatus::Active,
    318             }),
    319             delivery_method: Some(OperationalListingDeliveryMethod::Pickup),
    320             location: Some(OperationalListingPublicLocation {
    321                 primary: "Farm".into(),
    322                 city: Some("Town".into()),
    323                 region: Some("Region".into()),
    324                 country: Some("US".into()),
    325                 geohash: "9q8yy".into(),
    326             }),
    327             images: None,
    328         }
    329     }
    330 
    331     fn base_event(listing: &OperationalListing) -> RadrootsSignatureVerifiedEvent {
    332         let mut tags = vec![
    333             vec!["d".into(), listing.d_tag.to_string()],
    334             vec!["p".into(), listing.farm.pubkey.clone()],
    335             vec![
    336                 "a".into(),
    337                 format!("30340:{}:{}", listing.farm.pubkey, listing.farm.d_tag),
    338             ],
    339             vec!["key".into(), listing.product.key.clone()],
    340             vec!["title".into(), listing.product.title.clone()],
    341             vec!["category".into(), listing.product.category.clone()],
    342             vec![
    343                 "summary".into(),
    344                 listing.product.summary.clone().unwrap_or_default(),
    345             ],
    346             vec![
    347                 "radroots:primary_bin".into(),
    348                 listing.primary_bin_id.to_string(),
    349             ],
    350         ];
    351         for bin in &listing.bins {
    352             tags.push(vec![
    353                 "radroots:bin".into(),
    354                 bin.bin_id.to_string(),
    355                 bin.quantity.amount().to_string(),
    356                 bin.quantity.unit().code().to_string(),
    357             ]);
    358             tags.push(vec![
    359                 "radroots:price".into(),
    360                 bin.bin_id.to_string(),
    361                 bin.price_per_canonical_unit.amount().amount().to_string(),
    362                 bin.price_per_canonical_unit
    363                     .amount()
    364                     .currency()
    365                     .as_str()
    366                     .to_string(),
    367                 bin.price_per_canonical_unit.quantity().amount().to_string(),
    368                 bin.price_per_canonical_unit
    369                     .quantity()
    370                     .unit()
    371                     .code()
    372                     .to_string(),
    373             ]);
    374         }
    375         if let Some(inventory) = listing.inventory_available {
    376             tags.push(vec!["inventory".into(), inventory.to_string()]);
    377         }
    378         if let Some(availability) = &listing.availability {
    379             match availability {
    380                 OperationalListingAvailability::Status { status } => tags.push(vec![
    381                     "status".into(),
    382                     match status {
    383                         radroots_event::listing::operational::OperationalListingStatus::Active => {
    384                             "active".into()
    385                         }
    386                         radroots_event::listing::operational::OperationalListingStatus::Sold => {
    387                             "sold".into()
    388                         }
    389                         radroots_event::listing::operational::OperationalListingStatus::Other {
    390                             value,
    391                         } => value.clone(),
    392                     },
    393                 ]),
    394                 OperationalListingAvailability::Window { start, end } => {
    395                     if let Some(start) = start {
    396                         tags.push(vec![
    397                             "radroots:availability_start".into(),
    398                             start.to_string(),
    399                         ]);
    400                     }
    401                     if let Some(end) = end {
    402                         tags.push(vec!["expires_at".into(), end.to_string()]);
    403                     }
    404                 }
    405             }
    406         }
    407         if let Some(delivery) = &listing.delivery_method {
    408             let mut tag = vec!["delivery".into()];
    409             match delivery {
    410                 OperationalListingDeliveryMethod::Pickup => tag.push("pickup".into()),
    411                 OperationalListingDeliveryMethod::LocalDelivery => {
    412                     tag.push("local_delivery".into())
    413                 }
    414                 OperationalListingDeliveryMethod::Shipping => tag.push("shipping".into()),
    415                 OperationalListingDeliveryMethod::Other { method } => {
    416                     tag.push("other".into());
    417                     tag.push(method.clone());
    418                 }
    419             }
    420             tags.push(tag);
    421         }
    422         if let Some(location) = &listing.location {
    423             tags.push(vec![
    424                 "location".into(),
    425                 location.primary.clone(),
    426                 location.city.clone().unwrap_or_default(),
    427                 location.region.clone().unwrap_or_default(),
    428                 location.country.clone().unwrap_or_default(),
    429             ]);
    430             tags.push(vec!["g".into(), location.geohash.clone()]);
    431         }
    432 
    433         event_with_parts(SELLER, KIND_CLASSIFIED_LISTING, tags, String::new())
    434     }
    435 
    436     fn event_with_parts(
    437         author: &str,
    438         kind: u32,
    439         tags: Vec<Vec<String>>,
    440         content: String,
    441     ) -> RadrootsSignatureVerifiedEvent {
    442         let secret = match author {
    443             SELLER => FIXTURE_ALICE_SECRET_KEY_HEX,
    444             OTHER_SELLER => FIXTURE_BOB_SECRET_KEY_HEX,
    445             _ => panic!("test author must be an approved fixture identity"),
    446         };
    447         let keys = Keys::parse(secret).expect("fixture signing key");
    448         let tags = tags
    449             .into_iter()
    450             .map(|tag| Tag::parse(tag).expect("test tag"))
    451             .collect::<Vec<_>>();
    452         let event = EventBuilder::new(
    453             Kind::Custom(u16::try_from(kind).expect("test kind")),
    454             content,
    455         )
    456         .tags(tags)
    457         .allow_self_tagging()
    458         .custom_created_at(Timestamp::from_secs(1))
    459         .sign_with_keys(&keys)
    460         .expect("signed test event");
    461         let raw_json = event.as_json();
    462         let envelope = Nip01EventWire::parse_json(raw_json.as_str())
    463             .expect("canonical event wire")
    464             .into_envelope()
    465             .expect("event envelope");
    466         verify_nip01_event(envelope).expect("verified test event")
    467     }
    468 
    469     fn assert_validation_err(
    470         listing: OperationalListing,
    471         expected: OperationalListingValidationError,
    472     ) {
    473         let event = base_event(&listing);
    474         let err = validate_operational_listing_event(&event).unwrap_err();
    475         assert_eq!(format!("{err}"), format!("{expected}"));
    476     }
    477 
    478     fn assert_secondary_bin_model_error(
    479         update: impl FnOnce(&mut OperationalListingBin),
    480         expected: OperationalListingValidationError,
    481     ) {
    482         let mut listing = listing_with_secondary_bin();
    483         update(&mut listing.bins[1]);
    484 
    485         assert_eq!(
    486             validate_operational_listing_model(listing, &seller_pubkey())
    487                 .expect_err("invalid secondary bin"),
    488             expected
    489         );
    490     }
    491 
    492     fn listing_with_secondary_bin() -> OperationalListing {
    493         let mut listing = base_listing();
    494         let mut secondary_bin = listing.bins[0].clone();
    495         secondary_bin.bin_id = bin_id("bin-2");
    496         listing.bins.push(secondary_bin);
    497         listing
    498     }
    499 
    500     #[test]
    501     fn validate_listing_ok() {
    502         let listing = base_listing();
    503         let event = base_event(&listing);
    504         assert!(validate_operational_listing_event(&event).is_ok());
    505     }
    506 
    507     #[test]
    508     #[cfg(any())]
    509     fn model_and_verified_event_validation_return_the_same_projection() {
    510         let listing = listing_with_secondary_bin();
    511         let event_projection =
    512             validate_operational_listing_event(&base_event(&listing)).expect("event projection");
    513         let model_projection = validate_operational_listing_model(listing, &seller_pubkey())
    514             .expect("model projection");
    515 
    516         assert_eq!(
    517             serde_json::to_value(model_projection).expect("model projection JSON"),
    518             serde_json::to_value(event_projection).expect("event projection JSON")
    519         );
    520     }
    521 
    522     #[test]
    523     fn model_and_verified_event_validation_return_the_same_semantic_errors() {
    524         let mut listing = base_listing();
    525         listing.inventory_available = None;
    526         let event_error = validate_operational_listing_event(&base_event(&listing))
    527             .expect_err("event inventory error");
    528         let model_error = validate_operational_listing_model(listing, &seller_pubkey())
    529             .expect_err("model inventory error");
    530         assert_eq!(model_error, event_error);
    531 
    532         let listing = base_listing();
    533         let event = event_with_parts(
    534             OTHER_SELLER,
    535             KIND_CLASSIFIED_LISTING,
    536             base_event(&listing).event().tags_as_vec(),
    537             String::new(),
    538         );
    539         let event_error =
    540             validate_operational_listing_event(&event).expect_err("event seller error");
    541         let model_error = validate_operational_listing_model(listing, &other_seller_pubkey())
    542             .expect_err("model seller error");
    543         assert_eq!(model_error, event_error);
    544     }
    545 
    546     #[test]
    547     fn model_validation_reports_errors_before_event_encoding() {
    548         let mut listing = base_listing();
    549         listing.bins.clear();
    550         assert_eq!(
    551             validate_operational_listing_model(listing, &seller_pubkey())
    552                 .expect_err("missing bins"),
    553             OperationalListingValidationError::MissingBins
    554         );
    555 
    556         let mut listing = base_listing();
    557         listing.primary_bin_id = bin_id("missing");
    558         assert_eq!(
    559             validate_operational_listing_model(listing, &seller_pubkey())
    560                 .expect_err("missing primary bin"),
    561             OperationalListingValidationError::MissingPrimaryBin
    562         );
    563 
    564         let mut listing = base_listing();
    565         listing.bins.push(listing.bins[0].clone());
    566         assert_eq!(
    567             validate_operational_listing_model(listing, &seller_pubkey())
    568                 .expect_err("duplicate bin ID"),
    569             OperationalListingValidationError::InvalidBin
    570         );
    571 
    572         let mut listing = base_listing();
    573         listing.location.as_mut().expect("location").geohash = " ".into();
    574         assert_eq!(
    575             validate_operational_listing_model(listing, &seller_pubkey())
    576                 .expect_err("missing geohash"),
    577             OperationalListingValidationError::MissingLocationGeohash
    578         );
    579 
    580         let mut listing = base_listing();
    581         listing.location.as_mut().expect("location").geohash = "9q8yyz".into();
    582         assert_eq!(
    583             validate_operational_listing_model(listing, &seller_pubkey())
    584                 .expect_err("invalid geohash"),
    585             OperationalListingValidationError::InvalidLocationGeohash
    586         );
    587     }
    588 
    589     #[test]
    590     fn model_validation_rejects_invalid_secondary_bin_quantities() {
    591         assert_secondary_bin_model_error(
    592             |bin| bin.quantity = Quantity::try_new(Decimal::ONE, Unit::MassKg).unwrap(),
    593             OperationalListingValidationError::InvalidBin,
    594         );
    595     }
    596 
    597     #[test]
    598     fn model_validation_rejects_invalid_secondary_bin_prices() {
    599         assert_secondary_bin_model_error(
    600             |bin| {
    601                 bin.price_per_canonical_unit = QuantityPrice::try_new(
    602                     bin.price_per_canonical_unit.amount().clone(),
    603                     Quantity::try_new(Decimal::from(2u32), Unit::MassG).unwrap(),
    604                 )
    605                 .unwrap();
    606             },
    607             OperationalListingValidationError::InvalidPrice,
    608         );
    609         assert_secondary_bin_model_error(
    610             |bin| {
    611                 bin.price_per_canonical_unit = QuantityPrice::try_new(
    612                     bin.price_per_canonical_unit.amount().clone(),
    613                     Quantity::try_new(Decimal::ONE, Unit::MassKg).unwrap(),
    614                 )
    615                 .unwrap();
    616             },
    617             OperationalListingValidationError::InvalidPrice,
    618         );
    619         assert_secondary_bin_model_error(
    620             |bin| {
    621                 bin.price_per_canonical_unit = QuantityPrice::try_new(
    622                     bin.price_per_canonical_unit.amount().clone(),
    623                     Quantity::try_new(Decimal::ONE, Unit::Each).unwrap(),
    624                 )
    625                 .unwrap();
    626             },
    627             OperationalListingValidationError::InvalidPrice,
    628         );
    629     }
    630 
    631     #[test]
    632     fn validate_listing_rejects_retired_kind() {
    633         let listing = base_listing();
    634         let event = event_with_parts(
    635             SELLER,
    636             30403,
    637             base_event(&listing).event().tags_as_vec(),
    638             String::new(),
    639         );
    640         let err = validate_operational_listing_event(&event).unwrap_err();
    641         assert_eq!(
    642             err,
    643             OperationalListingValidationError::InvalidKind { kind: 30403 }
    644         );
    645     }
    646 
    647     #[test]
    648     fn validate_listing_rejects_missing_d_tag() {
    649         let event = event_with_parts(SELLER, KIND_CLASSIFIED_LISTING, Vec::new(), String::new());
    650         let err = validate_operational_listing_event(&event).unwrap_err();
    651         assert_eq!(err, OperationalListingValidationError::InvalidProfile);
    652     }
    653 
    654     #[test]
    655     fn validate_listing_rejects_invalid_currency() {
    656         let event = event_with_parts(
    657             SELLER,
    658             KIND_CLASSIFIED_LISTING,
    659             vec![
    660                 vec!["d".into(), "AAAAAAAAAAAAAAAAAAAAAg".into()],
    661                 vec!["p".into(), SELLER.into()],
    662                 vec!["a".into(), format!("30340:{SELLER}:AAAAAAAAAAAAAAAAAAAAAA")],
    663                 vec!["key".into(), "coffee".into()],
    664                 vec!["title".into(), "Coffee".into()],
    665                 vec!["category".into(), "coffee".into()],
    666                 vec!["summary".into(), "Single origin".into()],
    667                 vec!["radroots:primary_bin".into(), "bin-1".into()],
    668                 vec![
    669                     "quantity".into(),
    670                     "1".into(),
    671                     "lb".into(),
    672                     "bag".into(),
    673                     "5".into(),
    674                 ],
    675                 vec![
    676                     "price".into(),
    677                     "20".into(),
    678                     "US".into(),
    679                     "1".into(),
    680                     "lb".into(),
    681                 ],
    682                 vec![
    683                     "location".into(),
    684                     "Farm".into(),
    685                     "Town".into(),
    686                     "Region".into(),
    687                 ],
    688                 vec!["status".into(), "active".into()],
    689                 vec!["delivery".into(), "pickup".into()],
    690             ],
    691             String::new(),
    692         );
    693         let err = validate_operational_listing_event(&event).unwrap_err();
    694         assert!(format!("{err:?}").starts_with("ParseError"));
    695     }
    696 
    697     #[test]
    698     fn validate_listing_rejects_mismatched_seller() {
    699         let listing = base_listing();
    700         let event = event_with_parts(
    701             OTHER_SELLER,
    702             KIND_CLASSIFIED_LISTING,
    703             base_event(&listing).event().tags_as_vec(),
    704             String::new(),
    705         );
    706         let err = validate_operational_listing_event(&event).unwrap_err();
    707         assert_eq!(err, OperationalListingValidationError::InvalidSeller);
    708     }
    709 
    710     #[test]
    711     fn validate_listing_rejects_missing_inventory() {
    712         let mut listing = base_listing();
    713         listing.inventory_available = None;
    714         let event = base_event(&listing);
    715         let err = validate_operational_listing_event(&event).unwrap_err();
    716         assert_eq!(err, OperationalListingValidationError::MissingInventory);
    717     }
    718 
    719     #[test]
    720     fn validate_listing_rejects_invalid_kind() {
    721         let listing = base_listing();
    722         let event = event_with_parts(
    723             SELLER,
    724             0,
    725             base_event(&listing).event().tags_as_vec(),
    726             String::new(),
    727         );
    728         let err = validate_operational_listing_event(&event).unwrap_err();
    729         assert_eq!(
    730             err,
    731             OperationalListingValidationError::InvalidKind { kind: 0 }
    732         );
    733     }
    734 
    735     #[test]
    736     fn validate_listing_rejects_missing_title() {
    737         let mut listing = base_listing();
    738         listing.product.title = " ".into();
    739         assert_validation_err(listing, OperationalListingValidationError::MissingTitle);
    740     }
    741 
    742     #[test]
    743     fn tampered_envelope_cannot_reach_operational_validation() {
    744         let verified = base_event(&base_listing());
    745         let event = verified.into_event();
    746         let tampered = EventEnvelope::new(EventEnvelopeParts {
    747             id: event.id_hex(),
    748             author: event.author().to_hex().to_owned(),
    749             created_at: event.created_at_u64(),
    750             kind: event.kind_u32(),
    751             tags: event.tags_as_vec(),
    752             content: "tampered".to_owned(),
    753             sig: event.signature_hex(),
    754         })
    755         .expect("well-shaped tampered envelope");
    756 
    757         assert!(verify_nip01_event(tampered).is_err());
    758     }
    759 
    760     #[test]
    761     fn validate_listing_rejects_missing_description() {
    762         let mut listing = base_listing();
    763         listing.product.summary = Some(" ".into());
    764         assert_validation_err(
    765             listing,
    766             OperationalListingValidationError::MissingDescription,
    767         );
    768     }
    769 
    770     #[test]
    771     fn validate_listing_rejects_missing_product_type() {
    772         let mut listing = base_listing();
    773         listing.product.category = " ".into();
    774         listing.product.key = " ".into();
    775         assert_validation_err(
    776             listing,
    777             OperationalListingValidationError::MissingProductType,
    778         );
    779     }
    780 
    781     #[test]
    782     fn validate_listing_rejects_missing_bins() {
    783         let mut listing = base_listing();
    784         listing.bins.clear();
    785         assert_validation_err(
    786             listing,
    787             OperationalListingValidationError::ParseError {
    788                 error:
    789                     radroots_event::listing::operational::OperationalListingParseError::InvalidTag(
    790                         "radroots:primary_bin".into(),
    791                     ),
    792             },
    793         );
    794     }
    795 
    796     #[test]
    797     fn validate_listing_rejects_missing_primary_bin_id() {
    798         assert!(InventoryBinId::parse(" ").is_err());
    799     }
    800 
    801     #[test]
    802     fn validate_listing_rejects_primary_bin_not_found() {
    803         let mut listing = base_listing();
    804         listing.primary_bin_id = bin_id("missing");
    805         assert_validation_err(
    806             listing,
    807             OperationalListingValidationError::ParseError {
    808                 error:
    809                     radroots_event::listing::operational::OperationalListingParseError::InvalidTag(
    810                         "radroots:primary_bin".into(),
    811                     ),
    812             },
    813         );
    814     }
    815 
    816     #[test]
    817     fn validate_listing_rejects_non_canonical_quantity() {
    818         let mut listing = base_listing();
    819         listing.bins[0].quantity = Quantity::try_new(Decimal::ONE, Unit::MassKg).unwrap();
    820         assert_validation_err(
    821             listing,
    822             OperationalListingValidationError::ParseError {
    823                 error:
    824                     radroots_event::listing::operational::OperationalListingParseError::InvalidTag(
    825                         "radroots:bin".into(),
    826                     ),
    827             },
    828         );
    829     }
    830 
    831     #[test]
    832     fn validate_listing_rejects_non_canonical_price_quantity() {
    833         let mut listing = base_listing();
    834         listing.bins[0].price_per_canonical_unit = QuantityPrice::try_new(
    835             listing.bins[0].price_per_canonical_unit.amount().clone(),
    836             Quantity::try_new(Decimal::ONE, Unit::MassKg).unwrap(),
    837         )
    838         .unwrap();
    839         assert_validation_err(
    840             listing,
    841             OperationalListingValidationError::ParseError {
    842                 error:
    843                     radroots_event::listing::operational::OperationalListingParseError::InvalidTag(
    844                         "radroots:price".into(),
    845                     ),
    846             },
    847         );
    848     }
    849 
    850     #[test]
    851     fn validate_listing_rejects_price_unit_mismatch() {
    852         let mut listing = base_listing();
    853         listing.bins[0].price_per_canonical_unit = QuantityPrice::try_new(
    854             listing.bins[0].price_per_canonical_unit.amount().clone(),
    855             Quantity::try_new(Decimal::ONE, Unit::Each).unwrap(),
    856         )
    857         .unwrap();
    858         assert_validation_err(
    859             listing,
    860             OperationalListingValidationError::ParseError {
    861                 error:
    862                     radroots_event::listing::operational::OperationalListingParseError::InvalidTag(
    863                         "radroots:price".into(),
    864                     ),
    865             },
    866         );
    867     }
    868 
    869     #[test]
    870     fn validate_listing_rejects_negative_inventory() {
    871         let mut listing = base_listing();
    872         listing.inventory_available = Some("-1".parse().unwrap());
    873         assert_validation_err(listing, OperationalListingValidationError::InvalidInventory);
    874     }
    875 
    876     #[test]
    877     fn validate_listing_rejects_missing_availability() {
    878         let mut listing = base_listing();
    879         listing.availability = None;
    880         assert_validation_err(
    881             listing,
    882             OperationalListingValidationError::MissingAvailability,
    883         );
    884     }
    885 
    886     #[test]
    887     fn validate_listing_rejects_missing_location() {
    888         let mut listing = base_listing();
    889         listing.location = None;
    890         assert_validation_err(listing, OperationalListingValidationError::MissingLocation);
    891     }
    892 
    893     #[test]
    894     fn validate_listing_rejects_missing_location_locality() {
    895         let mut listing = base_listing();
    896         let location = listing.location.as_mut().expect("location");
    897         location.city = None;
    898         location.region = None;
    899         location.country = None;
    900         assert_validation_err(
    901             listing,
    902             OperationalListingValidationError::MissingLocationLocality,
    903         );
    904     }
    905 
    906     #[test]
    907     fn validate_listing_rejects_missing_location_geohash() {
    908         let mut listing = base_listing();
    909         listing.location.as_mut().expect("location").geohash = " ".into();
    910         assert_validation_err(
    911             listing,
    912             OperationalListingValidationError::ParseError {
    913                 error:
    914                     radroots_event::listing::operational::OperationalListingParseError::InvalidTag(
    915                         "g".to_string(),
    916                     ),
    917             },
    918         );
    919     }
    920 
    921     #[test]
    922     fn validate_listing_rejects_invalid_location_geohash() {
    923         let mut listing = base_listing();
    924         listing.location.as_mut().expect("location").geohash = "9q8yyz".into();
    925         assert_validation_err(
    926             listing,
    927             OperationalListingValidationError::ParseError {
    928                 error:
    929                     radroots_event::listing::operational::OperationalListingParseError::InvalidTag(
    930                         "g".to_string(),
    931                     ),
    932             },
    933         );
    934     }
    935 
    936     #[test]
    937     fn validate_listing_rejects_missing_delivery_method() {
    938         let mut listing = base_listing();
    939         listing.delivery_method = None;
    940         assert_validation_err(
    941             listing,
    942             OperationalListingValidationError::MissingDeliveryMethod,
    943         );
    944     }
    945 
    946     #[test]
    947     fn validation_error_display_covers_all_variants() {
    948         let errors = vec![
    949             OperationalListingValidationError::InvalidKind { kind: 9 },
    950             OperationalListingValidationError::InvalidProfile,
    951             OperationalListingValidationError::MissingListingId,
    952             OperationalListingValidationError::ListingEventNotFound {
    953                 listing_addr: "addr".into(),
    954             },
    955             OperationalListingValidationError::ListingEventFetchFailed {
    956                 listing_addr: "addr".into(),
    957             },
    958             OperationalListingValidationError::ParseError {
    959                 error:
    960                     radroots_event::listing::operational::OperationalListingParseError::InvalidTag(
    961                         "d".into(),
    962                     ),
    963             },
    964             OperationalListingValidationError::InvalidSeller,
    965             OperationalListingValidationError::MissingFarmProfile,
    966             OperationalListingValidationError::MissingFarmRecord,
    967             OperationalListingValidationError::MissingTitle,
    968             OperationalListingValidationError::MissingDescription,
    969             OperationalListingValidationError::MissingProductType,
    970             OperationalListingValidationError::MissingBins,
    971             OperationalListingValidationError::MissingPrimaryBin,
    972             OperationalListingValidationError::InvalidBin,
    973             OperationalListingValidationError::MissingPrice,
    974             OperationalListingValidationError::InvalidPrice,
    975             OperationalListingValidationError::MissingInventory,
    976             OperationalListingValidationError::InvalidInventory,
    977             OperationalListingValidationError::MissingAvailability,
    978             OperationalListingValidationError::MissingLocation,
    979             OperationalListingValidationError::MissingLocationLocality,
    980             OperationalListingValidationError::MissingLocationGeohash,
    981             OperationalListingValidationError::InvalidLocationGeohash,
    982             OperationalListingValidationError::MissingDeliveryMethod,
    983         ];
    984         for error in errors {
    985             assert!(!error.to_string().trim().is_empty());
    986         }
    987     }
    988 }