lib

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

food_availability_conformance.rs (27927B)


      1 #![cfg(feature = "json")]
      2 
      3 use std::{borrow::Cow, collections::BTreeSet, fs, path::Path};
      4 
      5 use nostr::secp256k1::Message;
      6 use nostr::{Event as NostrEvent, JsonUtil, Keys, SECP256K1};
      7 use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256};
      8 use radroots_event::food::availability::{
      9     FoodAvailabilityDetails, FoodAvailabilityDetailsParts, FoodAvailabilityError,
     10     FoodAvailabilityImage, FoodAvailabilityStatus, FoodContent, FoodCurrency, FoodIdentifier,
     11     FoodImageDimensions, FoodPrice, FoodPublishedAt, FoodQuantity, FoodText, FoodUnit,
     12 };
     13 use radroots_event::media::AuthoredImage;
     14 use radroots_event::wire::{Nip01EventWire, compute_canonical_nip01_event_id};
     15 use radroots_event::{
     16     envelope::EventEnvelope, envelope::EventEnvelopeParts,
     17     listing::classified::ClassifiedListingPartition,
     18 };
     19 use radroots_event_codec::admission::food_availability::{
     20     RadrootsFoodAvailabilityAdmissionError, RadrootsFoodAvailabilityAdmissionOutcome,
     21     verify_and_admit_food_availability_event,
     22 };
     23 use radroots_event_codec::admission::food_availability::{
     24     RadrootsFoodAvailabilityRevisionError, validate_food_availability_revision,
     25 };
     26 use radroots_event_codec::decode::food_availability::{
     27     RadrootsFoodAvailabilityImageDiagnostic, RadrootsFoodAvailabilityProjectionError,
     28     RadrootsFoodAvailabilityProjectionOutcome, RadrootsInboundFoodAvailabilityProjection,
     29     project_verified_food_availability_event,
     30 };
     31 use radroots_event_codec::encode::food_availability::{
     32     RadrootsFoodAvailabilityEncodeError, authored_food_availability_build_tags,
     33     authored_food_availability_to_wire_parts,
     34 };
     35 use radroots_event_codec::verify::{RadrootsSignatureVerifiedEvent, verify_nip01_event};
     36 use serde::Deserialize;
     37 use serde_json::{Value, json};
     38 
     39 const SECRET_KEY_ONE: &str = "0000000000000000000000000000000000000000000000000000000000000001";
     40 const SECRET_KEY_TWO: &str = "0000000000000000000000000000000000000000000000000000000000000002";
     41 const PACKAGED_VECTORS: &str = include_str!("fixtures/food_availability_profile.v1.json");
     42 const WORKSPACE_VECTOR_PATH: &str =
     43     "../../contracts/conformance/vectors/food_availability/profile.v1.json";
     44 const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml";
     45 
     46 const VECTOR_IDS: [&str; 40] = [
     47     "food_authored_unit_g_001",
     48     "food_authored_unit_kg_002",
     49     "food_authored_unit_lb_003",
     50     "food_authored_unit_oz_004",
     51     "food_authored_unit_each_005",
     52     "food_authored_unit_dozen_006",
     53     "food_authored_unit_bunch_007",
     54     "food_authored_unit_punnet_008",
     55     "food_authored_unit_bag_009",
     56     "food_authored_unit_basket_010",
     57     "food_authored_wire_budget_ascii_max_011",
     58     "food_authored_wire_budget_escaped_overflow_012",
     59     "food_authored_future_published_at_013",
     60     "food_admission_normalizes_decimal_currency_014",
     61     "food_admission_optional_standard_tags_015",
     62     "food_admission_excludes_operational_before_validation_016",
     63     "food_admission_excludes_generic_nip99_017",
     64     "food_admission_rejects_ambiguous_markers_018",
     65     "food_admission_rejects_wrong_kind_019",
     66     "food_admission_rejects_core_tag_shape_020",
     67     "food_admission_rejects_prohibited_delivery_021",
     68     "food_admission_rejects_price_frequency_022",
     69     "food_admission_requires_price_unit_023",
     70     "food_admission_bounds_raw_decimal_digits_024",
     71     "food_admission_rejects_malformed_price_unit_025",
     72     "food_admission_rejects_quantity_unit_mismatch_026",
     73     "food_admission_rejects_status_027",
     74     "food_admission_rejects_future_published_at_028",
     75     "food_admission_preserves_ordered_image_diagnostics_029",
     76     "food_admission_bounds_image_projection_030",
     77     "food_admission_rejects_invalid_signature_031",
     78     "food_revision_accepts_later_created_at_032",
     79     "food_revision_rejects_invalid_previous_033",
     80     "food_revision_rejects_invalid_current_034",
     81     "food_revision_rejects_identifier_coordinate_change_035",
     82     "food_revision_rejects_author_coordinate_change_036",
     83     "food_revision_rejects_published_at_change_037",
     84     "food_revision_rejects_older_created_at_038",
     85     "food_revision_equal_time_a_current_039",
     86     "food_revision_equal_time_b_current_040",
     87 ];
     88 
     89 #[derive(Debug, Deserialize)]
     90 #[serde(deny_unknown_fields)]
     91 struct Suite {
     92     suite: String,
     93     contract_version: String,
     94     vectors: Vec<Vector>,
     95 }
     96 
     97 #[derive(Debug, Deserialize)]
     98 #[serde(deny_unknown_fields)]
     99 struct Vector {
    100     id: String,
    101     kind: String,
    102     input: Value,
    103     expected: Value,
    104 }
    105 
    106 #[derive(Debug, Deserialize)]
    107 #[serde(deny_unknown_fields)]
    108 struct AuthoredInput {
    109     details: DetailsInput,
    110     created_at: u64,
    111 }
    112 
    113 #[derive(Debug, Deserialize)]
    114 #[serde(deny_unknown_fields)]
    115 struct DetailsInput {
    116     content: TextRecipe,
    117     identifier: String,
    118     title: String,
    119     summary: String,
    120     published_at: u64,
    121     location: String,
    122     price: PriceInput,
    123     quantity: Option<QuantityInput>,
    124     status: String,
    125     images: Vec<ImageInput>,
    126 }
    127 
    128 #[derive(Debug, Deserialize)]
    129 #[serde(untagged)]
    130 enum TextRecipe {
    131     Exact(ExactText),
    132     Repeat(RepeatText),
    133 }
    134 
    135 #[derive(Debug, Deserialize)]
    136 #[serde(deny_unknown_fields)]
    137 struct ExactText {
    138     value: String,
    139 }
    140 
    141 #[derive(Debug, Deserialize)]
    142 #[serde(deny_unknown_fields)]
    143 struct RepeatText {
    144     repeat: String,
    145     count: usize,
    146 }
    147 
    148 #[derive(Debug, Deserialize)]
    149 #[serde(deny_unknown_fields)]
    150 struct PriceInput {
    151     amount: String,
    152     currency: String,
    153     unit: String,
    154 }
    155 
    156 #[derive(Debug, Deserialize)]
    157 #[serde(deny_unknown_fields)]
    158 struct QuantityInput {
    159     amount: String,
    160     unit: String,
    161 }
    162 
    163 #[derive(Debug, Deserialize)]
    164 #[serde(deny_unknown_fields)]
    165 struct ImageInput {
    166     bytes_utf8: String,
    167     url: String,
    168     media_type: String,
    169     uploaded_at: u64,
    170     dimensions: String,
    171 }
    172 
    173 #[derive(Debug, Deserialize)]
    174 #[serde(deny_unknown_fields)]
    175 struct EventInput {
    176     event: Value,
    177 }
    178 
    179 #[derive(Debug, Deserialize)]
    180 #[serde(deny_unknown_fields)]
    181 struct RevisionInput {
    182     previous: Value,
    183     current: Value,
    184 }
    185 
    186 #[derive(Debug, Deserialize)]
    187 #[serde(deny_unknown_fields)]
    188 struct RawEvent {
    189     id: String,
    190     pubkey: String,
    191     created_at: u64,
    192     kind: u32,
    193     tags: Vec<Vec<String>>,
    194     content: String,
    195     sig: String,
    196 }
    197 
    198 #[test]
    199 fn checked_in_food_availability_profile_vectors_execute() {
    200     let suite = parse_suite();
    201     for vector in &suite.vectors {
    202         execute(vector);
    203     }
    204 }
    205 
    206 #[test]
    207 fn strict_revision_rejects_a_direct_envelope_above_the_authored_wire_bound() {
    208     let suite = parse_suite();
    209     let vector = suite
    210         .vectors
    211         .iter()
    212         .find(|vector| vector.id == "food_revision_accepts_later_created_at_032")
    213         .expect("revision fixture");
    214     let input = revision_input(vector);
    215     let previous = verified_event(&input.previous, vector.id.as_str());
    216     let mut current = raw_event(&input.current, vector.id.as_str());
    217     current.content = core::iter::repeat_n('\0', 64 * 1024).collect();
    218     let current = directly_signed_verified_event(current, vector.id.as_str());
    219 
    220     let error = validate_food_availability_revision(&previous, &current)
    221         .expect_err("oversized strict revision must fail");
    222     match error {
    223         RadrootsFoodAvailabilityRevisionError::CurrentInvalid(error) => {
    224             assert_eq!(error.code(), "food_event_wire_too_large");
    225         }
    226         error => panic!("unexpected strict revision error: {error:?}"),
    227     }
    228 }
    229 
    230 fn parse_suite() -> Suite {
    231     let vectors = conformance_vectors();
    232     let suite: Suite = serde_json::from_str(&vectors)
    233         .unwrap_or_else(|error| panic!("FoodAvailability vectors must parse: {error}"));
    234     assert_eq!(suite.suite, "food_availability_profile");
    235     assert_eq!(suite.contract_version, "1.0.0");
    236     assert_eq!(suite.vectors.len(), VECTOR_IDS.len());
    237 
    238     let actual = suite
    239         .vectors
    240         .iter()
    241         .map(|vector| vector.id.as_str())
    242         .collect::<BTreeSet<_>>();
    243     assert_eq!(actual.len(), suite.vectors.len(), "duplicate vector ids");
    244     let expected = VECTOR_IDS.iter().copied().collect::<BTreeSet<_>>();
    245     assert_eq!(actual, expected, "FoodAvailability vector inventory drift");
    246     suite
    247 }
    248 
    249 fn conformance_vectors() -> Cow<'static, str> {
    250     let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(WORKSPACE_VECTOR_PATH);
    251     match fs::read_to_string(&workspace_path) {
    252         Ok(canonical) => {
    253             assert_eq!(
    254                 canonical,
    255                 PACKAGED_VECTORS,
    256                 "packaged FoodAvailability vectors must match {}",
    257                 workspace_path.display()
    258             );
    259             Cow::Owned(canonical)
    260         }
    261         Err(error)
    262             if error.kind() == std::io::ErrorKind::NotFound
    263                 && !Path::new(env!("CARGO_MANIFEST_DIR"))
    264                     .join(WORKSPACE_CONTRACT_MARKER_PATH)
    265                     .is_file() =>
    266         {
    267             Cow::Borrowed(PACKAGED_VECTORS)
    268         }
    269         Err(error) => panic!("failed to read {}: {error}", workspace_path.display()),
    270     }
    271 }
    272 
    273 fn execute(vector: &Vector) {
    274     match vector.kind.as_str() {
    275         "food_availability.build_authored_draft.valid" => authored_valid(
    276             vector,
    277             vector.expected["wire_parts"]
    278                 .get("content_length")
    279                 .is_some(),
    280         ),
    281         "food_availability.build_authored_draft.invalid" => authored_invalid(vector),
    282         "food_availability.project_verified_event.valid" => projection_valid(vector),
    283         "food_availability.project_verified_event.invalid" => projection_invalid(vector),
    284         "food_availability.verify_and_admit_event.valid" => verify_and_admit_valid(vector),
    285         "food_availability.verify_and_admit_event.invalid" => verify_and_admit_invalid(vector),
    286         "food_availability.validate_revision.valid" => revision_valid(vector),
    287         "food_availability.validate_revision.invalid" => revision_invalid(vector),
    288         kind => panic!("{} uses unsupported vector kind {kind}", vector.id),
    289     }
    290 }
    291 
    292 fn authored_valid(vector: &Vector, summarized_content: bool) {
    293     let input = authored_input(vector);
    294     let (details, content) = strict_details(&input.details, &vector.id);
    295     let tags = authored_food_availability_build_tags(&details, input.created_at)
    296         .unwrap_or_else(|error| panic!("{} tags failed: {error}", vector.id));
    297     let wire = authored_food_availability_to_wire_parts(&details, input.created_at)
    298         .unwrap_or_else(|error| panic!("{} wire failed: {error}", vector.id));
    299     assert_eq!(wire.tags, tags, "{}", vector.id);
    300     assert_eq!(wire.content, content, "{}", vector.id);
    301 
    302     let actual = if summarized_content {
    303         json!({
    304             "wire_parts": {
    305                 "kind": wire.kind,
    306                 "content_length": wire.content.len(),
    307                 "tags": wire.tags,
    308             }
    309         })
    310     } else {
    311         json!({
    312             "wire_parts": {
    313                 "kind": wire.kind,
    314                 "content": wire.content,
    315                 "tags": wire.tags,
    316             }
    317         })
    318     };
    319     assert_eq!(actual, vector.expected, "{}", vector.id);
    320 }
    321 
    322 fn authored_invalid(vector: &Vector) {
    323     let input = authored_input(vector);
    324     let (details, _) = strict_details(&input.details, &vector.id);
    325     let error = authored_food_availability_to_wire_parts(&details, input.created_at)
    326         .expect_err("invalid authored vector must fail");
    327     assert_eq!(
    328         authored_error_value(&error),
    329         vector.expected["error"],
    330         "{}",
    331         vector.id
    332     );
    333 }
    334 
    335 fn authored_input(vector: &Vector) -> AuthoredInput {
    336     serde_json::from_value(vector.input.clone())
    337         .unwrap_or_else(|error| panic!("{} authored input is invalid: {error}", vector.id))
    338 }
    339 
    340 fn strict_details(input: &DetailsInput, vector_id: &str) -> (FoodAvailabilityDetails, String) {
    341     let content = input.content.materialize(vector_id);
    342     let price_unit = FoodUnit::parse(&input.price.unit)
    343         .unwrap_or_else(|error| panic!("{vector_id} price unit failed: {error}"));
    344     let price = FoodPrice::new(
    345         input.price.amount.clone(),
    346         FoodCurrency::parse(&input.price.currency)
    347             .unwrap_or_else(|error| panic!("{vector_id} currency failed: {error}")),
    348         price_unit,
    349     )
    350     .unwrap_or_else(|error| panic!("{vector_id} price failed: {error}"));
    351     let quantity = input.quantity.as_ref().map(|quantity| {
    352         let unit = FoodUnit::parse(&quantity.unit)
    353             .unwrap_or_else(|error| panic!("{vector_id} quantity unit failed: {error}"));
    354         FoodQuantity::new(quantity.amount.clone(), unit)
    355             .unwrap_or_else(|error| panic!("{vector_id} quantity failed: {error}"))
    356     });
    357     let images = input
    358         .images
    359         .iter()
    360         .map(|image| strict_image(image, vector_id))
    361         .collect();
    362     let details = FoodAvailabilityDetails::new(FoodAvailabilityDetailsParts {
    363         content: FoodContent::new(content.clone())
    364             .unwrap_or_else(|error| panic!("{vector_id} content failed: {error}")),
    365         identifier: FoodIdentifier::parse(&input.identifier)
    366             .unwrap_or_else(|error| panic!("{vector_id} identifier failed: {error}")),
    367         title: FoodText::new(input.title.clone())
    368             .unwrap_or_else(|error| panic!("{vector_id} title failed: {error}")),
    369         summary: FoodText::new(input.summary.clone())
    370             .unwrap_or_else(|error| panic!("{vector_id} summary failed: {error}")),
    371         published_at: FoodPublishedAt::new(input.published_at)
    372             .unwrap_or_else(|error| panic!("{vector_id} published_at failed: {error}")),
    373         location: FoodText::new(input.location.clone())
    374             .unwrap_or_else(|error| panic!("{vector_id} location failed: {error}")),
    375         price,
    376         quantity,
    377         status: FoodAvailabilityStatus::parse(&input.status)
    378             .unwrap_or_else(|error| panic!("{vector_id} status failed: {error}")),
    379         images,
    380     })
    381     .unwrap_or_else(|error| panic!("{vector_id} details failed: {error}"));
    382     (details, content)
    383 }
    384 
    385 fn strict_image(input: &ImageInput, vector_id: &str) -> FoodAvailabilityImage {
    386     let bytes = input.bytes_utf8.as_bytes();
    387     let hash = Sha256::digest(bytes);
    388     let media_type = MediaType::parse(&input.media_type)
    389         .unwrap_or_else(|error| panic!("{vector_id} image media type failed: {error}"));
    390     let verified = BlobDescriptor::new(
    391         BlobUrl::parse(&input.url)
    392             .unwrap_or_else(|error| panic!("{vector_id} image URL failed: {error}")),
    393         hash,
    394         bytes.len() as u64,
    395         media_type.clone(),
    396         input.uploaded_at,
    397     )
    398     .unwrap_or_else(|error| panic!("{vector_id} image descriptor failed: {error}"))
    399     .approve_reference()
    400     .unwrap_or_else(|error| panic!("{vector_id} image reference failed: {error}"))
    401     .verify_bytes(bytes, &media_type)
    402     .unwrap_or_else(|error| panic!("{vector_id} image bytes failed: {error}"));
    403     let image = AuthoredImage::try_from_verified_descriptor(verified)
    404         .unwrap_or_else(|error| panic!("{vector_id} authored image failed: {error}"));
    405     let dimensions = FoodImageDimensions::parse(&input.dimensions)
    406         .unwrap_or_else(|error| panic!("{vector_id} image dimensions failed: {error}"));
    407     FoodAvailabilityImage::new(image, dimensions)
    408 }
    409 
    410 impl TextRecipe {
    411     fn materialize(&self, vector_id: &str) -> String {
    412         match self {
    413             Self::Exact(value) => value.value.clone(),
    414             Self::Repeat(value) => {
    415                 assert_eq!(
    416                     value.repeat.chars().count(),
    417                     1,
    418                     "{vector_id} repeat value must be one character"
    419                 );
    420                 value.repeat.repeat(value.count)
    421             }
    422         }
    423     }
    424 }
    425 
    426 fn authored_error_value(error: &RadrootsFoodAvailabilityEncodeError) -> Value {
    427     match error {
    428         RadrootsFoodAvailabilityEncodeError::Domain(FoodAvailabilityError::PublishedAtFuture {
    429             published_at,
    430             created_at,
    431         }) => json!({
    432             "code": error.code(),
    433             "message": error.to_string(),
    434             "published_at": published_at,
    435             "created_at": created_at,
    436         }),
    437         RadrootsFoodAvailabilityEncodeError::EventWireTooLarge { max, actual } => json!({
    438             "code": error.code(),
    439             "message": error.to_string(),
    440             "max": max,
    441             "actual": actual,
    442         }),
    443         _ => json!({
    444             "code": error.code(),
    445             "message": error.to_string(),
    446         }),
    447     }
    448 }
    449 
    450 fn projection_valid(vector: &Vector) {
    451     let input = event_input(vector);
    452     let verified = verified_event(&input.event, &vector.id);
    453     let event_id = verified.event().id_hex().to_string();
    454     let outcome = project_verified_food_availability_event(&verified)
    455         .unwrap_or_else(|error| panic!("{} projection failed: {error}", vector.id));
    456     let actual = match outcome {
    457         RadrootsFoodAvailabilityProjectionOutcome::Focused(projection)
    458             if vector.expected.get("projection").is_some() =>
    459         {
    460             json!({
    461                 "outcome": "focused",
    462                 "event_id": event_id,
    463                 "projection": projection_value(&projection),
    464             })
    465         }
    466         RadrootsFoodAvailabilityProjectionOutcome::Focused(projection) => json!({
    467             "outcome": "focused",
    468             "event_id": event_id,
    469             "image_count": projection.images().len(),
    470             "diagnostics": diagnostic_codes(projection.diagnostics()),
    471             "first_raw_tag": projection.images().first().expect("first bounded image").raw_tag(),
    472             "last_raw_tag": projection.images().last().expect("last bounded image").raw_tag(),
    473         }),
    474         RadrootsFoodAvailabilityProjectionOutcome::Excluded(partition) => json!({
    475             "outcome": "excluded",
    476             "event_id": event_id,
    477             "partition": partition_name(partition),
    478         }),
    479         _ => panic!("{} returned an unsupported projection outcome", vector.id),
    480     };
    481     assert_eq!(actual, vector.expected, "{}", vector.id);
    482 }
    483 
    484 fn projection_invalid(vector: &Vector) {
    485     let input = event_input(vector);
    486     let verified = verified_event(&input.event, &vector.id);
    487     let error = project_verified_food_availability_event(&verified)
    488         .expect_err("invalid focused FoodAvailability projection vector must fail");
    489     assert_eq!(
    490         projection_error_value(&error),
    491         vector.expected["error"],
    492         "{}",
    493         vector.id
    494     );
    495 }
    496 
    497 fn verify_and_admit_valid(vector: &Vector) {
    498     let input = event_input(vector);
    499     let verified = verified_event(&input.event, &vector.id);
    500     let outcome = verify_and_admit_food_availability_event(verified.into_event())
    501         .unwrap_or_else(|error| panic!("{} admission failed: {error}", vector.id));
    502     let admitted = match outcome {
    503         RadrootsFoodAvailabilityAdmissionOutcome::Admitted(admitted) => *admitted,
    504         RadrootsFoodAvailabilityAdmissionOutcome::Excluded(excluded) => {
    505             panic!(
    506                 "{} was unexpectedly excluded as {:?}",
    507                 vector.id,
    508                 excluded.partition()
    509             )
    510         }
    511         _ => panic!("{} returned an unsupported admission outcome", vector.id),
    512     };
    513     let actual = json!({
    514         "outcome": "admitted",
    515         "event_id": admitted.event().id_hex(),
    516         "projection": projection_value(admitted.projection()),
    517     });
    518     assert_eq!(actual, vector.expected, "{}", vector.id);
    519 }
    520 
    521 fn verify_and_admit_invalid(vector: &Vector) {
    522     let input = event_input(vector);
    523     let envelope = canonical_envelope(&input.event, &vector.id);
    524     let error = verify_and_admit_food_availability_event(envelope)
    525         .expect_err("invalid signature vector must fail verification");
    526     assert_eq!(
    527         admission_error_value(&error),
    528         vector.expected["error"],
    529         "{}",
    530         vector.id
    531     );
    532 }
    533 
    534 fn event_input(vector: &Vector) -> EventInput {
    535     serde_json::from_value(vector.input.clone())
    536         .unwrap_or_else(|error| panic!("{} event input is invalid: {error}", vector.id))
    537 }
    538 
    539 fn admission_error_value(error: &RadrootsFoodAvailabilityAdmissionError) -> Value {
    540     json!({
    541         "code": error.code(),
    542         "message": error.to_string(),
    543     })
    544 }
    545 
    546 fn projection_error_value(error: &RadrootsFoodAvailabilityProjectionError) -> Value {
    547     json!({
    548         "code": error.code(),
    549         "message": error.to_string(),
    550     })
    551 }
    552 
    553 fn projection_value(projection: &RadrootsInboundFoodAvailabilityProjection) -> Value {
    554     let quantity = projection.quantity().map(|quantity| {
    555         json!({
    556             "amount": quantity.amount(),
    557             "unit": quantity.unit().as_str(),
    558         })
    559     });
    560     let images = projection
    561         .images()
    562         .iter()
    563         .map(|image| {
    564             let dimensions = image.dimensions().map(|dimensions| {
    565                 json!({
    566                     "width": dimensions.width(),
    567                     "height": dimensions.height(),
    568                 })
    569             });
    570             json!({
    571                 "raw_tag": image.raw_tag(),
    572                 "url": image.url(),
    573                 "dimensions": dimensions,
    574                 "diagnostics": diagnostic_codes(image.diagnostics()),
    575                 "qualifies": image.qualifies(),
    576             })
    577         })
    578         .collect::<Vec<_>>();
    579     json!({
    580         "content": projection.content().as_str(),
    581         "identifier": projection.identifier().as_str(),
    582         "title": projection.title().as_str(),
    583         "summary": projection.summary().as_str(),
    584         "published_at": projection.published_at().as_u64(),
    585         "location": projection.location().as_str(),
    586         "price": {
    587             "amount": projection.price().amount(),
    588             "currency": projection.price().currency().as_str(),
    589             "unit": projection.price().unit().as_str(),
    590         },
    591         "quantity": quantity,
    592         "status": projection.status().as_str(),
    593         "images": images,
    594         "diagnostics": diagnostic_codes(projection.diagnostics()),
    595     })
    596 }
    597 
    598 fn diagnostic_codes(diagnostics: &[RadrootsFoodAvailabilityImageDiagnostic]) -> Vec<&'static str> {
    599     diagnostics
    600         .iter()
    601         .map(|diagnostic| diagnostic.code())
    602         .collect()
    603 }
    604 
    605 fn partition_name(partition: ClassifiedListingPartition) -> &'static str {
    606     match partition {
    607         ClassifiedListingPartition::FocusedFoodAvailability => "focused_food_availability",
    608         ClassifiedListingPartition::OperationalListing => "operational_listing",
    609         ClassifiedListingPartition::GenericNip99 => "generic_nip99",
    610         ClassifiedListingPartition::Ambiguous => "ambiguous",
    611     }
    612 }
    613 
    614 fn revision_valid(vector: &Vector) {
    615     let input = revision_input(vector);
    616     let previous = verified_event(&input.previous, &vector.id);
    617     let current = verified_event(&input.current, &vector.id);
    618     validate_food_availability_revision(&previous, &current)
    619         .unwrap_or_else(|error| panic!("{} revision failed: {error}", vector.id));
    620     let actual = json!({
    621         "result": "accepted",
    622         "current_event_id": current.event().id_hex(),
    623     });
    624     assert_eq!(actual, vector.expected, "{}", vector.id);
    625 }
    626 
    627 fn revision_invalid(vector: &Vector) {
    628     let input = revision_input(vector);
    629     let previous = verified_event(&input.previous, &vector.id);
    630     let current = verified_event(&input.current, &vector.id);
    631     let error = validate_food_availability_revision(&previous, &current)
    632         .expect_err("invalid revision vector must fail");
    633     assert_eq!(
    634         revision_error_value(&error),
    635         vector.expected["error"],
    636         "{}",
    637         vector.id
    638     );
    639 }
    640 
    641 fn revision_input(vector: &Vector) -> RevisionInput {
    642     serde_json::from_value(vector.input.clone())
    643         .unwrap_or_else(|error| panic!("{} revision input is invalid: {error}", vector.id))
    644 }
    645 
    646 fn revision_error_value(error: &RadrootsFoodAvailabilityRevisionError) -> Value {
    647     json!({
    648         "code": error.code(),
    649         "message": error.to_string(),
    650     })
    651 }
    652 
    653 fn verified_event(value: &Value, vector_id: &str) -> RadrootsSignatureVerifiedEvent {
    654     let raw = raw_event(value, vector_id);
    655     let raw_json = serde_json::to_string(value).expect("raw event must serialize");
    656     let event = NostrEvent::from_json(&raw_json)
    657         .unwrap_or_else(|error| panic!("{vector_id} is not a NIP-01 event: {error}"));
    658     assert_eq!(event.id.to_string(), raw.id, "{vector_id}");
    659     assert_eq!(event.sig.to_string(), raw.sig, "{vector_id}");
    660     assert_eq!(event.pubkey.to_string(), raw.pubkey, "{vector_id}");
    661     assert_eq!(event.created_at.as_secs(), raw.created_at, "{vector_id}");
    662     assert_eq!(u32::from(event.kind.as_u16()), raw.kind, "{vector_id}");
    663     assert_eq!(
    664         event
    665             .tags
    666             .iter()
    667             .map(|tag| tag.as_slice().to_vec())
    668             .collect::<Vec<_>>(),
    669         raw.tags,
    670         "{vector_id}"
    671     );
    672     assert_eq!(event.content, raw.content, "{vector_id}");
    673     event
    674         .verify()
    675         .unwrap_or_else(|error| panic!("{vector_id} NIP-01 verification failed: {error}"));
    676 
    677     let keys = keys_for_pubkey(&raw.pubkey, vector_id);
    678     let message = Message::from_digest(event.id.to_bytes());
    679     let deterministic = SECP256K1.sign_schnorr_no_aux_rand(
    680         &message,
    681         &nostr::secp256k1::Keypair::from_secret_key(SECP256K1, keys.secret_key()),
    682     );
    683     assert_eq!(
    684         event.sig, deterministic,
    685         "{vector_id} deterministic signature drift"
    686     );
    687 
    688     verify_nip01_event(canonical_envelope(value, vector_id))
    689         .unwrap_or_else(|error| panic!("{vector_id} Radroots verification failed: {error}"))
    690 }
    691 
    692 fn canonical_envelope(value: &Value, vector_id: &str) -> EventEnvelope {
    693     let raw_json = serde_json::to_string(value).expect("raw event must serialize");
    694     let wire = Nip01EventWire::parse_json(&raw_json)
    695         .unwrap_or_else(|error| panic!("{vector_id} wire parsing failed: {error}"));
    696     wire.verify_id()
    697         .unwrap_or_else(|error| panic!("{vector_id} event id failed: {error}"));
    698     wire.into_envelope()
    699         .unwrap_or_else(|error| panic!("{vector_id} envelope conversion failed: {error}"))
    700 }
    701 
    702 fn raw_event(value: &Value, vector_id: &str) -> RawEvent {
    703     serde_json::from_value(value.clone())
    704         .unwrap_or_else(|error| panic!("{vector_id} raw event is invalid: {error}"))
    705 }
    706 
    707 fn directly_signed_verified_event(
    708     mut raw: RawEvent,
    709     vector_id: &str,
    710 ) -> RadrootsSignatureVerifiedEvent {
    711     let id = compute_canonical_nip01_event_id(
    712         &raw.pubkey,
    713         raw.created_at,
    714         raw.kind,
    715         &raw.tags,
    716         &raw.content,
    717     )
    718     .unwrap_or_else(|error| panic!("{vector_id} direct event id failed: {error}"));
    719     let nostr_id = nostr::EventId::from_hex(&id.to_hex())
    720         .unwrap_or_else(|error| panic!("{vector_id} direct event id conversion failed: {error}"));
    721     let keys = keys_for_pubkey(&raw.pubkey, vector_id);
    722     let message = Message::from_digest(nostr_id.to_bytes());
    723     let signature = SECP256K1.sign_schnorr_no_aux_rand(
    724         &message,
    725         &nostr::secp256k1::Keypair::from_secret_key(SECP256K1, keys.secret_key()),
    726     );
    727     raw.id = id.into_string();
    728     raw.sig = signature.to_string();
    729 
    730     let event = EventEnvelope::new(EventEnvelopeParts {
    731         id: raw.id,
    732         author: raw.pubkey,
    733         created_at: raw.created_at,
    734         kind: raw.kind,
    735         tags: raw.tags,
    736         content: raw.content,
    737         sig: raw.sig,
    738     })
    739     .unwrap_or_else(|error| panic!("{vector_id} direct envelope failed: {error}"));
    740     verify_nip01_event(event)
    741         .unwrap_or_else(|error| panic!("{vector_id} direct verification failed: {error}"))
    742 }
    743 
    744 fn keys_for_pubkey(pubkey: &str, vector_id: &str) -> Keys {
    745     for secret in [SECRET_KEY_ONE, SECRET_KEY_TWO] {
    746         let keys = Keys::parse(secret).expect("fixed secret key must parse");
    747         if keys.public_key().to_string() == pubkey {
    748             return keys;
    749         }
    750     }
    751     panic!("{vector_id} uses an ungoverned fixture pubkey {pubkey}")
    752 }