lib

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

validation.rs (19116B)


      1 #![forbid(unsafe_code)]
      2 
      3 #[cfg(not(feature = "std"))]
      4 use alloc::{format, string::String, vec::Vec};
      5 
      6 use radroots_core::{
      7     RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreUnit,
      8 };
      9 use radroots_events::{
     10     RadrootsNostrEvent,
     11     ids::RadrootsListingAddress,
     12     kinds::is_listing_kind,
     13     listing::{
     14         RadrootsListing, RadrootsListingAvailability, RadrootsListingDeliveryMethod,
     15         RadrootsListingLocation,
     16     },
     17     order::RadrootsListingParseError,
     18     trade_validation::RadrootsTradeValidationListingError as TradeListingValidationError,
     19 };
     20 
     21 use crate::listing::codec::listing_from_event_parts;
     22 
     23 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
     24 #[derive(Clone, Debug)]
     25 pub struct RadrootsTradeListing {
     26     pub listing_id: String,
     27     pub listing_addr: String,
     28     pub seller_pubkey: String,
     29     pub title: String,
     30     pub description: String,
     31     pub product_type: String,
     32     pub primary_bin_id: String,
     33     pub bin_quantity: RadrootsCoreQuantity,
     34     pub unit: RadrootsCoreUnit,
     35     pub unit_price: RadrootsCoreMoney,
     36     pub inventory_available: RadrootsCoreDecimal,
     37     pub availability: RadrootsListingAvailability,
     38     pub location: RadrootsListingLocation,
     39     pub delivery_method: RadrootsListingDeliveryMethod,
     40     pub listing: RadrootsListing,
     41 }
     42 
     43 pub fn validate_listing_event(
     44     event: &RadrootsNostrEvent,
     45 ) -> Result<RadrootsTradeListing, TradeListingValidationError> {
     46     if !is_listing_kind(event.kind) {
     47         return Err(TradeListingValidationError::InvalidKind { kind: event.kind });
     48     }
     49 
     50     let listing = listing_from_event_parts(&event.tags, &event.content)
     51         .map_err(|error| TradeListingValidationError::ParseError { error })?;
     52     let listing_id = listing.d_tag.trim().to_string();
     53 
     54     let seller_pubkey = event.author.clone();
     55     if listing.farm.pubkey != seller_pubkey {
     56         return Err(TradeListingValidationError::InvalidSeller);
     57     }
     58     let listing_addr_raw = format!("{}:{}:{}", event.kind, seller_pubkey, listing_id);
     59     let listing_addr = RadrootsListingAddress::parse(&listing_addr_raw)
     60         .map_err(|_| TradeListingValidationError::ParseError {
     61             error: RadrootsListingParseError::InvalidTag("listing_addr".to_string()),
     62         })?
     63         .into_string();
     64 
     65     let title = listing.product.title.trim().to_string();
     66     if title.is_empty() {
     67         return Err(TradeListingValidationError::MissingTitle);
     68     }
     69 
     70     let description = listing
     71         .product
     72         .summary
     73         .as_ref()
     74         .map(|s| s.trim().to_string())
     75         .unwrap_or_default();
     76     if description.is_empty() {
     77         return Err(TradeListingValidationError::MissingDescription);
     78     }
     79 
     80     let product_type = if !listing.product.category.trim().is_empty() {
     81         listing.product.category.trim().to_string()
     82     } else {
     83         listing.product.key.trim().to_string()
     84     };
     85     if product_type.is_empty() {
     86         return Err(TradeListingValidationError::MissingProductType);
     87     }
     88 
     89     if listing.bins.is_empty() {
     90         return Err(TradeListingValidationError::MissingBins);
     91     }
     92     let primary_bin_id = listing.primary_bin_id.trim().to_string();
     93     let primary_bin = listing
     94         .bins
     95         .iter()
     96         .find(|bin| bin.bin_id == primary_bin_id)
     97         .ok_or(TradeListingValidationError::MissingPrimaryBin)?;
     98 
     99     if primary_bin.quantity.amount.is_sign_negative() {
    100         return Err(TradeListingValidationError::InvalidBin);
    101     }
    102     if !primary_bin.quantity.is_canonical() {
    103         return Err(TradeListingValidationError::InvalidBin);
    104     }
    105     if !primary_bin
    106         .price_per_canonical_unit
    107         .is_price_per_canonical_unit()
    108     {
    109         return Err(TradeListingValidationError::InvalidPrice);
    110     }
    111     if primary_bin
    112         .price_per_canonical_unit
    113         .amount
    114         .amount
    115         .is_sign_negative()
    116     {
    117         return Err(TradeListingValidationError::InvalidPrice);
    118     }
    119     if primary_bin.price_per_canonical_unit.quantity.unit != primary_bin.quantity.unit {
    120         return Err(TradeListingValidationError::InvalidPrice);
    121     }
    122 
    123     let inventory_available = listing
    124         .inventory_available
    125         .ok_or(TradeListingValidationError::MissingInventory)?;
    126     if inventory_available.is_sign_negative() {
    127         return Err(TradeListingValidationError::InvalidInventory);
    128     }
    129 
    130     let availability = listing
    131         .availability
    132         .clone()
    133         .ok_or(TradeListingValidationError::MissingAvailability)?;
    134     let location = listing
    135         .location
    136         .clone()
    137         .ok_or(TradeListingValidationError::MissingLocation)?;
    138     let delivery_method = listing
    139         .delivery_method
    140         .clone()
    141         .ok_or(TradeListingValidationError::MissingDeliveryMethod)?;
    142 
    143     Ok(RadrootsTradeListing {
    144         listing_id,
    145         listing_addr,
    146         seller_pubkey,
    147         title,
    148         description,
    149         product_type,
    150         primary_bin_id: primary_bin_id.clone(),
    151         bin_quantity: primary_bin.quantity.clone(),
    152         unit: primary_bin.quantity.unit,
    153         unit_price: primary_bin.price_per_canonical_unit.amount.clone(),
    154         inventory_available,
    155         availability,
    156         location,
    157         delivery_method,
    158         listing,
    159     })
    160 }
    161 
    162 #[cfg(test)]
    163 mod tests {
    164     use super::{TradeListingValidationError, validate_listing_event};
    165     use radroots_core::{
    166         RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
    167         RadrootsCoreQuantityPrice, RadrootsCoreUnit,
    168     };
    169     use radroots_events::{
    170         RadrootsNostrEvent,
    171         farm::RadrootsFarmRef,
    172         ids::{RadrootsDTag, RadrootsInventoryBinId},
    173         kinds::{KIND_LISTING, KIND_LISTING_DRAFT},
    174         listing::{
    175             RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
    176             RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingProduct,
    177         },
    178     };
    179 
    180     const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
    181     const OTHER_SELLER: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
    182 
    183     fn d_tag(raw: &str) -> RadrootsDTag {
    184         RadrootsDTag::parse(raw).expect("d tag")
    185     }
    186 
    187     fn bin_id(raw: &str) -> RadrootsInventoryBinId {
    188         RadrootsInventoryBinId::parse(raw).expect("bin id")
    189     }
    190 
    191     fn base_listing() -> RadrootsListing {
    192         RadrootsListing {
    193             d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"),
    194             published_at: None,
    195             farm: RadrootsFarmRef {
    196                 pubkey: SELLER.into(),
    197                 d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(),
    198             },
    199             product: RadrootsListingProduct {
    200                 key: "coffee".into(),
    201                 title: "Coffee".into(),
    202                 category: "coffee".into(),
    203                 summary: Some("Single origin coffee".into()),
    204                 process: None,
    205                 lot: None,
    206                 location: None,
    207                 profile: None,
    208                 year: None,
    209             },
    210             primary_bin_id: bin_id("bin-1"),
    211             bins: vec![RadrootsListingBin {
    212                 bin_id: bin_id("bin-1"),
    213                 quantity: RadrootsCoreQuantity::new(
    214                     RadrootsCoreDecimal::from(1000u32),
    215                     RadrootsCoreUnit::MassG,
    216                 ),
    217                 price_per_canonical_unit: RadrootsCoreQuantityPrice {
    218                     amount: RadrootsCoreMoney::new(
    219                         RadrootsCoreDecimal::from(20u32),
    220                         RadrootsCoreCurrency::USD,
    221                     ),
    222                     quantity: RadrootsCoreQuantity::new(
    223                         RadrootsCoreDecimal::from(1u32),
    224                         RadrootsCoreUnit::MassG,
    225                     ),
    226                 },
    227                 display_amount: None,
    228                 display_unit: None,
    229                 display_label: None,
    230                 display_price: None,
    231                 display_price_unit: None,
    232             }],
    233             resource_area: None,
    234             plot: None,
    235             discounts: None,
    236             inventory_available: Some(RadrootsCoreDecimal::from(5u32)),
    237             availability: Some(RadrootsListingAvailability::Status {
    238                 status: radroots_events::listing::RadrootsListingStatus::Active,
    239             }),
    240             delivery_method: Some(RadrootsListingDeliveryMethod::Pickup),
    241             location: Some(RadrootsListingLocation {
    242                 primary: "Farm".into(),
    243                 city: None,
    244                 region: None,
    245                 country: None,
    246                 lat: None,
    247                 lng: None,
    248                 geohash: None,
    249             }),
    250             images: None,
    251         }
    252     }
    253 
    254     fn base_event(listing: &RadrootsListing) -> RadrootsNostrEvent {
    255         RadrootsNostrEvent {
    256             id: "evt".into(),
    257             author: SELLER.into(),
    258             created_at: 0,
    259             kind: KIND_LISTING,
    260             tags: vec![
    261                 vec!["d".into(), listing.d_tag.to_string()],
    262                 vec!["p".into(), listing.farm.pubkey.clone()],
    263                 vec![
    264                     "a".into(),
    265                     format!("30340:{}:{}", listing.farm.pubkey, listing.farm.d_tag),
    266                 ],
    267             ],
    268             content: serde_json::to_string(listing).unwrap(),
    269             sig: "sig".into(),
    270         }
    271     }
    272 
    273     fn assert_validation_err(listing: RadrootsListing, expected: TradeListingValidationError) {
    274         let event = base_event(&listing);
    275         let err = validate_listing_event(&event).unwrap_err();
    276         assert_eq!(format!("{err}"), format!("{expected}"));
    277     }
    278 
    279     #[test]
    280     fn validate_listing_ok() {
    281         let listing = base_listing();
    282         let event = base_event(&listing);
    283         assert!(validate_listing_event(&event).is_ok());
    284     }
    285 
    286     #[test]
    287     fn validate_draft_listing_ok() {
    288         let listing = base_listing();
    289         let mut event = base_event(&listing);
    290         event.kind = KIND_LISTING_DRAFT;
    291         let validated = validate_listing_event(&event).expect("draft listing");
    292         assert_eq!(
    293             validated.listing_addr,
    294             format!("30403:{SELLER}:{}", listing.d_tag)
    295         );
    296     }
    297 
    298     #[test]
    299     fn validate_listing_rejects_missing_d_tag() {
    300         let listing = base_listing();
    301         let mut event = base_event(&listing);
    302         event.tags.clear();
    303         let err = validate_listing_event(&event).unwrap_err();
    304         assert_eq!(
    305             err,
    306             TradeListingValidationError::ParseError {
    307                 error: crate::listing::codec::ListingParseError::MissingTag("d".to_string())
    308             }
    309         );
    310     }
    311 
    312     #[test]
    313     fn validate_listing_rejects_invalid_currency() {
    314         let mut event = base_event(&base_listing());
    315         event.content = String::new();
    316         event.tags = vec![
    317             vec!["d".into(), "AAAAAAAAAAAAAAAAAAAAAg".into()],
    318             vec!["p".into(), SELLER.into()],
    319             vec!["a".into(), format!("30340:{SELLER}:AAAAAAAAAAAAAAAAAAAAAA")],
    320             vec!["key".into(), "coffee".into()],
    321             vec!["title".into(), "Coffee".into()],
    322             vec!["category".into(), "coffee".into()],
    323             vec!["summary".into(), "Single origin".into()],
    324             vec![
    325                 "quantity".into(),
    326                 "1".into(),
    327                 "lb".into(),
    328                 "bag".into(),
    329                 "5".into(),
    330             ],
    331             vec![
    332                 "price".into(),
    333                 "20".into(),
    334                 "US".into(),
    335                 "1".into(),
    336                 "lb".into(),
    337             ],
    338             vec![
    339                 "location".into(),
    340                 "Farm".into(),
    341                 "Town".into(),
    342                 "Region".into(),
    343             ],
    344             vec!["status".into(), "active".into()],
    345             vec!["delivery".into(), "pickup".into()],
    346         ];
    347         let err = validate_listing_event(&event).unwrap_err();
    348         assert!(format!("{err:?}").starts_with("ParseError"));
    349     }
    350 
    351     #[test]
    352     fn validate_listing_rejects_mismatched_seller() {
    353         let listing = base_listing();
    354         let mut event = base_event(&listing);
    355         event.author = OTHER_SELLER.into();
    356         let err = validate_listing_event(&event).unwrap_err();
    357         assert_eq!(err, TradeListingValidationError::InvalidSeller);
    358     }
    359 
    360     #[test]
    361     fn validate_listing_rejects_invalid_listing_address_parts() {
    362         let mut listing = base_listing();
    363         listing.farm.pubkey = "not-a-pubkey".into();
    364         let mut event = base_event(&listing);
    365         event.author = "not-a-pubkey".into();
    366         let err = validate_listing_event(&event).unwrap_err();
    367 
    368         assert_eq!(
    369             err,
    370             TradeListingValidationError::ParseError {
    371                 error: crate::listing::codec::ListingParseError::InvalidTag(
    372                     "listing_addr".to_string()
    373                 )
    374             }
    375         );
    376     }
    377 
    378     #[test]
    379     fn validate_listing_rejects_missing_inventory() {
    380         let mut listing = base_listing();
    381         listing.inventory_available = None;
    382         let event = base_event(&listing);
    383         let err = validate_listing_event(&event).unwrap_err();
    384         assert_eq!(err, TradeListingValidationError::MissingInventory);
    385     }
    386 
    387     #[test]
    388     fn validate_listing_rejects_invalid_kind() {
    389         let listing = base_listing();
    390         let mut event = base_event(&listing);
    391         event.kind = 0;
    392         let err = validate_listing_event(&event).unwrap_err();
    393         assert_eq!(err, TradeListingValidationError::InvalidKind { kind: 0 });
    394     }
    395 
    396     #[test]
    397     fn validate_listing_rejects_missing_title() {
    398         let mut listing = base_listing();
    399         listing.product.title = " ".into();
    400         assert_validation_err(listing, TradeListingValidationError::MissingTitle);
    401     }
    402 
    403     #[test]
    404     fn validate_listing_rejects_missing_description() {
    405         let mut listing = base_listing();
    406         listing.product.summary = Some(" ".into());
    407         assert_validation_err(listing, TradeListingValidationError::MissingDescription);
    408     }
    409 
    410     #[test]
    411     fn validate_listing_rejects_missing_product_type() {
    412         let mut listing = base_listing();
    413         listing.product.category = " ".into();
    414         listing.product.key = " ".into();
    415         assert_validation_err(listing, TradeListingValidationError::MissingProductType);
    416     }
    417 
    418     #[test]
    419     fn validate_listing_rejects_missing_bins() {
    420         let mut listing = base_listing();
    421         listing.bins.clear();
    422         assert_validation_err(listing, TradeListingValidationError::MissingBins);
    423     }
    424 
    425     #[test]
    426     fn validate_listing_rejects_missing_primary_bin_id() {
    427         assert!(RadrootsInventoryBinId::parse(" ").is_err());
    428     }
    429 
    430     #[test]
    431     fn validate_listing_rejects_primary_bin_not_found() {
    432         let mut listing = base_listing();
    433         listing.primary_bin_id = bin_id("missing");
    434         assert_validation_err(listing, TradeListingValidationError::MissingPrimaryBin);
    435     }
    436 
    437     #[test]
    438     fn validate_listing_rejects_negative_quantity() {
    439         let mut listing = base_listing();
    440         listing.bins[0].quantity.amount = "-1".parse().unwrap();
    441         assert_validation_err(listing, TradeListingValidationError::InvalidBin);
    442     }
    443 
    444     #[test]
    445     fn validate_listing_rejects_non_canonical_quantity() {
    446         let mut listing = base_listing();
    447         listing.bins[0].quantity.unit = RadrootsCoreUnit::MassKg;
    448         assert_validation_err(listing, TradeListingValidationError::InvalidBin);
    449     }
    450 
    451     #[test]
    452     fn validate_listing_rejects_non_canonical_price_quantity() {
    453         let mut listing = base_listing();
    454         listing.bins[0].price_per_canonical_unit.quantity.unit = RadrootsCoreUnit::MassKg;
    455         assert_validation_err(listing, TradeListingValidationError::InvalidPrice);
    456     }
    457 
    458     #[test]
    459     fn validate_listing_rejects_negative_price_amount() {
    460         let mut listing = base_listing();
    461         listing.bins[0].price_per_canonical_unit.amount.amount = "-1".parse().unwrap();
    462         assert_validation_err(listing, TradeListingValidationError::InvalidPrice);
    463     }
    464 
    465     #[test]
    466     fn validate_listing_rejects_price_unit_mismatch() {
    467         let mut listing = base_listing();
    468         listing.bins[0].price_per_canonical_unit.quantity.unit = RadrootsCoreUnit::Each;
    469         assert_validation_err(listing, TradeListingValidationError::InvalidPrice);
    470     }
    471 
    472     #[test]
    473     fn validate_listing_rejects_negative_inventory() {
    474         let mut listing = base_listing();
    475         listing.inventory_available = Some("-1".parse().unwrap());
    476         assert_validation_err(listing, TradeListingValidationError::InvalidInventory);
    477     }
    478 
    479     #[test]
    480     fn validate_listing_rejects_missing_availability() {
    481         let mut listing = base_listing();
    482         listing.availability = None;
    483         assert_validation_err(listing, TradeListingValidationError::MissingAvailability);
    484     }
    485 
    486     #[test]
    487     fn validate_listing_rejects_missing_location() {
    488         let mut listing = base_listing();
    489         listing.location = None;
    490         assert_validation_err(listing, TradeListingValidationError::MissingLocation);
    491     }
    492 
    493     #[test]
    494     fn validate_listing_rejects_missing_delivery_method() {
    495         let mut listing = base_listing();
    496         listing.delivery_method = None;
    497         assert_validation_err(listing, TradeListingValidationError::MissingDeliveryMethod);
    498     }
    499 
    500     #[test]
    501     fn validation_error_display_covers_all_variants() {
    502         let errors = vec![
    503             TradeListingValidationError::InvalidKind { kind: 9 },
    504             TradeListingValidationError::MissingListingId,
    505             TradeListingValidationError::ListingEventNotFound {
    506                 listing_addr: "addr".into(),
    507             },
    508             TradeListingValidationError::ListingEventFetchFailed {
    509                 listing_addr: "addr".into(),
    510             },
    511             TradeListingValidationError::ParseError {
    512                 error: crate::listing::codec::ListingParseError::InvalidTag("d".into()),
    513             },
    514             TradeListingValidationError::InvalidSeller,
    515             TradeListingValidationError::MissingFarmProfile,
    516             TradeListingValidationError::MissingFarmRecord,
    517             TradeListingValidationError::MissingTitle,
    518             TradeListingValidationError::MissingDescription,
    519             TradeListingValidationError::MissingProductType,
    520             TradeListingValidationError::MissingBins,
    521             TradeListingValidationError::MissingPrimaryBin,
    522             TradeListingValidationError::InvalidBin,
    523             TradeListingValidationError::MissingPrice,
    524             TradeListingValidationError::InvalidPrice,
    525             TradeListingValidationError::MissingInventory,
    526             TradeListingValidationError::InvalidInventory,
    527             TradeListingValidationError::MissingAvailability,
    528             TradeListingValidationError::MissingLocation,
    529             TradeListingValidationError::MissingDeliveryMethod,
    530         ];
    531         for error in errors {
    532             assert!(!error.to_string().trim().is_empty());
    533         }
    534     }
    535 }