lib

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

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 }