operational_listing.rs (54342B)
1 #![cfg(feature = "json")] 2 3 use radroots_core::pricing::{Discount, DiscountScope, DiscountThreshold, DiscountValue}; 4 use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit}; 5 use radroots_event::{ 6 envelope::EventEnvelope, 7 envelope::EventEnvelopeParts, 8 envelope::kind::{ 9 KIND_CLASSIFIED_LISTING, KIND_FARM, KIND_PLOT, KIND_POST, KIND_RESOURCE_AREA, 10 }, 11 farm::FarmRef, 12 farm::plot::PlotRef, 13 farm::resource_area::ResourceAreaRef, 14 id::{DTag, InventoryBinId}, 15 listing::operational::OperationalListingParseError, 16 listing::operational::{ 17 OperationalListing, OperationalListingAvailability, OperationalListingBin, 18 OperationalListingDeliveryMethod, OperationalListingImage, OperationalListingImageSize, 19 OperationalListingProduct, OperationalListingPublicLocation, OperationalListingStatus, 20 }, 21 tag::name::{TAG_D, TAG_PUBLISHED_AT}, 22 }; 23 use radroots_event_codec::decode::EventParseError; 24 use radroots_event_codec::decode::operational_listing::{ 25 data_from_event, data_from_nostr_event, operational_listing_from_event, 26 operational_listing_from_event_parts, operational_listing_from_nostr_event, parsed_from_event, 27 parsed_from_nostr_event, 28 }; 29 use radroots_event_codec::encode::EventEncodeError; 30 use radroots_event_codec::encode::operational_listing::{ 31 OperationalListingTagOptions, operational_listing_tags_full, 32 operational_listing_tags_with_options, 33 }; 34 use radroots_event_codec::encode::operational_listing::{ 35 operational_listing_build_tags, to_wire_parts, to_wire_parts_with_kind, 36 }; 37 use std::{borrow::Cow, collections::BTreeSet, fs, path::Path, str::FromStr}; 38 39 use serde_json::Value; 40 41 const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; 42 const AUTHOR: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; 43 const EVENT_SIG: &str = concat!( 44 "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", 45 "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" 46 ); 47 const PACKAGED_PARSE_VECTORS: &str = 48 include_str!("fixtures/operational_listing_parse_event.v1.json"); 49 const WORKSPACE_PARSE_VECTOR_PATH: &str = 50 "../../contracts/conformance/vectors/operational_listing/parse_event.v1.json"; 51 const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml"; 52 53 fn listing_d_tag(raw: &str) -> DTag { 54 raw.parse().unwrap() 55 } 56 57 fn bin_id(raw: &str) -> InventoryBinId { 58 raw.parse().unwrap() 59 } 60 61 fn money(amount: Decimal, currency: Currency) -> Money { 62 Money::try_new(amount, currency).unwrap() 63 } 64 65 fn quantity(amount: Decimal, unit: Unit) -> Quantity { 66 Quantity::try_new(amount, unit).unwrap() 67 } 68 69 fn quantity_price(amount: Money, quantity: Quantity) -> QuantityPrice { 70 QuantityPrice::try_new(amount, quantity).unwrap() 71 } 72 73 fn sample_operational_listing_tags() -> Vec<Vec<String>> { 74 operational_listing_build_tags(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg")).unwrap() 75 } 76 77 fn remove_tags(tags: &mut Vec<Vec<String>>, name: &str) { 78 tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(name)); 79 } 80 81 fn replace_first_tag(tags: &mut [Vec<String>], name: &str, replacement: Vec<&str>) { 82 let tag = tags 83 .iter_mut() 84 .find(|tag| tag.first().map(|value| value.as_str()) == Some(name)) 85 .expect("tag"); 86 *tag = replacement.into_iter().map(str::to_string).collect(); 87 } 88 89 fn event_envelope(kind: u32, tags: Vec<Vec<String>>, content: String) -> EventEnvelope { 90 EventEnvelope::new(EventEnvelopeParts { 91 id: EVENT_ID.to_string(), 92 author: AUTHOR.to_string(), 93 created_at: 7, 94 kind, 95 tags, 96 content, 97 sig: EVENT_SIG.to_string(), 98 }) 99 .unwrap() 100 } 101 102 fn assert_missing_tag(tags: Vec<Vec<String>>, expected: &'static str) { 103 match operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget") { 104 Err(EventParseError::MissingTag(tag)) => assert_eq!(tag, expected), 105 other => panic!("expected missing tag {expected}: {other:?}"), 106 } 107 } 108 109 fn assert_invalid_tag(tags: Vec<Vec<String>>, expected: &'static str) { 110 match operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget") { 111 Err(EventParseError::InvalidTag(tag)) => assert_eq!(tag, expected), 112 other => panic!("expected invalid tag {expected}: {other:?}"), 113 } 114 } 115 116 fn sample_listing(d_tag: &str) -> OperationalListing { 117 let quantity = quantity(Decimal::from(1u32), Unit::Each); 118 let price = quantity_price(money(Decimal::from(10u32), Currency::USD), quantity.clone()); 119 120 OperationalListing { 121 d_tag: listing_d_tag(d_tag), 122 published_at: None, 123 farm: FarmRef { 124 pubkey: "farm_pubkey".to_string(), 125 d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), 126 }, 127 product: OperationalListingProduct { 128 key: "sku".to_string(), 129 title: "Widget".to_string(), 130 category: "Tools".to_string(), 131 summary: None, 132 process: None, 133 lot: None, 134 location: None, 135 profile: None, 136 year: None, 137 }, 138 primary_bin_id: bin_id("bin-1"), 139 bins: vec![OperationalListingBin { 140 bin_id: bin_id("bin-1"), 141 quantity, 142 price_per_canonical_unit: price, 143 display_amount: None, 144 display_unit: None, 145 display_label: None, 146 display_price: None, 147 display_price_unit: None, 148 }], 149 resource_area: None, 150 plot: None, 151 discounts: None, 152 inventory_available: None, 153 availability: None, 154 delivery_method: None, 155 location: None, 156 images: None, 157 } 158 } 159 160 #[test] 161 fn operational_listing_from_event_parts_preserves_listing_error_taxonomy() { 162 let mut tags = sample_operational_listing_tags(); 163 remove_tags(&mut tags, TAG_D); 164 assert_eq!( 165 operational_listing_from_event_parts(&tags, "").unwrap_err(), 166 OperationalListingParseError::MissingTag(TAG_D.to_string()) 167 ); 168 169 let mut tags = sample_operational_listing_tags(); 170 replace_first_tag(&mut tags, TAG_D, vec![TAG_D, "bad d"]); 171 assert_eq!( 172 operational_listing_from_event_parts(&tags, "").unwrap_err(), 173 OperationalListingParseError::InvalidTag(TAG_D.to_string()) 174 ); 175 176 let mut tags = sample_operational_listing_tags(); 177 replace_first_tag( 178 &mut tags, 179 "radroots:bin", 180 vec!["radroots:bin", "bin-1", "bad", "each"], 181 ); 182 assert_eq!( 183 operational_listing_from_event_parts(&tags, "").unwrap_err(), 184 OperationalListingParseError::InvalidNumber("radroots:bin".to_string()) 185 ); 186 187 let mut tags = sample_operational_listing_tags(); 188 replace_first_tag( 189 &mut tags, 190 "radroots:bin", 191 vec!["radroots:bin", "bin-1", "1", "bad"], 192 ); 193 assert_eq!( 194 operational_listing_from_event_parts(&tags, "").unwrap_err(), 195 OperationalListingParseError::InvalidUnit 196 ); 197 198 let mut tags = sample_operational_listing_tags(); 199 replace_first_tag( 200 &mut tags, 201 "radroots:price", 202 vec!["radroots:price", "bin-1", "10", "not-currency", "1", "each"], 203 ); 204 assert_eq!( 205 operational_listing_from_event_parts(&tags, "").unwrap_err(), 206 OperationalListingParseError::InvalidCurrency 207 ); 208 209 let mut tags = sample_operational_listing_tags(); 210 tags.push(vec!["radroots:discount".to_string(), "{".to_string()]); 211 assert_eq!( 212 operational_listing_from_event_parts(&tags, "").unwrap_err(), 213 OperationalListingParseError::InvalidDiscount("radroots:discount".to_string()) 214 ); 215 216 assert_eq!( 217 operational_listing_from_event_parts( 218 &sample_operational_listing_tags(), 219 r#"{"location":{"coordinates":[1,2]}}"#, 220 ) 221 .unwrap_err(), 222 OperationalListingParseError::InvalidJson("location".to_string()) 223 ); 224 } 225 226 #[test] 227 fn operational_listing_from_event_parts_does_not_allow_json_content_to_override_tags() { 228 let tags = sample_operational_listing_tags(); 229 let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); 230 listing.product.title = "Content override".to_string(); 231 listing.product.category = "Content category".to_string(); 232 listing.inventory_available = Some(Decimal::from(99u32)); 233 listing.bins[0].quantity = quantity(Decimal::from(42u32), Unit::Each); 234 235 let decoded = 236 operational_listing_from_event_parts(&tags, &serde_json::to_string(&listing).unwrap()) 237 .expect("decode tag-authoritative listing"); 238 239 assert_eq!(decoded.product.title, "Widget"); 240 assert_eq!(decoded.product.category, "Tools"); 241 assert_eq!(decoded.inventory_available, None); 242 assert_eq!(decoded.bins[0].quantity.amount(), Decimal::from(1u32)); 243 } 244 245 #[test] 246 fn checked_in_listing_parse_vectors_execute_against_the_typed_decoder() { 247 let vectors = listing_parse_vectors(); 248 let suite: Value = serde_json::from_str(&vectors).expect("listing parse vectors must parse"); 249 assert_eq!(suite["suite"], "operational_listing"); 250 assert_eq!(suite["contract_version"], "1.0.0"); 251 let vectors = suite["vectors"].as_array().expect("listing parse vectors"); 252 assert_eq!(vectors.len(), 9, "listing parse vector inventory drifted"); 253 254 let mut ids = BTreeSet::new(); 255 for vector in vectors { 256 let id = vector["id"].as_str().expect("vector id"); 257 assert!(ids.insert(id), "duplicate listing parse vector id {id}"); 258 execute_listing_parse_vector(vector, id); 259 } 260 } 261 262 fn listing_parse_vectors() -> Cow<'static, str> { 263 let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(WORKSPACE_PARSE_VECTOR_PATH); 264 match fs::read_to_string(&workspace_path) { 265 Ok(canonical) => { 266 assert_eq!( 267 canonical, 268 PACKAGED_PARSE_VECTORS, 269 "packaged listing parse vectors must match {}", 270 workspace_path.display() 271 ); 272 Cow::Owned(canonical) 273 } 274 Err(error) 275 if error.kind() == std::io::ErrorKind::NotFound 276 && !Path::new(env!("CARGO_MANIFEST_DIR")) 277 .join(WORKSPACE_CONTRACT_MARKER_PATH) 278 .is_file() => 279 { 280 Cow::Borrowed(PACKAGED_PARSE_VECTORS) 281 } 282 Err(error) => panic!("failed to read {}: {error}", workspace_path.display()), 283 } 284 } 285 286 fn execute_listing_parse_vector(vector: &Value, id: &str) { 287 let input = &vector["input"]; 288 let tags: Vec<Vec<String>> = 289 serde_json::from_value(input["tags"].clone()).expect("vector tags"); 290 let content = input["content"].as_str().expect("vector content"); 291 let event_kind = input["event_kind"].as_u64().expect("vector event kind") as u32; 292 let event = event_envelope(event_kind, tags, content.to_string()); 293 294 match vector["kind"].as_str().expect("vector kind") { 295 "operational_listing.parse_event.valid" => { 296 let listing = operational_listing_from_nostr_event(&event) 297 .unwrap_or_else(|error| panic!("{id} failed: {error}")); 298 let expected = &vector["expected"]["listing"]; 299 assert_eq!(listing.d_tag.as_str(), expected["d_tag"], "{id}"); 300 assert_eq!(listing.product.title, expected["title"], "{id}"); 301 assert_eq!(listing.product.category, expected["category"], "{id}"); 302 assert_eq!( 303 listing.primary_bin_id.as_str(), 304 expected["primary_bin_id"], 305 "{id}" 306 ); 307 let primary = listing 308 .bins 309 .iter() 310 .find(|bin| bin.bin_id == listing.primary_bin_id) 311 .expect("primary listing bin"); 312 assert_eq!( 313 primary.quantity.amount().to_string(), 314 expected["quantity_amount"], 315 "{id}" 316 ); 317 assert_eq!( 318 primary.quantity.unit().code(), 319 expected["quantity_unit"], 320 "{id}" 321 ); 322 assert_eq!( 323 primary 324 .price_per_canonical_unit 325 .amount() 326 .amount() 327 .to_string(), 328 expected["price_amount"], 329 "{id}" 330 ); 331 assert_eq!( 332 primary 333 .price_per_canonical_unit 334 .amount() 335 .currency() 336 .as_str(), 337 expected["price_currency"], 338 "{id}" 339 ); 340 assert_eq!( 341 serde_json::to_value( 342 listing 343 .inventory_available 344 .as_ref() 345 .map(ToString::to_string) 346 ) 347 .expect("inventory projection"), 348 expected["inventory_available"], 349 "{id}" 350 ); 351 } 352 "operational_listing.parse_event.invalid" => { 353 let error = operational_listing_from_nostr_event(&event) 354 .expect_err("invalid listing parse vector must fail"); 355 assert_eq!( 356 serde_json::to_value(error).expect("listing parse error"), 357 vector["expected"]["error"], 358 "{id}" 359 ); 360 } 361 kind => panic!("{id} uses unsupported listing parse vector kind {kind}"), 362 } 363 } 364 365 fn sample_listing_full(d_tag: &str) -> OperationalListing { 366 let qty_amount = Decimal::from_str("1000").unwrap(); 367 let price_amount = Decimal::from_str("0.01").unwrap(); 368 let display_qty = Decimal::from_str("1").unwrap(); 369 let display_price = Decimal::from_str("10").unwrap(); 370 371 OperationalListing { 372 d_tag: listing_d_tag(d_tag), 373 published_at: None, 374 farm: FarmRef { 375 pubkey: "farm_pubkey".to_string(), 376 d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), 377 }, 378 product: OperationalListingProduct { 379 key: "sku".to_string(), 380 title: "Widget".to_string(), 381 category: "Tools".to_string(), 382 summary: Some("Compact widget".to_string()), 383 process: Some("milled".to_string()), 384 lot: Some("lot-1".to_string()), 385 location: Some("Warehouse".to_string()), 386 profile: Some("standard".to_string()), 387 year: Some("2024".to_string()), 388 }, 389 primary_bin_id: bin_id("bin-1"), 390 bins: vec![OperationalListingBin { 391 bin_id: bin_id("bin-1"), 392 quantity: quantity(qty_amount, Unit::MassG), 393 price_per_canonical_unit: quantity_price( 394 money(price_amount, Currency::USD), 395 quantity(Decimal::from(1u32), Unit::MassG), 396 ), 397 display_amount: Some(display_qty), 398 display_unit: Some(Unit::MassKg), 399 display_label: Some("bag".to_string()), 400 display_price: Some(money(display_price, Currency::USD)), 401 display_price_unit: Some(Unit::MassKg), 402 }], 403 resource_area: None, 404 plot: None, 405 discounts: Some(vec![ 406 Discount::try_new( 407 DiscountScope::Bin, 408 DiscountThreshold::BinCount { 409 bin_id: "bin-1".to_string(), 410 min: 5, 411 }, 412 DiscountValue::MoneyPerBin(money(Decimal::from_str("2").unwrap(), Currency::USD)), 413 ) 414 .unwrap(), 415 ]), 416 inventory_available: None, 417 availability: None, 418 delivery_method: None, 419 location: Some(OperationalListingPublicLocation { 420 primary: "Moyobamba".to_string(), 421 city: Some("Moyobamba".to_string()), 422 region: Some("San Martin".to_string()), 423 country: Some("PE".to_string()), 424 geohash: "9q8yy".to_string(), 425 }), 426 images: Some(vec![OperationalListingImage { 427 url: "http://example.com/widget.jpg".to_string(), 428 size: Some(OperationalListingImageSize { w: 1200, h: 800 }), 429 }]), 430 } 431 } 432 433 #[test] 434 fn operational_listing_build_tags_requires_d_tag() { 435 assert!(DTag::parse("").is_err()); 436 } 437 438 #[test] 439 fn operational_listing_build_tags_rejects_invalid_d_tag() { 440 let listing = sample_listing("invalid:tag"); 441 let err = operational_listing_build_tags(&listing).unwrap_err(); 442 assert!(matches!(err, EventEncodeError::InvalidField("d"))); 443 } 444 445 #[test] 446 fn listing_roundtrip_from_event() { 447 let listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); 448 let parts = to_wire_parts(&listing).unwrap(); 449 450 assert_eq!(parts.content, "# Widget"); 451 452 let decoded = operational_listing_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); 453 assert_eq!(decoded.d_tag, listing.d_tag); 454 assert_eq!(decoded.product.key, listing.product.key); 455 assert_eq!(decoded.product.title, listing.product.title); 456 assert_eq!(decoded.primary_bin_id, listing.primary_bin_id); 457 assert_eq!(decoded.bins.len(), listing.bins.len()); 458 } 459 460 #[test] 461 fn operational_listing_from_event_reconstructs_from_tags_with_markdown_content() { 462 let listing = sample_listing_full("FAAAAAAAAAAAAAAAAAAAAA"); 463 let tags = operational_listing_build_tags(&listing).unwrap(); 464 465 let decoded = 466 operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "### Markdown listing") 467 .unwrap(); 468 assert_eq!(decoded.d_tag, listing.d_tag); 469 assert_eq!(decoded.product.summary, listing.product.summary); 470 assert_eq!(decoded.primary_bin_id, listing.primary_bin_id); 471 assert_eq!( 472 decoded 473 .location 474 .as_ref() 475 .map(|location| location.primary.as_str()), 476 Some("Moyobamba") 477 ); 478 } 479 480 #[test] 481 fn operational_listing_from_event_rejects_invalid_d_tag() { 482 let mut tags = 483 operational_listing_build_tags(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg")).unwrap(); 484 let d_tag = tags 485 .iter_mut() 486 .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_D)) 487 .expect("d tag"); 488 d_tag[1] = "invalid:tag".to_string(); 489 490 let err = 491 operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap_err(); 492 assert!(matches!(err, EventParseError::InvalidTag(TAG_D))); 493 } 494 495 #[test] 496 fn operational_listing_from_event_rejects_wrong_kind() { 497 let tags = operational_listing_build_tags(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg")).unwrap(); 498 499 let err = operational_listing_from_event(KIND_POST, &tags, "# Widget").unwrap_err(); 500 assert!(matches!( 501 err, 502 EventParseError::InvalidKind { 503 expected: "30402", 504 got: KIND_POST 505 } 506 )); 507 } 508 509 #[test] 510 fn operational_listing_from_event_covers_reference_tag_error_paths() { 511 let mut tags = sample_operational_listing_tags(); 512 remove_tags(&mut tags, TAG_D); 513 assert_missing_tag(tags, TAG_D); 514 515 let mut tags = sample_operational_listing_tags(); 516 replace_first_tag(&mut tags, TAG_D, vec![TAG_D]); 517 assert_invalid_tag(tags, TAG_D); 518 519 let mut tags = sample_operational_listing_tags(); 520 replace_first_tag(&mut tags, TAG_D, vec![TAG_D, " "]); 521 assert_invalid_tag(tags, TAG_D); 522 523 let mut tags = sample_operational_listing_tags(); 524 remove_tags(&mut tags, "a"); 525 assert_missing_tag(tags, "a"); 526 527 let mut tags = sample_operational_listing_tags(); 528 replace_first_tag(&mut tags, "a", vec!["a"]); 529 assert_invalid_tag(tags, "a"); 530 531 let mut tags = sample_operational_listing_tags(); 532 replace_first_tag(&mut tags, "a", vec!["a", "bad:farm_pubkey:farm"]); 533 assert_invalid_tag(tags, "a"); 534 535 let mut tags = sample_operational_listing_tags(); 536 replace_first_tag(&mut tags, "a", vec!["a", "30340"]); 537 assert_invalid_tag(tags, "a"); 538 539 let mut tags = sample_operational_listing_tags(); 540 replace_first_tag(&mut tags, "a", vec!["a", "30340::farm"]); 541 assert_invalid_tag(tags, "a"); 542 543 let mut tags = sample_operational_listing_tags(); 544 replace_first_tag(&mut tags, "a", vec!["a", "30340:farm_pubkey:"]); 545 assert_invalid_tag(tags, "a"); 546 547 let mut tags = sample_operational_listing_tags(); 548 replace_first_tag(&mut tags, "a", vec!["a", "30340:farm_pubkey:bad d"]); 549 assert_invalid_tag(tags, "a"); 550 551 let mut tags = sample_operational_listing_tags(); 552 replace_first_tag(&mut tags, "a", vec!["a", "30023:other:article"]); 553 assert_missing_tag(tags, "a"); 554 555 let mut tags = sample_operational_listing_tags(); 556 remove_tags(&mut tags, "p"); 557 assert_missing_tag(tags, "p"); 558 559 let mut tags = sample_operational_listing_tags(); 560 replace_first_tag(&mut tags, "p", vec!["p"]); 561 assert_invalid_tag(tags, "p"); 562 563 let mut tags = sample_operational_listing_tags(); 564 replace_first_tag(&mut tags, "p", vec!["p", " "]); 565 assert_invalid_tag(tags, "p"); 566 567 let mut tags = sample_operational_listing_tags(); 568 replace_first_tag(&mut tags, "p", vec!["p", "other_pubkey"]); 569 assert_invalid_tag(tags, "p"); 570 } 571 572 #[test] 573 fn operational_listing_from_event_covers_resource_and_plot_reference_paths() { 574 let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAw"); 575 listing.resource_area = Some(ResourceAreaRef { 576 pubkey: "resource_pubkey".to_string(), 577 d_tag: "AAAAAAAAAAAAAAAAAAAABQ".to_string(), 578 }); 579 listing.plot = Some(PlotRef { 580 pubkey: "plot_pubkey".to_string(), 581 d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(), 582 }); 583 let tags = operational_listing_build_tags(&listing).unwrap(); 584 let decoded = 585 operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); 586 assert_eq!( 587 decoded 588 .resource_area 589 .as_ref() 590 .map(|area| area.d_tag.as_str()), 591 Some("AAAAAAAAAAAAAAAAAAAABQ") 592 ); 593 assert_eq!( 594 decoded.plot.as_ref().map(|plot| plot.d_tag.as_str()), 595 Some("AAAAAAAAAAAAAAAAAAAAAw") 596 ); 597 598 let mut tags = sample_operational_listing_tags(); 599 tags.push(vec!["radroots:resource_area".to_string()]); 600 assert_invalid_tag(tags, "radroots:resource_area"); 601 602 let mut tags = sample_operational_listing_tags(); 603 tags.push(vec![ 604 "radroots:resource_area".to_string(), 605 format!("{KIND_FARM}:resource_pubkey:resource-area-1"), 606 ]); 607 assert_invalid_tag(tags, "radroots:resource_area"); 608 609 let mut tags = sample_operational_listing_tags(); 610 tags.push(vec![ 611 "radroots:resource_area".to_string(), 612 format!("{KIND_RESOURCE_AREA}::resource-area-1"), 613 ]); 614 assert_invalid_tag(tags, "radroots:resource_area"); 615 616 let mut tags = sample_operational_listing_tags(); 617 tags.push(vec![ 618 "radroots:resource_area".to_string(), 619 format!("{KIND_RESOURCE_AREA}:resource_pubkey:"), 620 ]); 621 assert_invalid_tag(tags, "radroots:resource_area"); 622 623 let mut tags = sample_operational_listing_tags(); 624 tags.push(vec![ 625 "radroots:resource_area".to_string(), 626 format!("{KIND_RESOURCE_AREA}:resource_pubkey:bad d"), 627 ]); 628 assert_invalid_tag(tags, "radroots:resource_area"); 629 630 let mut tags = sample_operational_listing_tags(); 631 tags.push(vec!["radroots:plot".to_string()]); 632 assert_invalid_tag(tags, "radroots:plot"); 633 634 let mut tags = sample_operational_listing_tags(); 635 tags.push(vec![ 636 "radroots:plot".to_string(), 637 format!("{KIND_RESOURCE_AREA}:plot_pubkey:plot-1"), 638 ]); 639 assert_invalid_tag(tags, "radroots:plot"); 640 641 let mut tags = sample_operational_listing_tags(); 642 tags.push(vec![ 643 "radroots:plot".to_string(), 644 format!("{KIND_PLOT}:plot_pubkey:"), 645 ]); 646 assert_invalid_tag(tags, "radroots:plot"); 647 648 let mut tags = sample_operational_listing_tags(); 649 tags.push(vec![ 650 "radroots:plot".to_string(), 651 format!("{KIND_PLOT}:plot_pubkey:bad d"), 652 ]); 653 assert_invalid_tag(tags, "radroots:plot"); 654 } 655 656 #[test] 657 fn operational_listing_from_event_covers_bin_and_price_error_paths() { 658 let mut tags = sample_operational_listing_tags(); 659 remove_tags(&mut tags, "radroots:primary_bin"); 660 assert_missing_tag(tags, "radroots:primary_bin"); 661 662 let mut tags = sample_operational_listing_tags(); 663 tags.push(vec![ 664 "radroots:primary_bin".to_string(), 665 "bin-1".to_string(), 666 ]); 667 let decoded = 668 operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); 669 assert_eq!(decoded.primary_bin_id.as_str(), "bin-1"); 670 671 let mut tags = sample_operational_listing_tags(); 672 tags.push(vec![ 673 "radroots:primary_bin".to_string(), 674 "bin-2".to_string(), 675 ]); 676 assert_invalid_tag(tags, "radroots:primary_bin"); 677 678 let mut tags = sample_operational_listing_tags(); 679 replace_first_tag( 680 &mut tags, 681 "radroots:primary_bin", 682 vec!["radroots:primary_bin", "bin-2"], 683 ); 684 assert_invalid_tag(tags, "radroots:primary_bin"); 685 686 let mut tags = sample_operational_listing_tags(); 687 remove_tags(&mut tags, "radroots:bin"); 688 assert_missing_tag(tags, "radroots:bin"); 689 690 let mut tags = sample_operational_listing_tags(); 691 remove_tags(&mut tags, "radroots:price"); 692 assert_missing_tag(tags, "radroots:price"); 693 694 let mut tags = sample_operational_listing_tags(); 695 replace_first_tag(&mut tags, "radroots:bin", vec!["radroots:bin"]); 696 assert_invalid_tag(tags, "radroots:bin"); 697 698 let mut tags = sample_operational_listing_tags(); 699 replace_first_tag( 700 &mut tags, 701 "radroots:bin", 702 vec!["radroots:bin", "bin-1", "1", "kg"], 703 ); 704 assert_invalid_tag(tags, "radroots:bin"); 705 706 let mut tags = sample_operational_listing_tags(); 707 replace_first_tag( 708 &mut tags, 709 "radroots:bin", 710 vec!["radroots:bin", "bin-1", "1", "not-a-unit"], 711 ); 712 assert_invalid_tag(tags, "radroots:bin"); 713 714 let mut tags = sample_operational_listing_tags(); 715 replace_first_tag( 716 &mut tags, 717 "radroots:bin", 718 vec!["radroots:bin", "bin-1", "1", "each", "1"], 719 ); 720 assert_invalid_tag(tags, "radroots:bin"); 721 722 let mut tags = sample_operational_listing_tags(); 723 replace_first_tag( 724 &mut tags, 725 "radroots:bin", 726 vec![ 727 "radroots:bin", 728 "bin-1", 729 "1", 730 "each", 731 "1", 732 "each", 733 "label", 734 "extra", 735 ], 736 ); 737 assert_invalid_tag(tags, "radroots:bin"); 738 739 let mut tags = sample_operational_listing_tags(); 740 replace_first_tag( 741 &mut tags, 742 "radroots:bin", 743 vec!["radroots:bin", "bin-1", "1", "each", "1", "each"], 744 ); 745 let decoded = 746 operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); 747 assert_eq!(decoded.bins[0].display_amount, Some(Decimal::from(1u32))); 748 assert_eq!(decoded.bins[0].display_unit, Some(Unit::Each)); 749 assert_eq!(decoded.bins[0].display_label, None); 750 751 let mut tags = sample_operational_listing_tags(); 752 tags.push(vec![ 753 "radroots:bin".to_string(), 754 "bin-1".to_string(), 755 "1".to_string(), 756 "each".to_string(), 757 ]); 758 assert_invalid_tag(tags, "radroots:bin"); 759 760 let mut tags = sample_operational_listing_tags(); 761 replace_first_tag(&mut tags, "radroots:price", vec!["radroots:price"]); 762 assert_invalid_tag(tags, "radroots:price"); 763 764 let mut tags = sample_operational_listing_tags(); 765 replace_first_tag( 766 &mut tags, 767 "radroots:price", 768 vec!["radroots:price", "bin-1", "10", "USD", "1", "kg"], 769 ); 770 assert_invalid_tag(tags, "radroots:price"); 771 772 let mut tags = sample_operational_listing_tags(); 773 replace_first_tag( 774 &mut tags, 775 "radroots:price", 776 vec!["radroots:price", "bin-1", "10", "not-currency", "1", "each"], 777 ); 778 assert_invalid_tag(tags, "radroots:price"); 779 780 let mut tags = sample_operational_listing_tags(); 781 replace_first_tag( 782 &mut tags, 783 "radroots:price", 784 vec!["radroots:price", "bin-1", "10", "USD", "1", "each", "10"], 785 ); 786 assert_invalid_tag(tags, "radroots:price"); 787 788 let mut tags = sample_operational_listing_tags(); 789 replace_first_tag( 790 &mut tags, 791 "radroots:price", 792 vec![ 793 "radroots:price", 794 "bin-1", 795 "10", 796 "USD", 797 "1", 798 "each", 799 "10", 800 "each", 801 "extra", 802 ], 803 ); 804 assert_invalid_tag(tags, "radroots:price"); 805 806 let mut tags = sample_operational_listing_tags(); 807 tags.push(vec![ 808 "radroots:price".to_string(), 809 "bin-1".to_string(), 810 "10".to_string(), 811 "USD".to_string(), 812 "1".to_string(), 813 "each".to_string(), 814 ]); 815 assert_invalid_tag(tags, "radroots:price"); 816 817 let mut tags = sample_operational_listing_tags(); 818 replace_first_tag( 819 &mut tags, 820 "radroots:price", 821 vec!["radroots:price", "bin-1", "10", "USD", "1", "g"], 822 ); 823 assert_invalid_tag(tags, "radroots:price"); 824 } 825 826 #[test] 827 fn operational_listing_from_event_covers_trade_location_delivery_and_image_paths() { 828 for expected in ["dd", "dd.lat", "dd.lon", "l", "L"] { 829 let mut tags = sample_operational_listing_tags(); 830 tags.push(vec![expected.to_string(), "synthetic".to_string()]); 831 assert_invalid_tag(tags, expected); 832 } 833 834 let mut tags = sample_operational_listing_tags(); 835 tags.push(vec!["location".to_string(), "Farm shelf".to_string()]); 836 let decoded = 837 operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); 838 assert_eq!(decoded.product.location.as_deref(), Some("Farm shelf")); 839 assert!(decoded.location.is_none()); 840 841 let mut tags = sample_operational_listing_tags(); 842 tags.push(vec!["location".to_string(), "Farm shelf".to_string()]); 843 tags.push(vec![ 844 "location".to_string(), 845 "Peru".to_string(), 846 "Moyobamba".to_string(), 847 "San Martin".to_string(), 848 "PE".to_string(), 849 ]); 850 tags.push(vec!["g".to_string(), "6gkzw".to_string()]); 851 let decoded = 852 operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); 853 assert_eq!(decoded.product.location.as_deref(), Some("Farm shelf")); 854 assert_eq!( 855 decoded.location.as_ref().map(|location| { 856 ( 857 location.primary.as_str(), 858 location.city.as_deref(), 859 location.geohash.as_str(), 860 ) 861 }), 862 Some(("Peru", Some("Moyobamba"), "6gkzw")) 863 ); 864 865 let mut tags = sample_operational_listing_tags(); 866 tags.push(vec![ 867 "location".to_string(), 868 " ".to_string(), 869 "Moyobamba".to_string(), 870 ]); 871 assert_invalid_tag(tags, "location"); 872 873 let mut tags = sample_operational_listing_tags(); 874 tags.push(vec![ 875 "location".to_string(), 876 "Farm stand".to_string(), 877 " ".to_string(), 878 "null".to_string(), 879 " ".to_string(), 880 ]); 881 tags.push(vec!["g".to_string(), "9q8yy".to_string()]); 882 let decoded = 883 operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); 884 assert_eq!( 885 decoded.location.as_ref().map(|location| { 886 ( 887 location.primary.as_str(), 888 location.city.as_deref(), 889 location.region.as_deref(), 890 location.country.as_deref(), 891 location.geohash.as_str(), 892 ) 893 }), 894 Some(("Farm stand", None, None, None, "9q8yy")) 895 ); 896 897 let mut tags = sample_operational_listing_tags(); 898 tags.push(vec!["g".to_string(), "9q8ya".to_string()]); 899 assert_invalid_tag(tags, "g"); 900 901 let mut tags = sample_operational_listing_tags(); 902 tags.push(vec!["g".to_string(), "9q8yy".to_string()]); 903 tags.push(vec!["g".to_string(), "6gkzw".to_string()]); 904 assert_invalid_tag(tags, "g"); 905 906 let mut tags = sample_operational_listing_tags(); 907 tags.push(vec!["inventory".to_string()]); 908 assert_invalid_tag(tags, "inventory"); 909 910 let mut tags = sample_operational_listing_tags(); 911 tags.push(vec!["inventory".to_string(), "bad".to_string()]); 912 assert_invalid_tag(tags, "inventory"); 913 914 let mut tags = sample_operational_listing_tags(); 915 tags.push(vec!["inventory".to_string(), "12.5".to_string()]); 916 tags.push(vec![ 917 "radroots:availability_start".to_string(), 918 "1730".to_string(), 919 ]); 920 tags.push(vec!["expires_at".to_string(), "1740".to_string()]); 921 tags.push(vec!["delivery".to_string(), "pickup".to_string()]); 922 tags.push(vec!["image".to_string(), " ".to_string()]); 923 tags.push(vec![ 924 "image".to_string(), 925 "https://example.test/a.jpg".to_string(), 926 ]); 927 tags.push(vec![ 928 "image".to_string(), 929 "https://example.test/b.jpg".to_string(), 930 "bad-size".to_string(), 931 ]); 932 let decoded = 933 operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); 934 let Some(OperationalListingAvailability::Window { start, end }) = decoded.availability else { 935 panic!("expected availability window"); 936 }; 937 assert_eq!(start, Some(1730)); 938 assert_eq!(end, Some(1740)); 939 assert!(matches!( 940 decoded.delivery_method, 941 Some(OperationalListingDeliveryMethod::Pickup) 942 )); 943 assert_eq!(decoded.images.as_ref().map(Vec::len), Some(2)); 944 assert!(decoded.images.as_ref().unwrap()[1].size.is_none()); 945 946 let mut tags = sample_operational_listing_tags(); 947 tags.push(vec!["delivery".to_string(), "local_delivery".to_string()]); 948 let decoded = 949 operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); 950 assert!(matches!( 951 decoded.delivery_method, 952 Some(OperationalListingDeliveryMethod::LocalDelivery) 953 )); 954 955 let mut tags = sample_operational_listing_tags(); 956 tags.push(vec!["delivery".to_string(), "shipping".to_string()]); 957 let decoded = 958 operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); 959 assert!(matches!( 960 decoded.delivery_method, 961 Some(OperationalListingDeliveryMethod::Shipping) 962 )); 963 964 let mut tags = sample_operational_listing_tags(); 965 tags.push(vec![ 966 "delivery".to_string(), 967 "other".to_string(), 968 "bike courier".to_string(), 969 ]); 970 let decoded = 971 operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); 972 let Some(OperationalListingDeliveryMethod::Other { method }) = decoded.delivery_method else { 973 panic!("expected other delivery method"); 974 }; 975 assert_eq!(method, "bike courier"); 976 977 let mut tags = sample_operational_listing_tags(); 978 tags.push(vec!["delivery".to_string(), "drone".to_string()]); 979 let decoded = 980 operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); 981 let Some(OperationalListingDeliveryMethod::Other { method }) = decoded.delivery_method else { 982 panic!("expected fallback delivery method"); 983 }; 984 assert_eq!(method, "drone"); 985 986 let mut tags = sample_operational_listing_tags(); 987 tags.push(vec!["status".to_string(), "active".to_string()]); 988 let decoded = 989 operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); 990 assert!(matches!( 991 decoded.availability, 992 Some(OperationalListingAvailability::Status { 993 status: OperationalListingStatus::Active 994 }) 995 )); 996 997 let mut tags = sample_operational_listing_tags(); 998 tags.push(vec!["status".to_string(), "sold".to_string()]); 999 let decoded = 1000 operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); 1001 assert!(matches!( 1002 decoded.availability, 1003 Some(OperationalListingAvailability::Status { 1004 status: OperationalListingStatus::Sold 1005 }) 1006 )); 1007 1008 let mut tags = sample_operational_listing_tags(); 1009 tags.push(vec!["status".to_string(), "paused".to_string()]); 1010 let decoded = 1011 operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); 1012 let Some(OperationalListingAvailability::Status { 1013 status: OperationalListingStatus::Other { value }, 1014 }) = decoded.availability 1015 else { 1016 panic!("expected other availability status"); 1017 }; 1018 assert_eq!(value, "paused"); 1019 } 1020 1021 #[test] 1022 fn operational_listing_from_event_rejects_private_location_content_edges() { 1023 let tags = sample_operational_listing_tags(); 1024 for content in [ 1025 "# Widget", 1026 "{not-json", 1027 r#"{"name":"Widget"}"#, 1028 r#"{"location":{"public_label":"Farm shelf"}}"#, 1029 ] { 1030 let decoded = 1031 operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, content).unwrap(); 1032 assert_eq!(decoded.product.key, "sku"); 1033 } 1034 1035 for key in [ 1036 "lat", 1037 "lng", 1038 "lon", 1039 "point", 1040 "polygon", 1041 "coordinates", 1042 "accuracy", 1043 "altitude", 1044 "label", 1045 "tag_0", 1046 "gcs", 1047 ] { 1048 let content = format!(r#"{{"location":{{"{key}":"secret"}}}}"#); 1049 let err = 1050 operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, &content).unwrap_err(); 1051 assert!(matches!(err, EventParseError::InvalidJson("content"))); 1052 } 1053 } 1054 1055 #[test] 1056 fn operational_listing_from_event_covers_remaining_edge_paths() { 1057 let mut tags = sample_operational_listing_tags(); 1058 tags.insert(0, Vec::new()); 1059 tags.push(vec!["location".to_string()]); 1060 let decoded = 1061 operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); 1062 assert_eq!(decoded.product.location, None); 1063 1064 let mut tags = sample_operational_listing_tags(); 1065 tags.push(vec![ 1066 "radroots:plot".to_string(), 1067 format!("{KIND_PLOT}::AAAAAAAAAAAAAAAAAAAAAw"), 1068 ]); 1069 assert_invalid_tag(tags, "radroots:plot"); 1070 1071 let mut tags = sample_operational_listing_tags(); 1072 tags.push(vec![ 1073 "radroots:primary_bin".to_string(), 1074 "bin-2".to_string(), 1075 ]); 1076 assert_invalid_tag(tags, "radroots:primary_bin"); 1077 1078 let mut tags = sample_operational_listing_tags(); 1079 let primary_position = tags 1080 .iter() 1081 .position(|tag| tag.first().map(String::as_str) == Some("radroots:primary_bin")) 1082 .expect("primary bin tag"); 1083 tags.insert( 1084 primary_position + 1, 1085 vec!["radroots:primary_bin".to_string(), "bin-2".to_string()], 1086 ); 1087 assert_invalid_tag(tags, "radroots:primary_bin"); 1088 1089 let mut tags = sample_operational_listing_tags(); 1090 tags.insert(0, vec!["key".to_string(), " ".to_string()]); 1091 tags.push(vec!["key".to_string(), "ignored".to_string()]); 1092 tags.insert(0, vec!["summary".to_string(), " ".to_string()]); 1093 tags.push(vec!["summary".to_string(), "first summary".to_string()]); 1094 tags.push(vec!["summary".to_string(), "ignored summary".to_string()]); 1095 tags.push(vec!["process".to_string(), "null".to_string()]); 1096 tags.push(vec!["lot".to_string(), " null ".to_string()]); 1097 tags.push(vec!["profile".to_string(), "null".to_string()]); 1098 tags.push(vec!["year".to_string(), "null".to_string()]); 1099 let decoded = 1100 operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap(); 1101 assert_eq!(decoded.product.key, "sku"); 1102 assert_eq!(decoded.product.summary.as_deref(), Some("first summary")); 1103 assert_eq!(decoded.product.process, None); 1104 assert_eq!(decoded.product.lot, None); 1105 assert_eq!(decoded.product.profile, None); 1106 assert_eq!(decoded.product.year, None); 1107 1108 let mut tags = sample_operational_listing_tags(); 1109 tags.push(vec!["radroots:availability_start".to_string()]); 1110 assert_invalid_tag(tags, "radroots:availability_start"); 1111 1112 let mut tags = sample_operational_listing_tags(); 1113 tags.push(vec![ 1114 "radroots:availability_start".to_string(), 1115 "bad".to_string(), 1116 ]); 1117 assert_invalid_tag(tags, "radroots:availability_start"); 1118 } 1119 1120 #[test] 1121 fn listing_parsed_wrappers_preserve_event_metadata() { 1122 let listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ"); 1123 let parts = to_wire_parts(&listing).unwrap(); 1124 let data = data_from_event( 1125 EVENT_ID.to_string(), 1126 AUTHOR.to_string(), 1127 7, 1128 parts.kind, 1129 parts.content.clone(), 1130 parts.tags.clone(), 1131 ) 1132 .unwrap(); 1133 assert_eq!(data.id, EVENT_ID); 1134 assert_eq!(data.author, AUTHOR); 1135 assert_eq!(data.published_at, 7); 1136 assert_eq!(data.kind, KIND_CLASSIFIED_LISTING); 1137 assert_eq!(data.data.d_tag, listing.d_tag); 1138 1139 let parsed = parsed_from_event( 1140 EVENT_ID.to_string(), 1141 AUTHOR.to_string(), 1142 7, 1143 parts.kind, 1144 parts.content.clone(), 1145 parts.tags.clone(), 1146 EVENT_SIG.to_string(), 1147 ) 1148 .unwrap(); 1149 assert_eq!(parsed.event.id_hex(), EVENT_ID); 1150 assert_eq!(parsed.event.author().to_hex(), AUTHOR); 1151 assert_eq!(parsed.event.created_at_u64(), 7); 1152 assert_eq!(parsed.event.signature_hex(), EVENT_SIG); 1153 assert_eq!(parsed.data.data.d_tag, listing.d_tag); 1154 1155 let event = event_envelope(parts.kind, parts.tags, parts.content); 1156 let data = data_from_nostr_event(&event).unwrap(); 1157 assert_eq!(data.data.d_tag, listing.d_tag); 1158 let parsed = parsed_from_nostr_event(&event).unwrap(); 1159 assert_eq!(parsed.event.signature_hex(), EVENT_SIG); 1160 assert_eq!(parsed.data.data.d_tag, listing.d_tag); 1161 1162 let err = parsed_from_event( 1163 EVENT_ID.to_string(), 1164 AUTHOR.to_string(), 1165 7, 1166 KIND_POST, 1167 event.content().to_string(), 1168 event.tags_as_vec(), 1169 EVENT_SIG.to_string(), 1170 ) 1171 .unwrap_err(); 1172 assert!(matches!( 1173 err, 1174 EventParseError::InvalidKind { 1175 expected: "30402", 1176 got: KIND_POST 1177 } 1178 )); 1179 } 1180 1181 #[test] 1182 fn retired_listing_kind_is_rejected() { 1183 let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ"); 1184 listing.published_at = Some(1_781_895_600); 1185 1186 assert!(matches!( 1187 to_wire_parts_with_kind(&listing, 30403), 1188 Err(EventEncodeError::InvalidKind(30403)) 1189 )); 1190 } 1191 1192 #[test] 1193 fn listing_roundtrips_published_at_for_active_and_rejects_bad_value() { 1194 let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); 1195 listing.published_at = Some(1_781_895_600); 1196 let parts = to_wire_parts_with_kind(&listing, KIND_CLASSIFIED_LISTING).unwrap(); 1197 assert!(parts.tags.iter().any(|tag| { 1198 tag.first().map(|value| value.as_str()) == Some(TAG_PUBLISHED_AT) 1199 && tag.get(1).map(|value| value.as_str()) == Some("1781895600") 1200 })); 1201 1202 let decoded = operational_listing_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); 1203 assert_eq!(decoded.published_at, Some(1_781_895_600)); 1204 1205 let mut tags = parts.tags; 1206 let published_at = tags 1207 .iter_mut() 1208 .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_PUBLISHED_AT)) 1209 .expect("published_at tag"); 1210 published_at[1] = "bad".to_string(); 1211 let err = 1212 operational_listing_from_event(KIND_CLASSIFIED_LISTING, &tags, "# Widget").unwrap_err(); 1213 assert!(matches!(err, EventParseError::InvalidTag(TAG_PUBLISHED_AT))); 1214 } 1215 1216 #[test] 1217 fn to_wire_parts_rejects_non_listing_kind() { 1218 let err = 1219 to_wire_parts_with_kind(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg"), KIND_POST).unwrap_err(); 1220 assert!(matches!(err, EventEncodeError::InvalidKind(KIND_POST))); 1221 } 1222 1223 #[test] 1224 fn operational_listing_build_tags_includes_listing_fields() { 1225 let listing = sample_listing_full("AAAAAAAAAAAAAAAAAAAAAg"); 1226 let tags = operational_listing_build_tags(&listing).unwrap(); 1227 1228 assert!(tags.iter().any(|t| { 1229 t.first().map(|s| s.as_str()) == Some(TAG_D) 1230 && t.get(1).map(|s| s.as_str()) == Some("AAAAAAAAAAAAAAAAAAAAAg") 1231 })); 1232 assert!(tags.iter().any(|t| { 1233 t.first().map(|s| s.as_str()) == Some("p") 1234 && t.get(1).map(|s| s.as_str()) == Some("farm_pubkey") 1235 })); 1236 assert!(tags.iter().any(|t| { 1237 t.first().map(|s| s.as_str()) == Some("a") 1238 && t.get(1).map(|s| s.as_str()) == Some("30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA") 1239 })); 1240 assert!(tags.iter().any(|t| { 1241 t.first().map(|s| s.as_str()) == Some("key") && t.get(1).map(|s| s.as_str()) == Some("sku") 1242 })); 1243 assert!(tags.iter().any(|t| { 1244 t.first().map(|s| s.as_str()) == Some("title") 1245 && t.get(1).map(|s| s.as_str()) == Some("Widget") 1246 })); 1247 1248 let primary_tag = tags 1249 .iter() 1250 .find(|t| t.first().map(|s| s.as_str()) == Some("radroots:primary_bin")) 1251 .expect("primary bin tag"); 1252 assert_eq!(primary_tag.get(1).map(|s| s.as_str()), Some("bin-1")); 1253 1254 let bin_tag = tags 1255 .iter() 1256 .find(|t| t.first().map(|s| s.as_str()) == Some("radroots:bin")) 1257 .expect("bin tag"); 1258 assert_eq!(bin_tag.get(1).map(|s| s.as_str()), Some("bin-1")); 1259 assert_eq!(bin_tag.get(2).map(|s| s.as_str()), Some("1000")); 1260 assert_eq!(bin_tag.get(3).map(|s| s.as_str()), Some("g")); 1261 assert_eq!(bin_tag.get(4).map(|s| s.as_str()), Some("1")); 1262 assert_eq!(bin_tag.get(5).map(|s| s.as_str()), Some("kg")); 1263 assert_eq!(bin_tag.get(6).map(|s| s.as_str()), Some("bag")); 1264 1265 let price_tag = tags 1266 .iter() 1267 .find(|t| t.first().map(|s| s.as_str()) == Some("radroots:price")) 1268 .expect("radroots price tag"); 1269 assert_eq!(price_tag.get(1).map(|s| s.as_str()), Some("bin-1")); 1270 assert_eq!(price_tag.get(2).map(|s| s.as_str()), Some("0.01")); 1271 assert_eq!(price_tag.get(3).map(|s| s.as_str()), Some("USD")); 1272 assert_eq!(price_tag.get(4).map(|s| s.as_str()), Some("1")); 1273 assert_eq!(price_tag.get(5).map(|s| s.as_str()), Some("g")); 1274 assert_eq!(price_tag.get(6).map(|s| s.as_str()), Some("10")); 1275 assert_eq!(price_tag.get(7).map(|s| s.as_str()), Some("kg")); 1276 1277 let generic_price_tag = tags 1278 .iter() 1279 .find(|t| { 1280 t.first().map(|s| s.as_str()) == Some("price") 1281 && t.get(1).map(|s| s.as_str()) == Some("10") 1282 }) 1283 .expect("generic price tag"); 1284 assert_eq!(generic_price_tag.get(2).map(|s| s.as_str()), Some("USD")); 1285 1286 let discount_tag = tags 1287 .iter() 1288 .find(|t| t.first().map(|s| s.as_str()) == Some("radroots:discount")) 1289 .expect("discount tag"); 1290 assert!( 1291 discount_tag 1292 .get(1) 1293 .map(|s| s.contains("\"scope\":\"bin\"")) 1294 .unwrap_or(false) 1295 ); 1296 1297 assert!(tags.iter().any(|t| { 1298 t.first().map(|s| s.as_str()) == Some("location") 1299 && t.get(1).map(|s| s.as_str()) == Some("Moyobamba") 1300 })); 1301 1302 let g_tags: Vec<&Vec<String>> = tags 1303 .iter() 1304 .filter(|t| t.first().map(|s| s.as_str()) == Some("g")) 1305 .collect(); 1306 assert_eq!(g_tags.len(), 1); 1307 assert_eq!(g_tags[0][1].len(), 5); 1308 assert!( 1309 !tags 1310 .iter() 1311 .any(|t| t.first().map(|s| s.as_str()) == Some("L")) 1312 ); 1313 assert!( 1314 !tags 1315 .iter() 1316 .any(|t| t.first().map(|s| s.as_str()) == Some("l")) 1317 ); 1318 1319 assert!(tags.iter().any(|t| { 1320 t.first().map(|s| s.as_str()) == Some("image") 1321 && t.get(1).map(|s| s.as_str()) == Some("http://example.com/widget.jpg") 1322 && t.get(2).map(|s| s.as_str()) == Some("1200x800") 1323 })); 1324 } 1325 1326 #[test] 1327 fn operational_listing_tags_full_uses_single_generic_price_for_primary_bin() { 1328 let mut listing = sample_listing_full("AAAAAAAAAAAAAAAAAAAAAw"); 1329 listing.bins.push(OperationalListingBin { 1330 bin_id: bin_id("bin-2"), 1331 quantity: quantity(Decimal::from_str("500").unwrap(), Unit::MassG), 1332 price_per_canonical_unit: quantity_price( 1333 money(Decimal::from_str("0.02").unwrap(), Currency::USD), 1334 quantity(Decimal::from(1u32), Unit::MassG), 1335 ), 1336 display_amount: Some(Decimal::from(500u32)), 1337 display_unit: Some(Unit::MassG), 1338 display_label: Some("sample".to_string()), 1339 display_price: Some(money(Decimal::from_str("10").unwrap(), Currency::USD)), 1340 display_price_unit: Some(Unit::MassG), 1341 }); 1342 1343 let tags = operational_listing_tags_full(&listing).unwrap(); 1344 let generic_price_tags: Vec<&Vec<String>> = tags 1345 .iter() 1346 .filter(|tag| tag.first().map(|value| value.as_str()) == Some("price")) 1347 .collect(); 1348 assert_eq!(generic_price_tags.len(), 1); 1349 assert_eq!( 1350 generic_price_tags[0].get(1).map(|value| value.as_str()), 1351 Some("10") 1352 ); 1353 assert_eq!( 1354 generic_price_tags[0].get(2).map(|value| value.as_str()), 1355 Some("USD") 1356 ); 1357 } 1358 1359 #[test] 1360 fn operational_listing_tags_full_includes_trade_fields() { 1361 let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); 1362 let inventory = Decimal::from_str("12.5").unwrap(); 1363 let inventory_value = inventory.to_string(); 1364 listing.inventory_available = Some(inventory); 1365 listing.availability = Some(OperationalListingAvailability::Window { 1366 start: Some(1730000000), 1367 end: Some(1731000000), 1368 }); 1369 listing.delivery_method = Some(OperationalListingDeliveryMethod::Shipping); 1370 1371 let tags = operational_listing_tags_full(&listing).unwrap(); 1372 1373 assert!(tags.iter().any(|t| { 1374 t.first().map(|s| s.as_str()) == Some("inventory") 1375 && t.get(1).map(|s| s.as_str()) == Some(inventory_value.as_str()) 1376 })); 1377 assert!(tags.iter().any(|t| { 1378 t.first().map(|s| s.as_str()) == Some("radroots:availability_start") 1379 && t.get(1).map(|s| s.as_str()) == Some("1730000000") 1380 })); 1381 assert!(tags.iter().any(|t| { 1382 t.first().map(|s| s.as_str()) == Some("expires_at") 1383 && t.get(1).map(|s| s.as_str()) == Some("1731000000") 1384 })); 1385 assert!(tags.iter().any(|t| { 1386 t.first().map(|s| s.as_str()) == Some("delivery") 1387 && t.get(1).map(|s| s.as_str()) == Some("shipping") 1388 })); 1389 } 1390 1391 #[test] 1392 fn operational_listing_tags_full_includes_status_tag() { 1393 let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); 1394 listing.availability = Some(OperationalListingAvailability::Status { 1395 status: OperationalListingStatus::Active, 1396 }); 1397 1398 let tags = operational_listing_tags_full(&listing).unwrap(); 1399 1400 assert!(tags.iter().any(|t| { 1401 t.first().map(|s| s.as_str()) == Some("status") 1402 && t.get(1).map(|s| s.as_str()) == Some("active") 1403 })); 1404 } 1405 1406 #[test] 1407 fn operational_listing_build_tags_ignores_null_strings() { 1408 let mut listing = sample_listing_full("AAAAAAAAAAAAAAAAAAAAAg"); 1409 listing.product.summary = Some("null".to_string()); 1410 listing.product.process = Some("null".to_string()); 1411 listing.product.lot = Some("null".to_string()); 1412 listing.product.location = Some("null".to_string()); 1413 listing.product.profile = Some("null".to_string()); 1414 listing.product.year = Some("null".to_string()); 1415 listing.location = Some(OperationalListingPublicLocation { 1416 primary: "Moyobamba".to_string(), 1417 city: Some("null".to_string()), 1418 region: Some("San Martin".to_string()), 1419 country: Some("null".to_string()), 1420 geohash: "9q8yy".to_string(), 1421 }); 1422 listing.images = Some(vec![OperationalListingImage { 1423 url: "null".to_string(), 1424 size: None, 1425 }]); 1426 1427 let tags = operational_listing_build_tags(&listing).unwrap(); 1428 assert!( 1429 !tags 1430 .iter() 1431 .any(|tag| tag.iter().any(|value| value == "null")) 1432 ); 1433 } 1434 1435 #[test] 1436 fn operational_listing_build_tags_rejects_location_without_public_locality() { 1437 let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); 1438 listing.location = Some(OperationalListingPublicLocation { 1439 primary: "Farm stand".to_string(), 1440 city: Some("null".to_string()), 1441 region: None, 1442 country: None, 1443 geohash: "9q8yy".to_string(), 1444 }); 1445 1446 assert!(matches!( 1447 operational_listing_build_tags(&listing), 1448 Err(EventEncodeError::EmptyRequiredField("location.locality")) 1449 )); 1450 } 1451 1452 #[test] 1453 fn operational_listing_tags_with_options_cover_location_fallback_paths() { 1454 let mut geohash_only = sample_listing("AAAAAAAAAAAAAAAAAAAAAg"); 1455 geohash_only.location = Some(OperationalListingPublicLocation { 1456 primary: "Moyobamba".to_string(), 1457 city: Some("Moyobamba".to_string()), 1458 region: None, 1459 country: None, 1460 geohash: "6gkzw".to_string(), 1461 }); 1462 let tags = operational_listing_tags_with_options( 1463 &geohash_only, 1464 OperationalListingTagOptions::default(), 1465 ) 1466 .unwrap(); 1467 assert!( 1468 tags.iter() 1469 .any(|tag| tag.first().map(|value| value.as_str()) == Some("g")) 1470 ); 1471 assert!( 1472 !tags 1473 .iter() 1474 .any(|tag| tag.first().map(|value| value.as_str()) == Some("l")) 1475 ); 1476 1477 let mut no_coordinates = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ"); 1478 no_coordinates.location = Some(OperationalListingPublicLocation { 1479 primary: "Moyobamba".to_string(), 1480 city: Some("Moyobamba".to_string()), 1481 region: None, 1482 country: None, 1483 geohash: "9q8yy".to_string(), 1484 }); 1485 let tags = operational_listing_tags_with_options( 1486 &no_coordinates, 1487 OperationalListingTagOptions::default(), 1488 ) 1489 .unwrap(); 1490 assert!( 1491 !tags 1492 .iter() 1493 .any(|tag| tag.first().map(|value| value.as_str()) == Some("L")) 1494 ); 1495 assert!( 1496 tags.iter() 1497 .any(|tag| tag.first().map(|value| value.as_str()) == Some("g")) 1498 ); 1499 1500 let mut without_geohash = sample_listing("AAAAAAAAAAAAAAAAAAAAAw"); 1501 without_geohash.location = Some(OperationalListingPublicLocation { 1502 primary: "Moyobamba".to_string(), 1503 city: Some("Moyobamba".to_string()), 1504 region: None, 1505 country: None, 1506 geohash: "9q8yy".to_string(), 1507 }); 1508 let tags = operational_listing_tags_with_options( 1509 &without_geohash, 1510 OperationalListingTagOptions { 1511 ..OperationalListingTagOptions::default() 1512 }, 1513 ) 1514 .unwrap(); 1515 assert!( 1516 tags.iter() 1517 .any(|tag| tag.first().map(|value| value.as_str()) == Some("g")) 1518 ); 1519 assert!( 1520 !tags 1521 .iter() 1522 .any(|tag| tag.first().map(|value| value.as_str()) == Some("L")) 1523 ); 1524 }