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 }