lib

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

listing.rs (42133B)


      1 #![cfg(feature = "serde_json")]
      2 
      3 use radroots_core::{
      4     RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountScope,
      5     RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreMoney,
      6     RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit,
      7 };
      8 use radroots_events::{
      9     RadrootsNostrEvent,
     10     farm::RadrootsFarmRef,
     11     ids::{RadrootsDTag, RadrootsInventoryBinId},
     12     kinds::{
     13         KIND_FARM, KIND_LISTING, KIND_LISTING_DRAFT, KIND_PLOT, KIND_POST, KIND_RESOURCE_AREA,
     14     },
     15     listing::{
     16         RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
     17         RadrootsListingDeliveryMethod, RadrootsListingImage, RadrootsListingImageSize,
     18         RadrootsListingLocation, RadrootsListingProduct, RadrootsListingStatus,
     19     },
     20     plot::RadrootsPlotRef,
     21     resource_area::RadrootsResourceAreaRef,
     22     tags::{TAG_D, TAG_PUBLISHED_AT},
     23 };
     24 use radroots_events_codec::error::{EventEncodeError, EventParseError};
     25 use radroots_events_codec::listing::decode::{
     26     data_from_event, data_from_nostr_event, listing_from_event, parsed_from_event,
     27     parsed_from_nostr_event,
     28 };
     29 use radroots_events_codec::listing::encode::{
     30     listing_build_tags, to_wire_parts, to_wire_parts_with_kind,
     31 };
     32 use radroots_events_codec::listing::tags::{
     33     ListingTagOptions, listing_tags_full, listing_tags_with_options,
     34 };
     35 use std::str::FromStr;
     36 
     37 fn listing_d_tag(raw: &str) -> RadrootsDTag {
     38     raw.parse().unwrap()
     39 }
     40 
     41 fn bin_id(raw: &str) -> RadrootsInventoryBinId {
     42     raw.parse().unwrap()
     43 }
     44 
     45 fn sample_listing_tags() -> Vec<Vec<String>> {
     46     listing_build_tags(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg")).unwrap()
     47 }
     48 
     49 fn remove_tags(tags: &mut Vec<Vec<String>>, name: &str) {
     50     tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(name));
     51 }
     52 
     53 fn replace_first_tag(tags: &mut [Vec<String>], name: &str, replacement: Vec<&str>) {
     54     let tag = tags
     55         .iter_mut()
     56         .find(|tag| tag.first().map(|value| value.as_str()) == Some(name))
     57         .expect("tag");
     58     *tag = replacement.into_iter().map(str::to_string).collect();
     59 }
     60 
     61 fn assert_missing_tag(tags: Vec<Vec<String>>, expected: &'static str) {
     62     match listing_from_event(KIND_LISTING, &tags, "# Widget") {
     63         Err(EventParseError::MissingTag(tag)) => assert_eq!(tag, expected),
     64         other => panic!("expected missing tag {expected}: {other:?}"),
     65     }
     66 }
     67 
     68 fn assert_invalid_tag(tags: Vec<Vec<String>>, expected: &'static str) {
     69     match listing_from_event(KIND_LISTING, &tags, "# Widget") {
     70         Err(EventParseError::InvalidTag(tag)) => assert_eq!(tag, expected),
     71         other => panic!("expected invalid tag {expected}: {other:?}"),
     72     }
     73 }
     74 
     75 fn sample_listing(d_tag: &str) -> RadrootsListing {
     76     let quantity =
     77         RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each);
     78     let price = RadrootsCoreQuantityPrice::new(
     79         RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD),
     80         quantity.clone(),
     81     );
     82 
     83     RadrootsListing {
     84         d_tag: listing_d_tag(d_tag),
     85         published_at: None,
     86         farm: RadrootsFarmRef {
     87             pubkey: "farm_pubkey".to_string(),
     88             d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
     89         },
     90         product: RadrootsListingProduct {
     91             key: "sku".to_string(),
     92             title: "Widget".to_string(),
     93             category: "Tools".to_string(),
     94             summary: None,
     95             process: None,
     96             lot: None,
     97             location: None,
     98             profile: None,
     99             year: None,
    100         },
    101         primary_bin_id: bin_id("bin-1"),
    102         bins: vec![RadrootsListingBin {
    103             bin_id: bin_id("bin-1"),
    104             quantity,
    105             price_per_canonical_unit: price,
    106             display_amount: None,
    107             display_unit: None,
    108             display_label: None,
    109             display_price: None,
    110             display_price_unit: None,
    111         }],
    112         resource_area: None,
    113         plot: None,
    114         discounts: None,
    115         inventory_available: None,
    116         availability: None,
    117         delivery_method: None,
    118         location: None,
    119         images: None,
    120     }
    121 }
    122 
    123 fn sample_listing_full(d_tag: &str) -> RadrootsListing {
    124     let qty_amount = RadrootsCoreDecimal::from_str("1000").unwrap();
    125     let price_amount = RadrootsCoreDecimal::from_str("0.01").unwrap();
    126     let display_qty = RadrootsCoreDecimal::from_str("1").unwrap();
    127     let display_price = RadrootsCoreDecimal::from_str("10").unwrap();
    128 
    129     RadrootsListing {
    130         d_tag: listing_d_tag(d_tag),
    131         published_at: None,
    132         farm: RadrootsFarmRef {
    133             pubkey: "farm_pubkey".to_string(),
    134             d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
    135         },
    136         product: RadrootsListingProduct {
    137             key: "sku".to_string(),
    138             title: "Widget".to_string(),
    139             category: "Tools".to_string(),
    140             summary: Some("Compact widget".to_string()),
    141             process: Some("milled".to_string()),
    142             lot: Some("lot-1".to_string()),
    143             location: Some("Warehouse".to_string()),
    144             profile: Some("standard".to_string()),
    145             year: Some("2024".to_string()),
    146         },
    147         primary_bin_id: bin_id("bin-1"),
    148         bins: vec![RadrootsListingBin {
    149             bin_id: bin_id("bin-1"),
    150             quantity: RadrootsCoreQuantity::new(qty_amount, RadrootsCoreUnit::MassG),
    151             price_per_canonical_unit: RadrootsCoreQuantityPrice::new(
    152                 RadrootsCoreMoney::new(price_amount, RadrootsCoreCurrency::USD),
    153                 RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::MassG),
    154             ),
    155             display_amount: Some(display_qty),
    156             display_unit: Some(RadrootsCoreUnit::MassKg),
    157             display_label: Some("bag".to_string()),
    158             display_price: Some(RadrootsCoreMoney::new(
    159                 display_price,
    160                 RadrootsCoreCurrency::USD,
    161             )),
    162             display_price_unit: Some(RadrootsCoreUnit::MassKg),
    163         }],
    164         resource_area: None,
    165         plot: None,
    166         discounts: Some(vec![RadrootsCoreDiscount {
    167             scope: RadrootsCoreDiscountScope::Bin,
    168             threshold: RadrootsCoreDiscountThreshold::BinCount {
    169                 bin_id: "bin-1".to_string(),
    170                 min: 5,
    171             },
    172             value: RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new(
    173                 RadrootsCoreDecimal::from_str("2").unwrap(),
    174                 RadrootsCoreCurrency::USD,
    175             )),
    176         }]),
    177         inventory_available: None,
    178         availability: None,
    179         delivery_method: None,
    180         location: Some(RadrootsListingLocation {
    181             primary: "Moyobamba".to_string(),
    182             city: Some("Moyobamba".to_string()),
    183             region: Some("San Martin".to_string()),
    184             country: Some("PE".to_string()),
    185             lat: Some(-6.0346),
    186             lng: Some(-76.9714),
    187             geohash: None,
    188         }),
    189         images: Some(vec![RadrootsListingImage {
    190             url: "http://example.com/widget.jpg".to_string(),
    191             size: Some(RadrootsListingImageSize { w: 1200, h: 800 }),
    192         }]),
    193     }
    194 }
    195 
    196 #[test]
    197 fn listing_build_tags_requires_d_tag() {
    198     assert!(RadrootsDTag::parse("").is_err());
    199 }
    200 
    201 #[test]
    202 fn listing_build_tags_rejects_invalid_d_tag() {
    203     let listing = sample_listing("invalid:tag");
    204     let err = listing_build_tags(&listing).unwrap_err();
    205     assert!(matches!(err, EventEncodeError::InvalidField("d")));
    206 }
    207 
    208 #[test]
    209 fn listing_roundtrip_from_event() {
    210     let listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
    211     let parts = to_wire_parts(&listing).unwrap();
    212 
    213     assert_eq!(parts.content, "# Widget");
    214 
    215     let decoded = listing_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
    216     assert_eq!(decoded.d_tag, listing.d_tag);
    217     assert_eq!(decoded.product.key, listing.product.key);
    218     assert_eq!(decoded.product.title, listing.product.title);
    219     assert_eq!(decoded.primary_bin_id, listing.primary_bin_id);
    220     assert_eq!(decoded.bins.len(), listing.bins.len());
    221 }
    222 
    223 #[test]
    224 fn listing_from_event_reconstructs_from_tags_with_markdown_content() {
    225     let listing = sample_listing_full("FAAAAAAAAAAAAAAAAAAAAA");
    226     let tags = listing_build_tags(&listing).unwrap();
    227 
    228     let decoded = listing_from_event(KIND_LISTING, &tags, "### Markdown listing").unwrap();
    229     assert_eq!(decoded.d_tag, listing.d_tag);
    230     assert_eq!(decoded.product.summary, listing.product.summary);
    231     assert_eq!(decoded.primary_bin_id, listing.primary_bin_id);
    232     assert_eq!(
    233         decoded
    234             .location
    235             .as_ref()
    236             .map(|location| location.primary.as_str()),
    237         Some("Moyobamba")
    238     );
    239 }
    240 
    241 #[test]
    242 fn listing_from_event_rejects_invalid_d_tag() {
    243     let mut tags = listing_build_tags(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg")).unwrap();
    244     let d_tag = tags
    245         .iter_mut()
    246         .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_D))
    247         .expect("d tag");
    248     d_tag[1] = "invalid:tag".to_string();
    249 
    250     let err = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap_err();
    251     assert!(matches!(err, EventParseError::InvalidTag(TAG_D)));
    252 }
    253 
    254 #[test]
    255 fn listing_from_event_rejects_wrong_kind() {
    256     let tags = listing_build_tags(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg")).unwrap();
    257 
    258     let err = listing_from_event(KIND_POST, &tags, "# Widget").unwrap_err();
    259     assert!(matches!(
    260         err,
    261         EventParseError::InvalidKind {
    262             expected: "30402 or 30403",
    263             got: KIND_POST
    264         }
    265     ));
    266 }
    267 
    268 #[test]
    269 fn listing_from_event_covers_reference_tag_error_paths() {
    270     let mut tags = sample_listing_tags();
    271     remove_tags(&mut tags, TAG_D);
    272     assert_missing_tag(tags, TAG_D);
    273 
    274     let mut tags = sample_listing_tags();
    275     replace_first_tag(&mut tags, TAG_D, vec![TAG_D]);
    276     assert_invalid_tag(tags, TAG_D);
    277 
    278     let mut tags = sample_listing_tags();
    279     replace_first_tag(&mut tags, TAG_D, vec![TAG_D, " "]);
    280     assert_invalid_tag(tags, TAG_D);
    281 
    282     let mut tags = sample_listing_tags();
    283     remove_tags(&mut tags, "a");
    284     assert_missing_tag(tags, "a");
    285 
    286     let mut tags = sample_listing_tags();
    287     replace_first_tag(&mut tags, "a", vec!["a"]);
    288     assert_invalid_tag(tags, "a");
    289 
    290     let mut tags = sample_listing_tags();
    291     replace_first_tag(&mut tags, "a", vec!["a", "bad:farm_pubkey:farm"]);
    292     assert_invalid_tag(tags, "a");
    293 
    294     let mut tags = sample_listing_tags();
    295     replace_first_tag(&mut tags, "a", vec!["a", "30340"]);
    296     assert_invalid_tag(tags, "a");
    297 
    298     let mut tags = sample_listing_tags();
    299     replace_first_tag(&mut tags, "a", vec!["a", "30340::farm"]);
    300     assert_invalid_tag(tags, "a");
    301 
    302     let mut tags = sample_listing_tags();
    303     replace_first_tag(&mut tags, "a", vec!["a", "30340:farm_pubkey:"]);
    304     assert_invalid_tag(tags, "a");
    305 
    306     let mut tags = sample_listing_tags();
    307     replace_first_tag(&mut tags, "a", vec!["a", "30340:farm_pubkey:bad d"]);
    308     assert_invalid_tag(tags, "a");
    309 
    310     let mut tags = sample_listing_tags();
    311     replace_first_tag(&mut tags, "a", vec!["a", "30023:other:article"]);
    312     assert_missing_tag(tags, "a");
    313 
    314     let mut tags = sample_listing_tags();
    315     remove_tags(&mut tags, "p");
    316     assert_missing_tag(tags, "p");
    317 
    318     let mut tags = sample_listing_tags();
    319     replace_first_tag(&mut tags, "p", vec!["p"]);
    320     assert_invalid_tag(tags, "p");
    321 
    322     let mut tags = sample_listing_tags();
    323     replace_first_tag(&mut tags, "p", vec!["p", " "]);
    324     assert_invalid_tag(tags, "p");
    325 
    326     let mut tags = sample_listing_tags();
    327     replace_first_tag(&mut tags, "p", vec!["p", "other_pubkey"]);
    328     assert_invalid_tag(tags, "p");
    329 }
    330 
    331 #[test]
    332 fn listing_from_event_covers_resource_and_plot_reference_paths() {
    333     let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAw");
    334     listing.resource_area = Some(RadrootsResourceAreaRef {
    335         pubkey: "resource_pubkey".to_string(),
    336         d_tag: "AAAAAAAAAAAAAAAAAAAABQ".to_string(),
    337     });
    338     listing.plot = Some(RadrootsPlotRef {
    339         pubkey: "plot_pubkey".to_string(),
    340         d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(),
    341     });
    342     let tags = listing_build_tags(&listing).unwrap();
    343     let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
    344     assert_eq!(
    345         decoded
    346             .resource_area
    347             .as_ref()
    348             .map(|area| area.d_tag.as_str()),
    349         Some("AAAAAAAAAAAAAAAAAAAABQ")
    350     );
    351     assert_eq!(
    352         decoded.plot.as_ref().map(|plot| plot.d_tag.as_str()),
    353         Some("AAAAAAAAAAAAAAAAAAAAAw")
    354     );
    355 
    356     let mut tags = sample_listing_tags();
    357     tags.push(vec!["radroots:resource_area".to_string()]);
    358     assert_invalid_tag(tags, "radroots:resource_area");
    359 
    360     let mut tags = sample_listing_tags();
    361     tags.push(vec![
    362         "radroots:resource_area".to_string(),
    363         format!("{KIND_FARM}:resource_pubkey:resource-area-1"),
    364     ]);
    365     assert_invalid_tag(tags, "radroots:resource_area");
    366 
    367     let mut tags = sample_listing_tags();
    368     tags.push(vec![
    369         "radroots:resource_area".to_string(),
    370         format!("{KIND_RESOURCE_AREA}::resource-area-1"),
    371     ]);
    372     assert_invalid_tag(tags, "radroots:resource_area");
    373 
    374     let mut tags = sample_listing_tags();
    375     tags.push(vec![
    376         "radroots:resource_area".to_string(),
    377         format!("{KIND_RESOURCE_AREA}:resource_pubkey:"),
    378     ]);
    379     assert_invalid_tag(tags, "radroots:resource_area");
    380 
    381     let mut tags = sample_listing_tags();
    382     tags.push(vec![
    383         "radroots:resource_area".to_string(),
    384         format!("{KIND_RESOURCE_AREA}:resource_pubkey:bad d"),
    385     ]);
    386     assert_invalid_tag(tags, "radroots:resource_area");
    387 
    388     let mut tags = sample_listing_tags();
    389     tags.push(vec!["radroots:plot".to_string()]);
    390     assert_invalid_tag(tags, "radroots:plot");
    391 
    392     let mut tags = sample_listing_tags();
    393     tags.push(vec![
    394         "radroots:plot".to_string(),
    395         format!("{KIND_RESOURCE_AREA}:plot_pubkey:plot-1"),
    396     ]);
    397     assert_invalid_tag(tags, "radroots:plot");
    398 
    399     let mut tags = sample_listing_tags();
    400     tags.push(vec![
    401         "radroots:plot".to_string(),
    402         format!("{KIND_PLOT}:plot_pubkey:"),
    403     ]);
    404     assert_invalid_tag(tags, "radroots:plot");
    405 
    406     let mut tags = sample_listing_tags();
    407     tags.push(vec![
    408         "radroots:plot".to_string(),
    409         format!("{KIND_PLOT}:plot_pubkey:bad d"),
    410     ]);
    411     assert_invalid_tag(tags, "radroots:plot");
    412 }
    413 
    414 #[test]
    415 fn listing_from_event_covers_bin_and_price_error_paths() {
    416     let mut tags = sample_listing_tags();
    417     remove_tags(&mut tags, "radroots:primary_bin");
    418     assert_missing_tag(tags, "radroots:primary_bin");
    419 
    420     let mut tags = sample_listing_tags();
    421     tags.push(vec![
    422         "radroots:primary_bin".to_string(),
    423         "bin-1".to_string(),
    424     ]);
    425     let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
    426     assert_eq!(decoded.primary_bin_id.as_str(), "bin-1");
    427 
    428     let mut tags = sample_listing_tags();
    429     tags.push(vec![
    430         "radroots:primary_bin".to_string(),
    431         "bin-2".to_string(),
    432     ]);
    433     assert_invalid_tag(tags, "radroots:primary_bin");
    434 
    435     let mut tags = sample_listing_tags();
    436     replace_first_tag(
    437         &mut tags,
    438         "radroots:primary_bin",
    439         vec!["radroots:primary_bin", "bin-2"],
    440     );
    441     assert_invalid_tag(tags, "radroots:primary_bin");
    442 
    443     let mut tags = sample_listing_tags();
    444     remove_tags(&mut tags, "radroots:bin");
    445     assert_missing_tag(tags, "radroots:bin");
    446 
    447     let mut tags = sample_listing_tags();
    448     remove_tags(&mut tags, "radroots:price");
    449     assert_missing_tag(tags, "radroots:price");
    450 
    451     let mut tags = sample_listing_tags();
    452     replace_first_tag(&mut tags, "radroots:bin", vec!["radroots:bin"]);
    453     assert_invalid_tag(tags, "radroots:bin");
    454 
    455     let mut tags = sample_listing_tags();
    456     replace_first_tag(
    457         &mut tags,
    458         "radroots:bin",
    459         vec!["radroots:bin", "bin-1", "1", "kg"],
    460     );
    461     assert_invalid_tag(tags, "radroots:bin");
    462 
    463     let mut tags = sample_listing_tags();
    464     replace_first_tag(
    465         &mut tags,
    466         "radroots:bin",
    467         vec!["radroots:bin", "bin-1", "1", "not-a-unit"],
    468     );
    469     assert_invalid_tag(tags, "radroots:bin");
    470 
    471     let mut tags = sample_listing_tags();
    472     replace_first_tag(
    473         &mut tags,
    474         "radroots:bin",
    475         vec!["radroots:bin", "bin-1", "1", "each", "1"],
    476     );
    477     assert_invalid_tag(tags, "radroots:bin");
    478 
    479     let mut tags = sample_listing_tags();
    480     replace_first_tag(
    481         &mut tags,
    482         "radroots:bin",
    483         vec![
    484             "radroots:bin",
    485             "bin-1",
    486             "1",
    487             "each",
    488             "1",
    489             "each",
    490             "label",
    491             "extra",
    492         ],
    493     );
    494     assert_invalid_tag(tags, "radroots:bin");
    495 
    496     let mut tags = sample_listing_tags();
    497     replace_first_tag(
    498         &mut tags,
    499         "radroots:bin",
    500         vec!["radroots:bin", "bin-1", "1", "each", "1", "each"],
    501     );
    502     let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
    503     assert_eq!(
    504         decoded.bins[0].display_amount,
    505         Some(RadrootsCoreDecimal::from(1u32))
    506     );
    507     assert_eq!(decoded.bins[0].display_unit, Some(RadrootsCoreUnit::Each));
    508     assert_eq!(decoded.bins[0].display_label, None);
    509 
    510     let mut tags = sample_listing_tags();
    511     tags.push(vec![
    512         "radroots:bin".to_string(),
    513         "bin-1".to_string(),
    514         "1".to_string(),
    515         "each".to_string(),
    516     ]);
    517     assert_invalid_tag(tags, "radroots:bin");
    518 
    519     let mut tags = sample_listing_tags();
    520     replace_first_tag(&mut tags, "radroots:price", vec!["radroots:price"]);
    521     assert_invalid_tag(tags, "radroots:price");
    522 
    523     let mut tags = sample_listing_tags();
    524     replace_first_tag(
    525         &mut tags,
    526         "radroots:price",
    527         vec!["radroots:price", "bin-1", "10", "USD", "1", "kg"],
    528     );
    529     assert_invalid_tag(tags, "radroots:price");
    530 
    531     let mut tags = sample_listing_tags();
    532     replace_first_tag(
    533         &mut tags,
    534         "radroots:price",
    535         vec!["radroots:price", "bin-1", "10", "not-currency", "1", "each"],
    536     );
    537     assert_invalid_tag(tags, "radroots:price");
    538 
    539     let mut tags = sample_listing_tags();
    540     replace_first_tag(
    541         &mut tags,
    542         "radroots:price",
    543         vec!["radroots:price", "bin-1", "10", "USD", "1", "each", "10"],
    544     );
    545     assert_invalid_tag(tags, "radroots:price");
    546 
    547     let mut tags = sample_listing_tags();
    548     replace_first_tag(
    549         &mut tags,
    550         "radroots:price",
    551         vec![
    552             "radroots:price",
    553             "bin-1",
    554             "10",
    555             "USD",
    556             "1",
    557             "each",
    558             "10",
    559             "each",
    560             "extra",
    561         ],
    562     );
    563     assert_invalid_tag(tags, "radroots:price");
    564 
    565     let mut tags = sample_listing_tags();
    566     tags.push(vec![
    567         "radroots:price".to_string(),
    568         "bin-1".to_string(),
    569         "10".to_string(),
    570         "USD".to_string(),
    571         "1".to_string(),
    572         "each".to_string(),
    573     ]);
    574     assert_invalid_tag(tags, "radroots:price");
    575 
    576     let mut tags = sample_listing_tags();
    577     replace_first_tag(
    578         &mut tags,
    579         "radroots:price",
    580         vec!["radroots:price", "bin-1", "10", "USD", "1", "g"],
    581     );
    582     assert_invalid_tag(tags, "radroots:price");
    583 }
    584 
    585 #[test]
    586 fn listing_from_event_covers_trade_location_delivery_and_image_paths() {
    587     let mut tags = sample_listing_tags();
    588     tags.push(vec!["location".to_string(), "Farm shelf".to_string()]);
    589     let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
    590     assert_eq!(
    591         decoded
    592             .location
    593             .as_ref()
    594             .map(|location| location.primary.as_str()),
    595         Some("Farm shelf")
    596     );
    597 
    598     let mut tags = sample_listing_tags();
    599     tags.push(vec!["location".to_string(), "Farm shelf".to_string()]);
    600     tags.push(vec![
    601         "location".to_string(),
    602         "Peru".to_string(),
    603         "Moyobamba".to_string(),
    604         "San Martin".to_string(),
    605         "PE".to_string(),
    606     ]);
    607     tags.push(vec!["g".to_string(), "6gkzwgjzn".to_string()]);
    608     let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
    609     assert_eq!(decoded.product.location.as_deref(), Some("Farm shelf"));
    610     assert_eq!(
    611         decoded.location.as_ref().map(|location| {
    612             (
    613                 location.primary.as_str(),
    614                 location.city.as_deref(),
    615                 location.geohash.as_deref(),
    616             )
    617         }),
    618         Some(("Peru", Some("Moyobamba"), Some("6gkzwgjzn")))
    619     );
    620 
    621     let mut tags = sample_listing_tags();
    622     tags.push(vec![
    623         "location".to_string(),
    624         " ".to_string(),
    625         "Moyobamba".to_string(),
    626     ]);
    627     assert_invalid_tag(tags, "location");
    628 
    629     let mut tags = sample_listing_tags();
    630     tags.push(vec!["inventory".to_string()]);
    631     assert_invalid_tag(tags, "inventory");
    632 
    633     let mut tags = sample_listing_tags();
    634     tags.push(vec!["inventory".to_string(), "bad".to_string()]);
    635     assert_invalid_tag(tags, "inventory");
    636 
    637     let mut tags = sample_listing_tags();
    638     tags.push(vec!["inventory".to_string(), "12.5".to_string()]);
    639     tags.push(vec![
    640         "radroots:availability_start".to_string(),
    641         "1730".to_string(),
    642     ]);
    643     tags.push(vec!["expires_at".to_string(), "1740".to_string()]);
    644     tags.push(vec!["delivery".to_string(), "pickup".to_string()]);
    645     tags.push(vec!["image".to_string(), " ".to_string()]);
    646     tags.push(vec!["g".to_string(), " ".to_string()]);
    647     tags.push(vec![
    648         "image".to_string(),
    649         "https://example.test/a.jpg".to_string(),
    650     ]);
    651     tags.push(vec![
    652         "image".to_string(),
    653         "https://example.test/b.jpg".to_string(),
    654         "bad-size".to_string(),
    655     ]);
    656     let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
    657     let Some(RadrootsListingAvailability::Window { start, end }) = decoded.availability else {
    658         panic!("expected availability window");
    659     };
    660     assert_eq!(start, Some(1730));
    661     assert_eq!(end, Some(1740));
    662     assert!(matches!(
    663         decoded.delivery_method,
    664         Some(RadrootsListingDeliveryMethod::Pickup)
    665     ));
    666     assert_eq!(decoded.images.as_ref().map(Vec::len), Some(2));
    667     assert!(decoded.images.as_ref().unwrap()[1].size.is_none());
    668 
    669     let mut tags = sample_listing_tags();
    670     tags.push(vec!["delivery".to_string(), "local_delivery".to_string()]);
    671     let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
    672     assert!(matches!(
    673         decoded.delivery_method,
    674         Some(RadrootsListingDeliveryMethod::LocalDelivery)
    675     ));
    676 
    677     let mut tags = sample_listing_tags();
    678     tags.push(vec!["delivery".to_string(), "shipping".to_string()]);
    679     let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
    680     assert!(matches!(
    681         decoded.delivery_method,
    682         Some(RadrootsListingDeliveryMethod::Shipping)
    683     ));
    684 
    685     let mut tags = sample_listing_tags();
    686     tags.push(vec![
    687         "delivery".to_string(),
    688         "other".to_string(),
    689         "bike courier".to_string(),
    690     ]);
    691     let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
    692     let Some(RadrootsListingDeliveryMethod::Other { method }) = decoded.delivery_method else {
    693         panic!("expected other delivery method");
    694     };
    695     assert_eq!(method, "bike courier");
    696 
    697     let mut tags = sample_listing_tags();
    698     tags.push(vec!["delivery".to_string(), "drone".to_string()]);
    699     let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
    700     let Some(RadrootsListingDeliveryMethod::Other { method }) = decoded.delivery_method else {
    701         panic!("expected fallback delivery method");
    702     };
    703     assert_eq!(method, "drone");
    704 
    705     let mut tags = sample_listing_tags();
    706     tags.push(vec!["status".to_string(), "active".to_string()]);
    707     let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
    708     assert!(matches!(
    709         decoded.availability,
    710         Some(RadrootsListingAvailability::Status {
    711             status: RadrootsListingStatus::Active
    712         })
    713     ));
    714 
    715     let mut tags = sample_listing_tags();
    716     tags.push(vec!["status".to_string(), "sold".to_string()]);
    717     let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
    718     assert!(matches!(
    719         decoded.availability,
    720         Some(RadrootsListingAvailability::Status {
    721             status: RadrootsListingStatus::Sold
    722         })
    723     ));
    724 
    725     let mut tags = sample_listing_tags();
    726     tags.push(vec!["status".to_string(), "paused".to_string()]);
    727     let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
    728     let Some(RadrootsListingAvailability::Status {
    729         status: RadrootsListingStatus::Other { value },
    730     }) = decoded.availability
    731     else {
    732         panic!("expected other availability status");
    733     };
    734     assert_eq!(value, "paused");
    735 }
    736 
    737 #[test]
    738 fn listing_from_event_covers_remaining_edge_paths() {
    739     let mut tags = sample_listing_tags();
    740     tags.insert(0, Vec::new());
    741     tags.push(vec!["location".to_string()]);
    742     let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
    743     assert_eq!(decoded.product.location, None);
    744 
    745     let mut tags = sample_listing_tags();
    746     tags.push(vec![
    747         "radroots:plot".to_string(),
    748         format!("{KIND_PLOT}::AAAAAAAAAAAAAAAAAAAAAw"),
    749     ]);
    750     assert_invalid_tag(tags, "radroots:plot");
    751 
    752     let mut tags = sample_listing_tags();
    753     tags.push(vec![
    754         "radroots:primary_bin".to_string(),
    755         "bin-2".to_string(),
    756     ]);
    757     assert_invalid_tag(tags, "radroots:primary_bin");
    758 
    759     let mut tags = sample_listing_tags();
    760     let primary_position = tags
    761         .iter()
    762         .position(|tag| tag.first().map(String::as_str) == Some("radroots:primary_bin"))
    763         .expect("primary bin tag");
    764     tags.insert(
    765         primary_position + 1,
    766         vec!["radroots:primary_bin".to_string(), "bin-2".to_string()],
    767     );
    768     assert_invalid_tag(tags, "radroots:primary_bin");
    769 
    770     let mut tags = sample_listing_tags();
    771     tags.insert(0, vec!["key".to_string(), " ".to_string()]);
    772     tags.push(vec!["key".to_string(), "ignored".to_string()]);
    773     tags.insert(0, vec!["summary".to_string(), " ".to_string()]);
    774     tags.push(vec!["summary".to_string(), "first summary".to_string()]);
    775     tags.push(vec!["summary".to_string(), "ignored summary".to_string()]);
    776     tags.push(vec!["process".to_string(), "null".to_string()]);
    777     tags.push(vec!["lot".to_string(), " null ".to_string()]);
    778     tags.push(vec!["profile".to_string(), "null".to_string()]);
    779     tags.push(vec!["year".to_string(), "null".to_string()]);
    780     let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap();
    781     assert_eq!(decoded.product.key, "sku");
    782     assert_eq!(decoded.product.summary.as_deref(), Some("first summary"));
    783     assert_eq!(decoded.product.process, None);
    784     assert_eq!(decoded.product.lot, None);
    785     assert_eq!(decoded.product.profile, None);
    786     assert_eq!(decoded.product.year, None);
    787 
    788     let mut tags = sample_listing_tags();
    789     tags.push(vec!["radroots:availability_start".to_string()]);
    790     assert_invalid_tag(tags, "radroots:availability_start");
    791 
    792     let mut tags = sample_listing_tags();
    793     tags.push(vec![
    794         "radroots:availability_start".to_string(),
    795         "bad".to_string(),
    796     ]);
    797     assert_invalid_tag(tags, "radroots:availability_start");
    798 }
    799 
    800 #[test]
    801 fn listing_parsed_wrappers_preserve_event_metadata() {
    802     let listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ");
    803     let parts = to_wire_parts(&listing).unwrap();
    804     let data = data_from_event(
    805         "event-id".to_string(),
    806         "author-pubkey".to_string(),
    807         7,
    808         parts.kind,
    809         parts.content.clone(),
    810         parts.tags.clone(),
    811     )
    812     .unwrap();
    813     assert_eq!(data.id, "event-id");
    814     assert_eq!(data.author, "author-pubkey");
    815     assert_eq!(data.published_at, 7);
    816     assert_eq!(data.kind, KIND_LISTING);
    817     assert_eq!(data.data.d_tag, listing.d_tag);
    818 
    819     let parsed = parsed_from_event(
    820         "event-id".to_string(),
    821         "author-pubkey".to_string(),
    822         7,
    823         parts.kind,
    824         parts.content.clone(),
    825         parts.tags.clone(),
    826         "sig".to_string(),
    827     )
    828     .unwrap();
    829     assert_eq!(parsed.event.id, "event-id");
    830     assert_eq!(parsed.event.author, "author-pubkey");
    831     assert_eq!(parsed.event.created_at, 7);
    832     assert_eq!(parsed.event.sig, "sig");
    833     assert_eq!(parsed.data.data.d_tag, listing.d_tag);
    834 
    835     let event = RadrootsNostrEvent {
    836         id: "event-id".to_string(),
    837         author: "author-pubkey".to_string(),
    838         created_at: 7,
    839         kind: parts.kind,
    840         tags: parts.tags,
    841         content: parts.content,
    842         sig: "sig".to_string(),
    843     };
    844     let data = data_from_nostr_event(&event).unwrap();
    845     assert_eq!(data.data.d_tag, listing.d_tag);
    846     let parsed = parsed_from_nostr_event(&event).unwrap();
    847     assert_eq!(parsed.event.sig, "sig");
    848     assert_eq!(parsed.data.data.d_tag, listing.d_tag);
    849 
    850     let err = parsed_from_event(
    851         "event-id".to_string(),
    852         "author-pubkey".to_string(),
    853         7,
    854         KIND_POST,
    855         event.content,
    856         event.tags,
    857         "sig".to_string(),
    858     )
    859     .unwrap_err();
    860     assert!(matches!(
    861         err,
    862         EventParseError::InvalidKind {
    863             expected: "30402 or 30403",
    864             got: KIND_POST
    865         }
    866     ));
    867 }
    868 
    869 #[test]
    870 fn draft_listing_roundtrip_from_event() {
    871     let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ");
    872     listing.published_at = Some(1_781_895_600);
    873     let parts = to_wire_parts_with_kind(&listing, KIND_LISTING_DRAFT).unwrap();
    874 
    875     let decoded = listing_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
    876     assert_eq!(parts.kind, KIND_LISTING_DRAFT);
    877     assert_eq!(parts.content, "# Widget");
    878     assert_eq!(decoded.d_tag, listing.d_tag);
    879     assert_eq!(decoded.published_at, Some(1_781_895_600));
    880 }
    881 
    882 #[test]
    883 fn listing_roundtrips_published_at_for_active_and_rejects_bad_value() {
    884     let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
    885     listing.published_at = Some(1_781_895_600);
    886     let parts = to_wire_parts_with_kind(&listing, KIND_LISTING).unwrap();
    887     assert!(parts.tags.iter().any(|tag| {
    888         tag.first().map(|value| value.as_str()) == Some(TAG_PUBLISHED_AT)
    889             && tag.get(1).map(|value| value.as_str()) == Some("1781895600")
    890     }));
    891 
    892     let decoded = listing_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
    893     assert_eq!(decoded.published_at, Some(1_781_895_600));
    894 
    895     let mut tags = parts.tags;
    896     let published_at = tags
    897         .iter_mut()
    898         .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_PUBLISHED_AT))
    899         .expect("published_at tag");
    900     published_at[1] = "bad".to_string();
    901     let err = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap_err();
    902     assert!(matches!(err, EventParseError::InvalidTag(TAG_PUBLISHED_AT)));
    903 }
    904 
    905 #[test]
    906 fn to_wire_parts_rejects_non_listing_kind() {
    907     let err =
    908         to_wire_parts_with_kind(&sample_listing("AAAAAAAAAAAAAAAAAAAAAg"), KIND_POST).unwrap_err();
    909     assert!(matches!(err, EventEncodeError::InvalidKind(KIND_POST)));
    910 }
    911 
    912 #[test]
    913 fn listing_build_tags_includes_listing_fields() {
    914     let listing = sample_listing_full("AAAAAAAAAAAAAAAAAAAAAg");
    915     let tags = listing_build_tags(&listing).unwrap();
    916 
    917     assert!(tags.iter().any(|t| {
    918         t.get(0).map(|s| s.as_str()) == Some(TAG_D)
    919             && t.get(1).map(|s| s.as_str()) == Some("AAAAAAAAAAAAAAAAAAAAAg")
    920     }));
    921     assert!(tags.iter().any(|t| {
    922         t.get(0).map(|s| s.as_str()) == Some("p")
    923             && t.get(1).map(|s| s.as_str()) == Some("farm_pubkey")
    924     }));
    925     assert!(tags.iter().any(|t| {
    926         t.get(0).map(|s| s.as_str()) == Some("a")
    927             && t.get(1).map(|s| s.as_str()) == Some("30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA")
    928     }));
    929     assert!(tags.iter().any(|t| {
    930         t.get(0).map(|s| s.as_str()) == Some("key") && t.get(1).map(|s| s.as_str()) == Some("sku")
    931     }));
    932     assert!(tags.iter().any(|t| {
    933         t.get(0).map(|s| s.as_str()) == Some("title")
    934             && t.get(1).map(|s| s.as_str()) == Some("Widget")
    935     }));
    936 
    937     let primary_tag = tags
    938         .iter()
    939         .find(|t| t.get(0).map(|s| s.as_str()) == Some("radroots:primary_bin"))
    940         .expect("primary bin tag");
    941     assert_eq!(primary_tag.get(1).map(|s| s.as_str()), Some("bin-1"));
    942 
    943     let bin_tag = tags
    944         .iter()
    945         .find(|t| t.get(0).map(|s| s.as_str()) == Some("radroots:bin"))
    946         .expect("bin tag");
    947     assert_eq!(bin_tag.get(1).map(|s| s.as_str()), Some("bin-1"));
    948     assert_eq!(bin_tag.get(2).map(|s| s.as_str()), Some("1000"));
    949     assert_eq!(bin_tag.get(3).map(|s| s.as_str()), Some("g"));
    950     assert_eq!(bin_tag.get(4).map(|s| s.as_str()), Some("1"));
    951     assert_eq!(bin_tag.get(5).map(|s| s.as_str()), Some("kg"));
    952     assert_eq!(bin_tag.get(6).map(|s| s.as_str()), Some("bag"));
    953 
    954     let price_tag = tags
    955         .iter()
    956         .find(|t| t.get(0).map(|s| s.as_str()) == Some("radroots:price"))
    957         .expect("radroots price tag");
    958     assert_eq!(price_tag.get(1).map(|s| s.as_str()), Some("bin-1"));
    959     assert_eq!(price_tag.get(2).map(|s| s.as_str()), Some("0.01"));
    960     assert_eq!(price_tag.get(3).map(|s| s.as_str()), Some("USD"));
    961     assert_eq!(price_tag.get(4).map(|s| s.as_str()), Some("1"));
    962     assert_eq!(price_tag.get(5).map(|s| s.as_str()), Some("g"));
    963     assert_eq!(price_tag.get(6).map(|s| s.as_str()), Some("10"));
    964     assert_eq!(price_tag.get(7).map(|s| s.as_str()), Some("kg"));
    965 
    966     let generic_price_tag = tags
    967         .iter()
    968         .find(|t| {
    969             t.get(0).map(|s| s.as_str()) == Some("price")
    970                 && t.get(1).map(|s| s.as_str()) == Some("10")
    971         })
    972         .expect("generic price tag");
    973     assert_eq!(generic_price_tag.get(2).map(|s| s.as_str()), Some("USD"));
    974 
    975     let discount_tag = tags
    976         .iter()
    977         .find(|t| t.get(0).map(|s| s.as_str()) == Some("radroots:discount"))
    978         .expect("discount tag");
    979     assert!(
    980         discount_tag
    981             .get(1)
    982             .map(|s| s.contains("\"scope\":\"bin\""))
    983             .unwrap_or(false)
    984     );
    985 
    986     assert!(tags.iter().any(|t| {
    987         t.get(0).map(|s| s.as_str()) == Some("location")
    988             && t.get(1).map(|s| s.as_str()) == Some("Moyobamba")
    989     }));
    990 
    991     let g_tags: Vec<&Vec<String>> = tags
    992         .iter()
    993         .filter(|t| t.get(0).map(|s| s.as_str()) == Some("g"))
    994         .collect();
    995     assert!(!g_tags.is_empty());
    996     let full_len = g_tags[0][1].len();
    997     assert_eq!(g_tags.len(), full_len);
    998     for (idx, tag) in g_tags.iter().enumerate() {
    999         assert_eq!(tag[1].len(), full_len - idx);
   1000     }
   1001     assert!(tags.iter().any(|t| {
   1002         t.get(0).map(|s| s.as_str()) == Some("L") && t.get(1).map(|s| s.as_str()) == Some("dd.lat")
   1003     }));
   1004     assert!(tags.iter().any(|t| {
   1005         t.get(0).map(|s| s.as_str()) == Some("L") && t.get(1).map(|s| s.as_str()) == Some("dd.lon")
   1006     }));
   1007     assert!(tags.iter().any(|t| {
   1008         t.get(0).map(|s| s.as_str()) == Some("l") && t.get(2).map(|s| s.as_str()) == Some("dd.lat")
   1009     }));
   1010     assert!(tags.iter().any(|t| {
   1011         t.get(0).map(|s| s.as_str()) == Some("l") && t.get(2).map(|s| s.as_str()) == Some("dd.lon")
   1012     }));
   1013 
   1014     assert!(tags.iter().any(|t| {
   1015         t.get(0).map(|s| s.as_str()) == Some("image")
   1016             && t.get(1).map(|s| s.as_str()) == Some("http://example.com/widget.jpg")
   1017             && t.get(2).map(|s| s.as_str()) == Some("1200x800")
   1018     }));
   1019 }
   1020 
   1021 #[test]
   1022 fn listing_tags_full_uses_single_generic_price_for_primary_bin() {
   1023     let mut listing = sample_listing_full("AAAAAAAAAAAAAAAAAAAAAw");
   1024     listing.bins.push(RadrootsListingBin {
   1025         bin_id: bin_id("bin-2"),
   1026         quantity: RadrootsCoreQuantity::new(
   1027             RadrootsCoreDecimal::from_str("500").unwrap(),
   1028             RadrootsCoreUnit::MassG,
   1029         ),
   1030         price_per_canonical_unit: RadrootsCoreQuantityPrice::new(
   1031             RadrootsCoreMoney::new(
   1032                 RadrootsCoreDecimal::from_str("0.02").unwrap(),
   1033                 RadrootsCoreCurrency::USD,
   1034             ),
   1035             RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::MassG),
   1036         ),
   1037         display_amount: Some(RadrootsCoreDecimal::from(500u32)),
   1038         display_unit: Some(RadrootsCoreUnit::MassG),
   1039         display_label: Some("sample".to_string()),
   1040         display_price: Some(RadrootsCoreMoney::new(
   1041             RadrootsCoreDecimal::from_str("10").unwrap(),
   1042             RadrootsCoreCurrency::USD,
   1043         )),
   1044         display_price_unit: Some(RadrootsCoreUnit::MassG),
   1045     });
   1046 
   1047     let tags = listing_tags_full(&listing).unwrap();
   1048     let generic_price_tags: Vec<&Vec<String>> = tags
   1049         .iter()
   1050         .filter(|tag| tag.first().map(|value| value.as_str()) == Some("price"))
   1051         .collect();
   1052     assert_eq!(generic_price_tags.len(), 1);
   1053     assert_eq!(
   1054         generic_price_tags[0].get(1).map(|value| value.as_str()),
   1055         Some("10")
   1056     );
   1057     assert_eq!(
   1058         generic_price_tags[0].get(2).map(|value| value.as_str()),
   1059         Some("USD")
   1060     );
   1061 }
   1062 
   1063 #[test]
   1064 fn listing_tags_full_includes_trade_fields() {
   1065     let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
   1066     let inventory = RadrootsCoreDecimal::from_str("12.5").unwrap();
   1067     let inventory_value = inventory.to_string();
   1068     listing.inventory_available = Some(inventory);
   1069     listing.availability = Some(RadrootsListingAvailability::Window {
   1070         start: Some(1730000000),
   1071         end: Some(1731000000),
   1072     });
   1073     listing.delivery_method = Some(RadrootsListingDeliveryMethod::Shipping);
   1074 
   1075     let tags = listing_tags_full(&listing).unwrap();
   1076 
   1077     assert!(tags.iter().any(|t| {
   1078         t.get(0).map(|s| s.as_str()) == Some("inventory")
   1079             && t.get(1).map(|s| s.as_str()) == Some(inventory_value.as_str())
   1080     }));
   1081     assert!(tags.iter().any(|t| {
   1082         t.get(0).map(|s| s.as_str()) == Some("radroots:availability_start")
   1083             && t.get(1).map(|s| s.as_str()) == Some("1730000000")
   1084     }));
   1085     assert!(tags.iter().any(|t| {
   1086         t.get(0).map(|s| s.as_str()) == Some("expires_at")
   1087             && t.get(1).map(|s| s.as_str()) == Some("1731000000")
   1088     }));
   1089     assert!(tags.iter().any(|t| {
   1090         t.get(0).map(|s| s.as_str()) == Some("delivery")
   1091             && t.get(1).map(|s| s.as_str()) == Some("shipping")
   1092     }));
   1093 }
   1094 
   1095 #[test]
   1096 fn listing_tags_full_includes_status_tag() {
   1097     let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
   1098     listing.availability = Some(RadrootsListingAvailability::Status {
   1099         status: RadrootsListingStatus::Active,
   1100     });
   1101 
   1102     let tags = listing_tags_full(&listing).unwrap();
   1103 
   1104     assert!(tags.iter().any(|t| {
   1105         t.get(0).map(|s| s.as_str()) == Some("status")
   1106             && t.get(1).map(|s| s.as_str()) == Some("active")
   1107     }));
   1108 }
   1109 
   1110 #[test]
   1111 fn listing_build_tags_ignores_null_strings() {
   1112     let mut listing = sample_listing_full("AAAAAAAAAAAAAAAAAAAAAg");
   1113     listing.product.summary = Some("null".to_string());
   1114     listing.product.process = Some("null".to_string());
   1115     listing.product.lot = Some("null".to_string());
   1116     listing.product.location = Some("null".to_string());
   1117     listing.product.profile = Some("null".to_string());
   1118     listing.product.year = Some("null".to_string());
   1119     listing.location = Some(RadrootsListingLocation {
   1120         primary: "Moyobamba".to_string(),
   1121         city: Some("null".to_string()),
   1122         region: Some("San Martin".to_string()),
   1123         country: Some("null".to_string()),
   1124         lat: Some(-6.0346),
   1125         lng: Some(-76.9714),
   1126         geohash: None,
   1127     });
   1128     listing.images = Some(vec![RadrootsListingImage {
   1129         url: "null".to_string(),
   1130         size: None,
   1131     }]);
   1132 
   1133     let tags = listing_build_tags(&listing).unwrap();
   1134     assert!(
   1135         !tags
   1136             .iter()
   1137             .any(|tag| tag.iter().any(|value| value == "null"))
   1138     );
   1139 }
   1140 
   1141 #[test]
   1142 fn listing_tags_with_options_cover_location_fallback_paths() {
   1143     let mut geohash_only = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
   1144     geohash_only.location = Some(RadrootsListingLocation {
   1145         primary: "Moyobamba".to_string(),
   1146         city: None,
   1147         region: None,
   1148         country: None,
   1149         lat: None,
   1150         lng: None,
   1151         geohash: Some("6gkzwgjzn".to_string()),
   1152     });
   1153     let tags = listing_tags_with_options(&geohash_only, ListingTagOptions::default()).unwrap();
   1154     assert!(
   1155         tags.iter()
   1156             .any(|tag| tag.get(0).map(|value| value.as_str()) == Some("g"))
   1157     );
   1158     assert!(tags.iter().any(|tag| {
   1159         tag.get(0).map(|value| value.as_str()) == Some("l")
   1160             && tag.get(2).map(|value| value.as_str()) == Some("dd")
   1161     }));
   1162 
   1163     let mut no_coordinates = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ");
   1164     no_coordinates.location = Some(RadrootsListingLocation {
   1165         primary: "Moyobamba".to_string(),
   1166         city: None,
   1167         region: None,
   1168         country: None,
   1169         lat: None,
   1170         lng: None,
   1171         geohash: None,
   1172     });
   1173     let tags = listing_tags_with_options(&no_coordinates, ListingTagOptions::default()).unwrap();
   1174     assert!(
   1175         !tags
   1176             .iter()
   1177             .any(|tag| tag.get(0).map(|value| value.as_str()) == Some("L"))
   1178     );
   1179     assert!(
   1180         !tags
   1181             .iter()
   1182             .any(|tag| tag.get(0).map(|value| value.as_str()) == Some("g"))
   1183     );
   1184 
   1185     let mut no_gps = sample_listing("AAAAAAAAAAAAAAAAAAAAAw");
   1186     no_gps.location = Some(RadrootsListingLocation {
   1187         primary: "Moyobamba".to_string(),
   1188         city: None,
   1189         region: None,
   1190         country: None,
   1191         lat: Some(-6.0346),
   1192         lng: Some(-76.9714),
   1193         geohash: None,
   1194     });
   1195     let tags = listing_tags_with_options(
   1196         &no_gps,
   1197         ListingTagOptions {
   1198             include_gps: false,
   1199             ..ListingTagOptions::default()
   1200         },
   1201     )
   1202     .unwrap();
   1203     assert!(
   1204         tags.iter()
   1205             .any(|tag| tag.get(0).map(|value| value.as_str()) == Some("g"))
   1206     );
   1207     assert!(
   1208         !tags
   1209             .iter()
   1210             .any(|tag| tag.get(0).map(|value| value.as_str()) == Some("L"))
   1211     );
   1212 }