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 }