lib

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

operational_listing_conformance.rs (14382B)


      1 #![cfg(feature = "json")]
      2 
      3 use std::{borrow::Cow, collections::BTreeSet, fs, path::Path};
      4 
      5 use radroots_event::{
      6     contract::validate_event_contract_shape, envelope::EventEnvelope, envelope::EventEnvelopeParts,
      7     listing::operational::OperationalListing,
      8 };
      9 use radroots_event_codec::encode::{
     10     EventEncodeError,
     11     operational_listing::{
     12         operational_listing_build_tags, operational_listing_tags_full, to_wire_parts,
     13     },
     14 };
     15 use serde::{Deserialize, de::DeserializeOwned};
     16 use serde_json::{Map, Value};
     17 
     18 const CONTRACT_ID: &str = "radroots.operational_listing.published.v1";
     19 const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml";
     20 const PACKAGED_BUILD_TAGS: &str = include_str!("fixtures/operational_listing_build_tags.v1.json");
     21 const WORKSPACE_BUILD_TAGS_PATH: &str =
     22     "../../contracts/conformance/vectors/operational_listing/build_tags.v1.json";
     23 const PACKAGED_BUILD_DRAFT: &str = include_str!("fixtures/operational_listing_build_draft.v1.json");
     24 const WORKSPACE_BUILD_DRAFT_PATH: &str =
     25     "../../contracts/conformance/vectors/operational_listing/build_draft.v1.json";
     26 const PACKAGED_FULL_TAGS: &str = include_str!("fixtures/operational_listing_tags_full.v1.json");
     27 const WORKSPACE_FULL_TAGS_PATH: &str =
     28     "../../contracts/conformance/vectors/events/operational_listing_tags_full.v1.json";
     29 
     30 #[derive(Debug, Deserialize)]
     31 #[serde(deny_unknown_fields)]
     32 struct Suite<T> {
     33     suite: String,
     34     contract_version: String,
     35     vectors: Vec<Vector<T>>,
     36 }
     37 
     38 #[derive(Debug, Deserialize)]
     39 #[serde(deny_unknown_fields)]
     40 struct Vector<T> {
     41     id: String,
     42     kind: String,
     43     input: ListingInput,
     44     expected: T,
     45 }
     46 
     47 #[derive(Debug, Deserialize)]
     48 #[serde(deny_unknown_fields)]
     49 struct ListingInput {
     50     listing: Value,
     51 }
     52 
     53 #[derive(Debug, Deserialize)]
     54 #[serde(untagged)]
     55 enum TagsExpected {
     56     Success(TagsSuccess),
     57     Failure(EncodeFailure),
     58 }
     59 
     60 #[derive(Debug, Deserialize)]
     61 #[serde(deny_unknown_fields)]
     62 struct TagsSuccess {
     63     tags: Vec<Vec<String>>,
     64 }
     65 
     66 #[derive(Debug, Deserialize)]
     67 #[serde(untagged)]
     68 enum DraftExpected {
     69     Success(DraftSuccess),
     70     Failure(EncodeFailure),
     71 }
     72 
     73 #[derive(Debug, Deserialize)]
     74 #[serde(deny_unknown_fields)]
     75 struct DraftSuccess {
     76     wire_parts: WirePartsExpected,
     77 }
     78 
     79 #[derive(Debug, Deserialize)]
     80 #[serde(deny_unknown_fields)]
     81 struct WirePartsExpected {
     82     kind: u32,
     83     content: String,
     84     tags: Vec<Vec<String>>,
     85 }
     86 
     87 #[derive(Debug, Deserialize)]
     88 #[serde(deny_unknown_fields)]
     89 struct EncodeFailure {
     90     error: EncodeErrorExpected,
     91 }
     92 
     93 #[derive(Debug, Deserialize)]
     94 #[serde(deny_unknown_fields)]
     95 struct EncodeErrorExpected {
     96     code: String,
     97     field: String,
     98 }
     99 
    100 #[test]
    101 fn checked_in_operational_listing_build_tags_vectors_execute() {
    102     let suite: Suite<TagsExpected> = parse_suite(
    103         PACKAGED_BUILD_TAGS,
    104         WORKSPACE_BUILD_TAGS_PATH,
    105         "operational_listing",
    106         &[
    107             "operational_listing_build_tags_empty_bins_002",
    108             "operational_listing_build_tags_victoria_carrots_001",
    109         ],
    110     );
    111 
    112     for vector in &suite.vectors {
    113         let listing = typed_listing(&vector.input.listing, &vector.id);
    114         assert_eq!(
    115             vector.kind, "operational_listing.build_tags",
    116             "{}",
    117             vector.id
    118         );
    119         match &vector.expected {
    120             TagsExpected::Success(expected) => {
    121                 let actual = operational_listing_build_tags(&listing)
    122                     .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id));
    123                 assert_eq!(actual, expected.tags, "{}", vector.id);
    124             }
    125             TagsExpected::Failure(expected) => {
    126                 let error = operational_listing_build_tags(&listing)
    127                     .expect_err("invalid build-tags vector must fail");
    128                 assert_encode_error(&vector.id, &error, &expected.error);
    129             }
    130         }
    131     }
    132 
    133     let mut misspelled = suite.vectors[0].input.listing.clone();
    134     misspelled["product"]["summmary"] = Value::String("misspelled optional field".to_string());
    135     assert_eq!(
    136         validate_listing_fixture_keys(&misspelled),
    137         Err("listing.product contains unknown key `summmary`".to_string())
    138     );
    139 }
    140 
    141 #[test]
    142 fn checked_in_operational_listing_build_draft_vectors_execute() {
    143     let suite: Suite<DraftExpected> = parse_suite(
    144         PACKAGED_BUILD_DRAFT,
    145         WORKSPACE_BUILD_DRAFT_PATH,
    146         "operational_listing",
    147         &[
    148             "operational_listing_build_draft_empty_bins_002",
    149             "operational_listing_build_draft_victoria_carrots_001",
    150         ],
    151     );
    152 
    153     for vector in &suite.vectors {
    154         let listing = typed_listing(&vector.input.listing, &vector.id);
    155         assert_eq!(
    156             vector.kind, "operational_listing.build_draft",
    157             "{}",
    158             vector.id
    159         );
    160         match &vector.expected {
    161             DraftExpected::Success(expected) => {
    162                 let actual = to_wire_parts(&listing)
    163                     .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id));
    164                 assert_eq!(actual.kind, expected.wire_parts.kind, "{}", vector.id);
    165                 assert_eq!(actual.content, expected.wire_parts.content, "{}", vector.id);
    166                 assert_eq!(actual.tags, expected.wire_parts.tags, "{}", vector.id);
    167             }
    168             DraftExpected::Failure(expected) => {
    169                 let error =
    170                     to_wire_parts(&listing).expect_err("invalid build-draft vector must fail");
    171                 assert_encode_error(&vector.id, &error, &expected.error);
    172             }
    173         }
    174     }
    175 }
    176 
    177 #[test]
    178 fn checked_in_operational_listing_full_event_tag_vectors_execute() {
    179     let suite: Suite<TagsExpected> = parse_suite(
    180         PACKAGED_FULL_TAGS,
    181         WORKSPACE_FULL_TAGS_PATH,
    182         "events",
    183         &[
    184             "operational_listing_tags_full_empty_bins_002",
    185             "operational_listing_tags_full_victoria_carrots_001",
    186         ],
    187     );
    188 
    189     for vector in &suite.vectors {
    190         let listing = typed_listing(&vector.input.listing, &vector.id);
    191         assert_eq!(
    192             vector.kind, "operational_listing_tags_full",
    193             "{}",
    194             vector.id
    195         );
    196         match &vector.expected {
    197             TagsExpected::Success(expected) => {
    198                 let actual = operational_listing_tags_full(&listing)
    199                     .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id));
    200                 assert_eq!(actual, expected.tags, "{}", vector.id);
    201 
    202                 let wire = to_wire_parts(&listing)
    203                     .unwrap_or_else(|error| panic!("{} draft failed: {error}", vector.id));
    204                 assert_eq!(wire.tags, actual, "{}", vector.id);
    205                 let event = EventEnvelope::new(EventEnvelopeParts {
    206                     id: "b".repeat(64),
    207                     author: listing.farm.pubkey.clone(),
    208                     created_at: listing.published_at.unwrap_or_default(),
    209                     kind: wire.kind,
    210                     tags: wire.tags,
    211                     content: wire.content,
    212                     sig: "c".repeat(128),
    213                 })
    214                 .unwrap_or_else(|error| panic!("{} envelope failed: {error}", vector.id));
    215                 validate_event_contract_shape(&event, CONTRACT_ID)
    216                     .unwrap_or_else(|error| panic!("{} contract failed: {error:?}", vector.id));
    217             }
    218             TagsExpected::Failure(expected) => {
    219                 let error = operational_listing_tags_full(&listing)
    220                     .expect_err("invalid full-tags vector must fail");
    221                 assert_encode_error(&vector.id, &error, &expected.error);
    222             }
    223         }
    224     }
    225 }
    226 
    227 fn parse_suite<T: DeserializeOwned>(
    228     packaged: &'static str,
    229     workspace_relative: &str,
    230     expected_suite: &str,
    231     expected_ids: &[&str],
    232 ) -> Suite<T> {
    233     let vectors = conformance_vectors(packaged, workspace_relative);
    234     let suite: Suite<T> = serde_json::from_str(&vectors)
    235         .unwrap_or_else(|error| panic!("{workspace_relative} must parse: {error}"));
    236     assert_eq!(suite.suite, expected_suite, "{workspace_relative}");
    237     assert_eq!(suite.contract_version, "1.0.0", "{workspace_relative}");
    238 
    239     let actual_ids = suite
    240         .vectors
    241         .iter()
    242         .map(|vector| vector.id.as_str())
    243         .collect::<BTreeSet<_>>();
    244     assert_eq!(
    245         actual_ids.len(),
    246         suite.vectors.len(),
    247         "{workspace_relative} contains duplicate vector ids"
    248     );
    249     let expected_ids = expected_ids.iter().copied().collect::<BTreeSet<_>>();
    250     assert_eq!(
    251         actual_ids, expected_ids,
    252         "{workspace_relative} inventory drift"
    253     );
    254     suite
    255 }
    256 
    257 fn conformance_vectors(packaged: &'static str, workspace_relative: &str) -> Cow<'static, str> {
    258     let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(workspace_relative);
    259     match fs::read_to_string(&workspace_path) {
    260         Ok(canonical) => {
    261             assert_eq!(
    262                 canonical,
    263                 packaged,
    264                 "packaged vectors must match {}",
    265                 workspace_path.display()
    266             );
    267             Cow::Owned(canonical)
    268         }
    269         Err(error)
    270             if error.kind() == std::io::ErrorKind::NotFound
    271                 && !Path::new(env!("CARGO_MANIFEST_DIR"))
    272                     .join(WORKSPACE_CONTRACT_MARKER_PATH)
    273                     .is_file() =>
    274         {
    275             Cow::Borrowed(packaged)
    276         }
    277         Err(error) => panic!("failed to read {}: {error}", workspace_path.display()),
    278     }
    279 }
    280 
    281 fn assert_encode_error(id: &str, actual: &EventEncodeError, expected: &EncodeErrorExpected) {
    282     assert_eq!(actual.code(), expected.code, "{id}");
    283     match actual {
    284         EventEncodeError::EmptyRequiredField(field) => {
    285             assert_eq!(*field, expected.field, "{id}");
    286         }
    287         other => panic!("{id} returned unexpected encode error {other:?}"),
    288     }
    289 }
    290 
    291 fn typed_listing(raw: &Value, id: &str) -> OperationalListing {
    292     validate_listing_fixture_keys(raw)
    293         .unwrap_or_else(|error| panic!("{id} fixture schema failed: {error}"));
    294     serde_json::from_value(raw.clone())
    295         .unwrap_or_else(|error| panic!("{id} listing must deserialize: {error}"))
    296 }
    297 
    298 fn validate_listing_fixture_keys(raw: &Value) -> Result<(), String> {
    299     let listing = fixture_object(raw, "listing")?;
    300     assert_allowed_keys(
    301         listing,
    302         &[
    303             "d_tag",
    304             "published_at",
    305             "farm",
    306             "product",
    307             "primary_bin_id",
    308             "bins",
    309             "inventory_available",
    310             "availability",
    311             "delivery_method",
    312             "location",
    313         ],
    314         "listing",
    315     )?;
    316 
    317     let farm = required_fixture_object(listing, "farm", "listing")?;
    318     assert_allowed_keys(farm, &["pubkey", "d_tag"], "listing.farm")?;
    319 
    320     let product = required_fixture_object(listing, "product", "listing")?;
    321     assert_allowed_keys(
    322         product,
    323         &["key", "title", "category", "summary"],
    324         "listing.product",
    325     )?;
    326 
    327     let bins = listing
    328         .get("bins")
    329         .and_then(Value::as_array)
    330         .ok_or_else(|| "listing.bins must be an array".to_string())?;
    331     for (index, raw_bin) in bins.iter().enumerate() {
    332         let bin_path = format!("listing.bins[{index}]");
    333         let bin = fixture_object(raw_bin, &bin_path)?;
    334         assert_allowed_keys(
    335             bin,
    336             &["bin_id", "quantity", "price_per_canonical_unit"],
    337             &bin_path,
    338         )?;
    339 
    340         let quantity = required_fixture_object(bin, "quantity", &bin_path)?;
    341         assert_allowed_keys(
    342             quantity,
    343             &["amount", "unit"],
    344             &format!("{bin_path}.quantity"),
    345         )?;
    346 
    347         let price = required_fixture_object(bin, "price_per_canonical_unit", &bin_path)?;
    348         let price_path = format!("{bin_path}.price_per_canonical_unit");
    349         assert_allowed_keys(price, &["amount", "quantity"], &price_path)?;
    350         let money = required_fixture_object(price, "amount", &price_path)?;
    351         assert_allowed_keys(
    352             money,
    353             &["amount", "currency"],
    354             &format!("{price_path}.amount"),
    355         )?;
    356         let price_quantity = required_fixture_object(price, "quantity", &price_path)?;
    357         assert_allowed_keys(
    358             price_quantity,
    359             &["amount", "unit"],
    360             &format!("{price_path}.quantity"),
    361         )?;
    362     }
    363 
    364     if let Some(raw_availability) = listing.get("availability") {
    365         let availability = fixture_object(raw_availability, "listing.availability")?;
    366         assert_allowed_keys(availability, &["kind", "amount"], "listing.availability")?;
    367         let amount = required_fixture_object(availability, "amount", "listing.availability")?;
    368         assert_allowed_keys(amount, &["status"], "listing.availability.amount")?;
    369         let status = required_fixture_object(amount, "status", "listing.availability.amount")?;
    370         assert_allowed_keys(status, &["kind"], "listing.availability.amount.status")?;
    371     }
    372 
    373     if let Some(raw_delivery) = listing.get("delivery_method") {
    374         let delivery = fixture_object(raw_delivery, "listing.delivery_method")?;
    375         assert_allowed_keys(delivery, &["kind"], "listing.delivery_method")?;
    376     }
    377 
    378     if let Some(raw_location) = listing.get("location") {
    379         let location = fixture_object(raw_location, "listing.location")?;
    380         assert_allowed_keys(
    381             location,
    382             &["primary", "city", "region", "country", "geohash"],
    383             "listing.location",
    384         )?;
    385     }
    386 
    387     Ok(())
    388 }
    389 
    390 fn fixture_object<'a>(raw: &'a Value, path: &str) -> Result<&'a Map<String, Value>, String> {
    391     raw.as_object()
    392         .ok_or_else(|| format!("{path} must be an object"))
    393 }
    394 
    395 fn required_fixture_object<'a>(
    396     parent: &'a Map<String, Value>,
    397     key: &str,
    398     parent_path: &str,
    399 ) -> Result<&'a Map<String, Value>, String> {
    400     let path = format!("{parent_path}.{key}");
    401     parent
    402         .get(key)
    403         .ok_or_else(|| format!("{path} is required"))
    404         .and_then(|raw| fixture_object(raw, &path))
    405 }
    406 
    407 fn assert_allowed_keys(
    408     object: &Map<String, Value>,
    409     allowed: &[&str],
    410     path: &str,
    411 ) -> Result<(), String> {
    412     for key in object.keys() {
    413         if !allowed.contains(&key.as_str()) {
    414             return Err(format!("{path} contains unknown key `{key}`"));
    415         }
    416     }
    417     Ok(())
    418 }