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 }