commit 7b0e4b3ef0f7fd41a2dd13318d974d973a04fcd7
parent 8bc581e6809e7065beda32cb31fbb91e8d6bf68c
Author: triesap <tyson@radroots.org>
Date: Tue, 4 Aug 2026 15:32:17 +0000
test: preserve operational listing qualification
Diffstat:
6 files changed, 1769 insertions(+), 1 deletion(-)
diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml
@@ -94,6 +94,7 @@ serde_json = { workspace = true, optional = true, features = ["std"] }
uuid = { workspace = true, optional = true, features = ["v4"] }
[dev-dependencies]
+nostr = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
diff --git a/crates/sdk/src/listing/operational_listing/draft.rs b/crates/sdk/src/listing/operational_listing/draft.rs
@@ -167,3 +167,382 @@ pub fn canonicalize_operational_listing_edit(
RadrootsOperationalListingCanonicalEdit::new(document.listing, seller_pubkey)
}
+
+#[cfg(test)]
+mod tests {
+ const FIXTURE_ALICE_PUBLIC_KEY_HEX: &str =
+ "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df";
+ const FIXTURE_BOB_PUBLIC_KEY_HEX: &str =
+ "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af";
+ use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit};
+ use radroots_event::{
+ envelope::kind::KIND_CLASSIFIED_LISTING,
+ farm::FarmRef,
+ id::{ClassifiedListingAddress, DTag, InventoryBinId},
+ listing::operational::{
+ OperationalListing, OperationalListingAvailability, OperationalListingBin,
+ OperationalListingDeliveryMethod, OperationalListingProduct,
+ OperationalListingPublicLocation, OperationalListingStatus,
+ },
+ trade::validation::OperationalListingValidationError,
+ };
+ use radroots_identity::PublicKey;
+
+ use super::{
+ RadrootsOperationalListingCanonicalEdit, RadrootsOperationalListingEditDocumentV1,
+ RadrootsOperationalListingEditError, canonicalize_operational_listing_edit,
+ };
+
+ const SELLER: &str = FIXTURE_ALICE_PUBLIC_KEY_HEX;
+ const OTHER: &str = FIXTURE_BOB_PUBLIC_KEY_HEX;
+
+ fn d_tag(raw: &str) -> DTag {
+ DTag::parse(raw).expect("d tag")
+ }
+
+ fn bin_id(raw: &str) -> InventoryBinId {
+ InventoryBinId::parse(raw).expect("bin id")
+ }
+
+ fn listing() -> OperationalListing {
+ OperationalListing {
+ d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"),
+ published_at: None,
+ farm: FarmRef {
+ pubkey: SELLER.to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
+ },
+ product: OperationalListingProduct {
+ key: "coffee".to_string(),
+ title: "Coffee".to_string(),
+ category: "coffee".to_string(),
+ summary: Some("Single origin coffee".to_string()),
+ process: None,
+ lot: None,
+ location: None,
+ profile: None,
+ year: None,
+ },
+ primary_bin_id: bin_id("bin-1"),
+ bins: vec![OperationalListingBin {
+ bin_id: bin_id("bin-1"),
+ quantity: Quantity::try_new(Decimal::from(1000u32), Unit::MassG).unwrap(),
+ price_per_canonical_unit: QuantityPrice::try_new(
+ Money::try_new(Decimal::from(20u32), Currency::USD).unwrap(),
+ Quantity::try_new(Decimal::from(1u32), Unit::MassG).unwrap(),
+ )
+ .unwrap(),
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ display_price: None,
+ display_price_unit: None,
+ }],
+ resource_area: None,
+ plot: None,
+ discounts: None,
+ inventory_available: Some(Decimal::from(5u32)),
+ availability: Some(OperationalListingAvailability::Status {
+ status: OperationalListingStatus::Active,
+ }),
+ delivery_method: Some(OperationalListingDeliveryMethod::Pickup),
+ location: Some(OperationalListingPublicLocation {
+ primary: "Victoria".to_string(),
+ city: Some("Victoria".to_string()),
+ region: Some("British Columbia".to_string()),
+ country: Some("CA".to_string()),
+ geohash: "c287g".to_string(),
+ }),
+ images: None,
+ }
+ }
+
+ fn seller_pubkey() -> PublicKey {
+ PublicKey::from_hex(SELLER).expect("seller public key")
+ }
+
+ #[test]
+ fn draft_document_wraps_listing() {
+ let document = RadrootsOperationalListingEditDocumentV1::new(listing());
+
+ assert_eq!(document.listing.d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg");
+ assert_eq!(document.listing.product.title, "Coffee");
+ }
+
+ #[cfg(any())]
+ #[test]
+ fn draft_document_deserializes_as_untrusted_input() {
+ let json = serde_json::to_string(&RadrootsOperationalListingEditDocumentV1::new(listing()))
+ .expect("serialize document");
+
+ let document: RadrootsOperationalListingEditDocumentV1 =
+ serde_json::from_str(&json).expect("deserialize document");
+ let canonical = canonicalize_operational_listing_edit(seller_pubkey(), document)
+ .expect("canonical draft");
+
+ assert_eq!(canonical.seller_pubkey().to_hex(), SELLER);
+ assert_eq!(canonical.listing().product.title, "Coffee");
+ }
+
+ #[test]
+ fn canonical_draft_carries_seller_listing_and_addresses() {
+ let seller_pubkey = PublicKey::from_hex(SELLER).expect("seller");
+ let listing = listing();
+
+ let canonical = RadrootsOperationalListingCanonicalEdit::new(listing, seller_pubkey)
+ .expect("canonical");
+
+ assert_eq!(canonical.seller_pubkey(), &seller_pubkey);
+ assert_eq!(
+ canonical.public_listing_addr().as_str(),
+ format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")
+ );
+ assert_eq!(canonical.listing().d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg");
+ }
+
+ #[test]
+ fn listing_edit_error_variants_are_precise() {
+ assert!(matches!(
+ RadrootsOperationalListingEditError::InvalidFarmPubkey(
+ PublicKey::from_hex("bad").unwrap_err()
+ ),
+ RadrootsOperationalListingEditError::InvalidFarmPubkey(_)
+ ));
+ assert!(matches!(
+ RadrootsOperationalListingEditError::InvalidClassifiedListingAddress(
+ ClassifiedListingAddress::parse("bad").unwrap_err()
+ ),
+ RadrootsOperationalListingEditError::InvalidClassifiedListingAddress(_)
+ ));
+ }
+
+ #[test]
+ fn canonicalize_operational_listing_edit_fills_missing_farm_pubkey_and_derives_address() {
+ let mut listing = listing();
+ listing.farm.pubkey.clear();
+ let document = RadrootsOperationalListingEditDocumentV1::new(listing);
+
+ let canonical = canonicalize_operational_listing_edit(seller_pubkey(), document)
+ .expect("canonical draft");
+
+ assert_eq!(canonical.seller_pubkey().to_hex(), SELLER);
+ assert_eq!(
+ canonical.public_listing_addr().as_str(),
+ format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")
+ );
+ assert_eq!(canonical.listing().farm.pubkey, SELLER);
+ }
+
+ #[test]
+ fn canonicalize_operational_listing_edit_rejects_mismatched_farm_pubkey() {
+ let mut listing = listing();
+ listing.farm.pubkey = OTHER.to_string();
+ let document = RadrootsOperationalListingEditDocumentV1::new(listing);
+
+ let error = canonicalize_operational_listing_edit(seller_pubkey(), document).unwrap_err();
+
+ assert!(matches!(
+ error,
+ RadrootsOperationalListingEditError::FarmPubkeyMismatch { .. }
+ ));
+ }
+
+ #[test]
+ fn canonicalize_operational_listing_edit_rejects_invalid_farm_pubkey() {
+ let mut listing = listing();
+ listing.farm.pubkey = "bad".to_string();
+ let document = RadrootsOperationalListingEditDocumentV1::new(listing);
+
+ let error = canonicalize_operational_listing_edit(seller_pubkey(), document).unwrap_err();
+
+ assert!(matches!(
+ error,
+ RadrootsOperationalListingEditError::InvalidFarmPubkey(_)
+ ));
+ }
+
+ #[test]
+ fn canonical_draft_new_rejects_mismatched_farm_pubkey() {
+ let mut listing = listing();
+ listing.farm.pubkey = OTHER.to_string();
+
+ let error = RadrootsOperationalListingCanonicalEdit::new(
+ listing,
+ PublicKey::from_hex(SELLER).expect("seller"),
+ )
+ .unwrap_err();
+
+ assert!(matches!(
+ error,
+ RadrootsOperationalListingEditError::FarmPubkeyMismatch { .. }
+ ));
+ }
+
+ #[test]
+ fn canonical_draft_new_rejects_invalid_farm_pubkey() {
+ let mut listing = listing();
+ listing.farm.pubkey = "bad".to_string();
+
+ let error = RadrootsOperationalListingCanonicalEdit::new(
+ listing,
+ PublicKey::from_hex(SELLER).expect("seller"),
+ )
+ .unwrap_err();
+
+ assert!(matches!(
+ error,
+ RadrootsOperationalListingEditError::InvalidFarmPubkey(_)
+ ));
+ }
+
+ #[test]
+ fn canonical_draft_new_rejects_empty_farm_pubkey() {
+ let mut listing = listing();
+ listing.farm.pubkey.clear();
+
+ let error = RadrootsOperationalListingCanonicalEdit::new(
+ listing,
+ PublicKey::from_hex(SELLER).expect("seller"),
+ )
+ .unwrap_err();
+
+ assert!(matches!(
+ error,
+ RadrootsOperationalListingEditError::InvalidFarmPubkey(_)
+ ));
+ }
+
+ #[test]
+ fn canonicalize_operational_listing_edit_rejects_missing_primary_bin() {
+ let mut listing = listing();
+ listing.primary_bin_id = bin_id("bin-2");
+ let document = RadrootsOperationalListingEditDocumentV1::new(listing);
+
+ let error = canonicalize_operational_listing_edit(seller_pubkey(), document).unwrap_err();
+
+ assert_eq!(
+ error,
+ RadrootsOperationalListingEditError::MissingPrimaryBin {
+ primary_bin_id: bin_id("bin-2")
+ }
+ );
+ }
+
+ #[test]
+ fn canonical_draft_new_rejects_missing_primary_bin() {
+ let mut listing = listing();
+ listing.primary_bin_id = bin_id("bin-2");
+
+ let error = RadrootsOperationalListingCanonicalEdit::new(
+ listing,
+ PublicKey::from_hex(SELLER).expect("seller"),
+ )
+ .unwrap_err();
+
+ assert_eq!(
+ error,
+ RadrootsOperationalListingEditError::MissingPrimaryBin {
+ primary_bin_id: bin_id("bin-2")
+ }
+ );
+ }
+
+ #[test]
+ fn canonicalize_operational_listing_edit_rejects_duplicate_bin_ids() {
+ let mut listing = listing();
+ listing.bins.push(listing.bins[0].clone());
+ let document = RadrootsOperationalListingEditDocumentV1::new(listing);
+
+ let error = canonicalize_operational_listing_edit(seller_pubkey(), document).unwrap_err();
+
+ assert_eq!(
+ error,
+ RadrootsOperationalListingEditError::DuplicateBinId {
+ bin_id: bin_id("bin-1")
+ }
+ );
+ }
+
+ #[test]
+ fn canonical_draft_new_rejects_invalid_model() {
+ let mut listing = listing();
+ listing.inventory_available = None;
+
+ let error = RadrootsOperationalListingCanonicalEdit::new(
+ listing,
+ PublicKey::from_hex(SELLER).expect("seller"),
+ )
+ .unwrap_err();
+
+ assert_eq!(
+ error,
+ RadrootsOperationalListingEditError::InvalidModel(
+ OperationalListingValidationError::MissingInventory
+ )
+ );
+ }
+
+ #[test]
+ fn canonical_draft_new_rejects_invalid_secondary_bin() {
+ let mut invalid_quantity_listing = listing();
+ let mut secondary_bin = invalid_quantity_listing.bins[0].clone();
+ secondary_bin.bin_id = bin_id("bin-2");
+ secondary_bin.quantity = Quantity::try_new(Decimal::ONE, Unit::MassKg).unwrap();
+ invalid_quantity_listing.bins.push(secondary_bin);
+
+ let error = RadrootsOperationalListingCanonicalEdit::new(
+ invalid_quantity_listing,
+ PublicKey::from_hex(SELLER).expect("seller"),
+ )
+ .unwrap_err();
+
+ assert_eq!(
+ error,
+ RadrootsOperationalListingEditError::InvalidModel(
+ OperationalListingValidationError::InvalidBin
+ )
+ );
+
+ let mut mismatched_unit_listing = listing();
+ let mut secondary_bin = mismatched_unit_listing.bins[0].clone();
+ secondary_bin.bin_id = bin_id("bin-2");
+ secondary_bin.price_per_canonical_unit = QuantityPrice::try_new(
+ secondary_bin.price_per_canonical_unit.amount().clone(),
+ Quantity::try_new(Decimal::ONE, Unit::Each).unwrap(),
+ )
+ .unwrap();
+ mismatched_unit_listing.bins.push(secondary_bin);
+
+ let error = RadrootsOperationalListingCanonicalEdit::new(
+ mismatched_unit_listing,
+ PublicKey::from_hex(SELLER).expect("seller"),
+ )
+ .unwrap_err();
+
+ assert_eq!(
+ error,
+ RadrootsOperationalListingEditError::InvalidModel(
+ OperationalListingValidationError::InvalidPrice
+ )
+ );
+ }
+
+ #[test]
+ fn canonical_draft_new_rejects_duplicate_bin_ids() {
+ let mut listing = listing();
+ listing.bins.push(listing.bins[0].clone());
+
+ let error = RadrootsOperationalListingCanonicalEdit::new(
+ listing,
+ PublicKey::from_hex(SELLER).expect("seller"),
+ )
+ .unwrap_err();
+
+ assert_eq!(
+ error,
+ RadrootsOperationalListingEditError::DuplicateBinId {
+ bin_id: bin_id("bin-1")
+ }
+ );
+ }
+}
diff --git a/crates/sdk/src/listing/operational_listing/mod.rs b/crates/sdk/src/listing/operational_listing/mod.rs
@@ -87,3 +87,167 @@ pub fn parse_operational_listing_event(
) -> Result<OperationalListing, OperationalListingParseError> {
operational_listing_from_nostr_event(event)
}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ RadrootsClassifiedListingAddressParts, RadrootsPublicClassifiedListingAddress,
+ parse_classified_listing_address, parse_operational_listing_event,
+ parse_public_classified_listing_address,
+ };
+ use radroots_event::{
+ envelope::EventEnvelope,
+ envelope::EventEnvelopeParts,
+ envelope::kind::{KIND_CLASSIFIED_LISTING, KIND_PROFILE},
+ id::ClassifiedListingAddress,
+ listing::operational::OperationalListingParseError,
+ };
+
+ const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+
+ fn listing_event() -> EventEnvelope {
+ EventEnvelope::new(EventEnvelopeParts {
+ id: "9".repeat(64),
+ author: SELLER.to_string(),
+ created_at: 1,
+ kind: KIND_CLASSIFIED_LISTING,
+ tags: vec![
+ vec!["d".into(), "AAAAAAAAAAAAAAAAAAAAAg".into()],
+ vec!["p".into(), SELLER.into()],
+ vec!["a".into(), format!("30340:{SELLER}:AAAAAAAAAAAAAAAAAAAAAA")],
+ vec!["key".into(), "coffee".into()],
+ vec!["title".into(), "Coffee".into()],
+ vec!["category".into(), "coffee".into()],
+ vec!["summary".into(), "Single origin".into()],
+ vec!["radroots:primary_bin".into(), "bin-1".into()],
+ vec![
+ "radroots:bin".into(),
+ "bin-1".into(),
+ "1000".into(),
+ "g".into(),
+ ],
+ vec![
+ "radroots:price".into(),
+ "bin-1".into(),
+ "20".into(),
+ "USD".into(),
+ "1".into(),
+ "g".into(),
+ ],
+ ],
+ content: String::new(),
+ sig: "f".repeat(128),
+ })
+ .expect("listing event")
+ }
+
+ #[test]
+ fn parse_operational_listing_event_rejects_non_listing_kind() {
+ let event = EventEnvelope::new(EventEnvelopeParts {
+ id: "8".repeat(64),
+ author: SELLER.to_string(),
+ created_at: 1,
+ kind: KIND_PROFILE,
+ tags: vec![],
+ content: String::new(),
+ sig: "f".repeat(128),
+ })
+ .expect("profile event");
+
+ assert!(matches!(
+ parse_operational_listing_event(&event),
+ Err(OperationalListingParseError::InvalidKind(KIND_PROFILE))
+ ));
+ }
+
+ #[test]
+ fn parse_operational_listing_event_accepts_listing_kind() {
+ let listing = parse_operational_listing_event(&listing_event()).expect("listing");
+
+ assert_eq!(listing.d_tag.as_str(), "AAAAAAAAAAAAAAAAAAAAAg");
+ assert_eq!(listing.farm.pubkey, SELLER);
+ assert_eq!(listing.primary_bin_id.as_str(), "bin-1");
+ }
+
+ #[test]
+ fn listing_address_associated_parsers_delegate_to_public_parsers() {
+ let raw = format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:listing-1");
+
+ let listing =
+ RadrootsClassifiedListingAddressParts::parse(raw.clone()).expect("listing address");
+ let public = RadrootsPublicClassifiedListingAddress::parse(&raw).expect("public address");
+ let typed = parse_public_classified_listing_address(
+ ClassifiedListingAddress::parse(&raw).expect("typed addr"),
+ )
+ .expect("typed public address");
+
+ assert_eq!(listing.address.as_str(), raw);
+ assert_eq!(public.address.as_str(), raw);
+ assert_eq!(typed.address.as_str(), raw);
+ assert_eq!(listing.seller_pubkey.to_hex(), SELLER);
+ assert_eq!(public.seller_pubkey.to_hex(), SELLER);
+ assert_eq!(typed.seller_pubkey.to_hex(), SELLER);
+ }
+
+ #[test]
+ fn parse_public_classified_listing_address_accepts_public_listing_kind() {
+ let raw = format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:listing-1");
+ let parsed = parse_public_classified_listing_address(&raw).expect("public listing address");
+
+ assert_eq!(parsed.address.as_str(), raw);
+ assert_eq!(parsed.kind, KIND_CLASSIFIED_LISTING);
+ assert_eq!(parsed.seller_pubkey.to_hex(), SELLER);
+ assert_eq!(parsed.listing_id.as_str(), "listing-1");
+ }
+
+ #[test]
+ fn parse_classified_listing_address_rejects_retired_listing_kind() {
+ let raw = format!("30403:{SELLER}:listing-1");
+
+ assert!(matches!(
+ parse_classified_listing_address(&raw),
+ Err(radroots_event::id::ParseError::UnexpectedKind {
+ expected: KIND_CLASSIFIED_LISTING,
+ actual: 30403,
+ })
+ ));
+ assert!(matches!(
+ parse_public_classified_listing_address(&raw),
+ Err(radroots_event::id::ParseError::UnexpectedKind {
+ expected: KIND_CLASSIFIED_LISTING,
+ actual: 30403,
+ })
+ ));
+ }
+
+ #[test]
+ fn parse_public_classified_listing_address_maps_invalid_listing_addresses() {
+ assert!(matches!(
+ parse_public_classified_listing_address("not-an-address"),
+ Err(radroots_event::id::ParseError::InvalidFormat)
+ ));
+
+ let raw = format!("{KIND_PROFILE}:{SELLER}:listing-1");
+ assert!(matches!(
+ parse_public_classified_listing_address(&raw),
+ Err(radroots_event::id::ParseError::UnexpectedKind {
+ expected: KIND_CLASSIFIED_LISTING,
+ actual: KIND_PROFILE,
+ })
+ ));
+ assert!(ClassifiedListingAddress::parse(&raw).is_err());
+ }
+
+ #[test]
+ fn parse_classified_listing_address_rejects_non_listing_kind() {
+ let raw = format!("{KIND_PROFILE}:{SELLER}:listing-1");
+
+ assert!(matches!(
+ parse_classified_listing_address(&raw),
+ Err(radroots_event::id::ParseError::UnexpectedKind {
+ expected: KIND_CLASSIFIED_LISTING,
+ actual: KIND_PROFILE,
+ })
+ ));
+ }
+}
diff --git a/crates/sdk/src/listing/operational_listing/mutation.rs b/crates/sdk/src/listing/operational_listing/mutation.rs
@@ -154,3 +154,352 @@ pub fn build_operational_listing_mutation_draft(
)
.map_err(RadrootsOperationalListingMutationError::FrozenDraft)
}
+
+#[cfg(all(test, feature = "local-signing"))]
+mod tests {
+ const FIXTURE_ALICE_SECRET_KEY_HEX: &str =
+ "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5";
+ const FIXTURE_ALICE_PUBLIC_KEY_HEX: &str =
+ "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df";
+ use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
+ use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit};
+ use radroots_event::{
+ contract::validate_event_contract_shape,
+ draft::EventDraft,
+ envelope::kind::KIND_CLASSIFIED_LISTING,
+ farm::FarmRef,
+ farm::resource_area::ResourceAreaRef,
+ id::{ClassifiedListingAddress, DTag, InventoryBinId},
+ listing::operational::{
+ OperationalListing, OperationalListingAvailability, OperationalListingBin,
+ OperationalListingDeliveryMethod, OperationalListingProduct,
+ OperationalListingPublicLocation, OperationalListingStatus,
+ },
+ wire::Nip01EventWire,
+ };
+ use radroots_event_codec::verify::verify_nip01_event;
+ use radroots_identity::PublicKey;
+
+ use crate::listing::operational_listing::draft::RadrootsOperationalListingCanonicalEdit;
+ use crate::listing::operational_listing::validation::validate_operational_listing_event;
+
+ use super::{
+ OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID, RadrootsOperationalListingLifecycleState,
+ RadrootsOperationalListingMutation, RadrootsOperationalListingMutationError,
+ build_operational_listing_mutation_draft,
+ };
+
+ const SELLER: &str = FIXTURE_ALICE_PUBLIC_KEY_HEX;
+
+ fn d_tag(raw: &str) -> DTag {
+ DTag::parse(raw).expect("d tag")
+ }
+
+ fn bin_id(raw: &str) -> InventoryBinId {
+ InventoryBinId::parse(raw).expect("bin id")
+ }
+
+ fn sign_draft(draft: &EventDraft) -> radroots_event::envelope::EventEnvelope {
+ let keys = Keys::parse(FIXTURE_ALICE_SECRET_KEY_HEX).expect("fixture signing key");
+ assert_eq!(keys.public_key().to_hex(), draft.expected_pubkey().to_hex());
+ let tags = draft
+ .tags_as_vec()
+ .into_iter()
+ .map(|tag| Tag::parse(tag).expect("draft tag"))
+ .collect::<Vec<_>>();
+ let event = EventBuilder::new(
+ Kind::Custom(u16::try_from(draft.kind_u32()).expect("NIP-01 kind")),
+ draft.content(),
+ )
+ .tags(tags)
+ .allow_self_tagging()
+ .custom_created_at(Timestamp::from_secs(draft.created_at_u64()))
+ .sign_with_keys(&keys)
+ .expect("signed listing event");
+ assert_eq!(event.id.to_hex(), draft.expected_event_id_hex());
+ let raw_json = event.as_json();
+ Nip01EventWire::parse_json(raw_json.as_str())
+ .expect("canonical event wire")
+ .into_envelope()
+ .expect("event envelope")
+ }
+
+ fn listing() -> OperationalListing {
+ OperationalListing {
+ d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"),
+ published_at: None,
+ farm: FarmRef {
+ pubkey: SELLER.to_string(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
+ },
+ product: OperationalListingProduct {
+ key: "coffee".to_string(),
+ title: "Coffee".to_string(),
+ category: "coffee".to_string(),
+ summary: Some("Single origin coffee".to_string()),
+ process: None,
+ lot: None,
+ location: None,
+ profile: None,
+ year: None,
+ },
+ primary_bin_id: bin_id("bin-1"),
+ bins: vec![OperationalListingBin {
+ bin_id: bin_id("bin-1"),
+ quantity: Quantity::try_new(Decimal::from(1000u32), Unit::MassG).unwrap(),
+ price_per_canonical_unit: QuantityPrice::try_new(
+ Money::try_new(Decimal::from(20u32), Currency::USD).unwrap(),
+ Quantity::try_new(Decimal::from(1u32), Unit::MassG).unwrap(),
+ )
+ .unwrap(),
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ display_price: None,
+ display_price_unit: None,
+ }],
+ resource_area: None,
+ plot: None,
+ discounts: None,
+ inventory_available: Some(Decimal::from(5u32)),
+ availability: Some(OperationalListingAvailability::Status {
+ status: OperationalListingStatus::Active,
+ }),
+ delivery_method: Some(OperationalListingDeliveryMethod::Pickup),
+ location: Some(OperationalListingPublicLocation {
+ primary: "Farm".to_string(),
+ city: Some("Town".to_string()),
+ region: Some("Region".to_string()),
+ country: Some("US".to_string()),
+ geohash: "9q8yy".to_string(),
+ }),
+ images: None,
+ }
+ }
+
+ fn canonical_draft() -> RadrootsOperationalListingCanonicalEdit {
+ RadrootsOperationalListingCanonicalEdit::new(
+ listing(),
+ PublicKey::from_hex(SELLER).expect("seller"),
+ )
+ .expect("canonical listing edit")
+ }
+
+ #[test]
+ fn supported_mutations_report_lifecycle_states() {
+ assert_eq!(
+ RadrootsOperationalListingMutation::publish(canonical_draft())
+ .lifecycle_state()
+ .expect("state"),
+ RadrootsOperationalListingLifecycleState::Published
+ );
+ assert_eq!(
+ RadrootsOperationalListingMutation::update(canonical_draft())
+ .lifecycle_state()
+ .expect("state"),
+ RadrootsOperationalListingLifecycleState::Published
+ );
+ assert_eq!(
+ RadrootsOperationalListingMutation::save_draft(canonical_draft())
+ .lifecycle_state()
+ .expect("state"),
+ RadrootsOperationalListingLifecycleState::Draft
+ );
+ }
+
+ #[test]
+ fn supported_mutations_expose_canonical_drafts() {
+ let publish = RadrootsOperationalListingMutation::publish(canonical_draft());
+ let update = RadrootsOperationalListingMutation::update(canonical_draft());
+ let save_draft = RadrootsOperationalListingMutation::save_draft(canonical_draft());
+
+ assert_eq!(
+ publish
+ .canonical_draft()
+ .expect("draft")
+ .seller_pubkey()
+ .to_hex(),
+ SELLER
+ );
+ assert_eq!(
+ update
+ .canonical_draft()
+ .expect("draft")
+ .seller_pubkey()
+ .to_hex(),
+ SELLER
+ );
+ assert_eq!(
+ save_draft
+ .canonical_draft()
+ .expect("draft")
+ .seller_pubkey()
+ .to_hex(),
+ SELLER
+ );
+ assert_eq!(
+ publish
+ .canonical_draft()
+ .expect("draft")
+ .listing()
+ .d_tag
+ .as_str(),
+ "AAAAAAAAAAAAAAAAAAAAAg"
+ );
+ }
+
+ #[test]
+ fn supported_mutations_report_listing_addresses() {
+ let publish = RadrootsOperationalListingMutation::publish(canonical_draft());
+ let update = RadrootsOperationalListingMutation::update(canonical_draft());
+ let save_draft = RadrootsOperationalListingMutation::save_draft(canonical_draft());
+
+ assert_eq!(
+ publish.listing_addr().expect("address").as_str(),
+ format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")
+ );
+ assert_eq!(
+ update.listing_addr().expect("address").as_str(),
+ format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")
+ );
+ assert_eq!(
+ save_draft.listing_addr().expect("address").as_str(),
+ format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")
+ );
+ }
+
+ #[test]
+ fn archive_is_explicitly_unsupported() {
+ let archive = RadrootsOperationalListingMutation::archive(
+ ClassifiedListingAddress::parse(format!(
+ "{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"
+ ))
+ .expect("listing address"),
+ );
+
+ assert_eq!(
+ archive.lifecycle_state().unwrap_err(),
+ RadrootsOperationalListingMutationError::UnsupportedMutation
+ );
+ assert_eq!(
+ archive.canonical_draft().unwrap_err(),
+ RadrootsOperationalListingMutationError::UnsupportedMutation
+ );
+ assert_eq!(
+ archive.listing_addr().unwrap_err(),
+ RadrootsOperationalListingMutationError::UnsupportedMutation
+ );
+ }
+
+ #[test]
+ fn build_operational_listing_mutation_draft_maps_publish_and_update_to_published_listing() {
+ let publish = RadrootsOperationalListingMutation::publish(canonical_draft());
+ let update = RadrootsOperationalListingMutation::update(canonical_draft());
+
+ let publish_draft =
+ build_operational_listing_mutation_draft(&publish, 1_700_000_000).expect("draft");
+ let update_draft =
+ build_operational_listing_mutation_draft(&update, 1_700_000_000).expect("draft");
+
+ assert_eq!(publish_draft.kind_u32(), KIND_CLASSIFIED_LISTING);
+ assert_eq!(
+ publish_draft.contract_id(),
+ OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID
+ );
+ assert_eq!(publish_draft.expected_pubkey().to_hex(), SELLER);
+ assert_eq!(publish_draft.created_at_u64(), 1_700_000_000);
+ assert_eq!(publish_draft.content(), "# Coffee\n\nSingle origin coffee");
+ assert_eq!(update_draft.kind_u32(), KIND_CLASSIFIED_LISTING);
+ assert_eq!(
+ update_draft.contract_id(),
+ OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID
+ );
+ assert_eq!(update_draft.expected_pubkey().to_hex(), SELLER);
+ }
+
+ #[test]
+ fn build_operational_listing_mutation_draft_rejects_save_draft() {
+ let save_draft = RadrootsOperationalListingMutation::save_draft(canonical_draft());
+
+ assert_eq!(
+ build_operational_listing_mutation_draft(&save_draft, 1_700_000_000).unwrap_err(),
+ RadrootsOperationalListingMutationError::UnsupportedMutation
+ );
+ }
+
+ #[test]
+ fn build_operational_listing_mutation_draft_rejects_archive() {
+ let archive = RadrootsOperationalListingMutation::archive(
+ ClassifiedListingAddress::parse(format!(
+ "{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"
+ ))
+ .expect("listing address"),
+ );
+
+ assert_eq!(
+ build_operational_listing_mutation_draft(&archive, 1_700_000_000).unwrap_err(),
+ RadrootsOperationalListingMutationError::UnsupportedMutation
+ );
+ }
+
+ #[test]
+ fn build_operational_listing_mutation_draft_reports_encode_errors() {
+ let mut listing = listing();
+ listing.resource_area = Some(ResourceAreaRef {
+ pubkey: SELLER.to_string(),
+ d_tag: "bad d tag".to_string(),
+ });
+ let draft = RadrootsOperationalListingCanonicalEdit::new(
+ listing,
+ PublicKey::from_hex(SELLER).expect("seller"),
+ )
+ .expect("canonical listing edit");
+ let publish = RadrootsOperationalListingMutation::publish(draft);
+
+ let err = build_operational_listing_mutation_draft(&publish, 1_700_000_000).unwrap_err();
+
+ assert!(matches!(
+ err,
+ RadrootsOperationalListingMutationError::EncodeListing(_)
+ ));
+ }
+
+ #[test]
+ fn build_operational_listing_mutation_draft_event_id_is_stable_for_fixed_input() {
+ let publish = RadrootsOperationalListingMutation::publish(canonical_draft());
+
+ let first =
+ build_operational_listing_mutation_draft(&publish, 1_700_000_000).expect("draft");
+ let second =
+ build_operational_listing_mutation_draft(&publish, 1_700_000_000).expect("draft");
+
+ assert_eq!(
+ first.expected_event_id_hex(),
+ second.expected_event_id_hex()
+ );
+ assert_eq!(first.expected_event_id_hex().len(), 64);
+ assert_eq!(first.tags_as_vec(), second.tags_as_vec());
+ assert_eq!(first.content(), second.content());
+ }
+
+ #[test]
+ fn build_operational_listing_mutation_draft_output_validates_as_operational_listing() {
+ let publish = RadrootsOperationalListingMutation::publish(canonical_draft());
+ let draft =
+ build_operational_listing_mutation_draft(&publish, 1_700_000_000).expect("draft");
+
+ let signed = sign_draft(&draft);
+ validate_event_contract_shape(&signed, OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID)
+ .expect("operational listing contract");
+ let verified = verify_nip01_event(signed).expect("verified listing");
+ let validated = validate_operational_listing_event(&verified).expect("validated listing");
+
+ assert_eq!(validated.seller_pubkey, SELLER);
+ assert!(
+ validated
+ .listing_addr
+ .as_str()
+ .contains(&format!(":{SELLER}:"))
+ );
+ }
+}
diff --git a/crates/sdk/src/listing/operational_listing/price_ext.rs b/crates/sdk/src/listing/operational_listing/price_ext.rs
@@ -61,3 +61,116 @@ impl BinPricingTryExt for OperationalListingBin {
})
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::BinPricingTryExt;
+ use radroots_core::pricing::Error as PricingError;
+ use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit};
+ use radroots_event::id::InventoryBinId;
+ use radroots_event::listing::operational::OperationalListingBin;
+
+ fn bin_id(raw: &str) -> InventoryBinId {
+ InventoryBinId::parse(raw).expect("bin id")
+ }
+
+ fn valid_bin() -> OperationalListingBin {
+ OperationalListingBin {
+ bin_id: bin_id("bin-1"),
+ quantity: Quantity::try_new(Decimal::from(2u32), Unit::MassG).unwrap(),
+ price_per_canonical_unit: QuantityPrice::try_new(
+ Money::try_new(Decimal::from(5u32), Currency::USD).unwrap(),
+ Quantity::try_new(Decimal::from(1u32), Unit::MassG).unwrap(),
+ )
+ .unwrap(),
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ display_price: None,
+ display_price_unit: None,
+ }
+ }
+
+ #[test]
+ fn try_subtotal_for_rejects_unit_mismatch() {
+ let bin = OperationalListingBin {
+ bin_id: bin_id("bin-1"),
+ quantity: Quantity::try_new(Decimal::from(1u32), Unit::MassG).unwrap(),
+ price_per_canonical_unit: QuantityPrice::try_new(
+ Money::try_new(Decimal::from(10u32), Currency::USD).unwrap(),
+ Quantity::try_new(Decimal::from(1u32), Unit::Each).unwrap(),
+ )
+ .unwrap(),
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ display_price: None,
+ display_price_unit: None,
+ };
+
+ let err = bin.try_subtotal_for_count(1).unwrap_err();
+ assert_eq!(
+ err,
+ PricingError::UnitMismatch {
+ have: Unit::MassG,
+ want: Unit::Each,
+ }
+ );
+ }
+
+ #[test]
+ fn subtotal_and_total_for_count_follow_effective_quantity() {
+ let bin = valid_bin();
+ let subtotal = bin.try_subtotal_for_count(3).unwrap();
+ let total = bin.try_total_for_count(3).unwrap();
+
+ assert_eq!(subtotal.quantity_amount, Decimal::from(6u32));
+ assert_eq!(subtotal.quantity_unit, Unit::MassG);
+ assert_eq!(subtotal.price_amount.amount(), Decimal::from(30u32));
+ assert_eq!(subtotal.price_currency, Currency::USD);
+
+ assert_eq!(total.quantity_amount, subtotal.quantity_amount);
+ assert_eq!(total.quantity_unit, subtotal.quantity_unit);
+ assert_eq!(total.price_amount, subtotal.price_amount);
+ assert_eq!(total.price_currency, subtotal.price_currency);
+ }
+
+ #[test]
+ fn try_subtotal_and_try_total_match() {
+ let bin = valid_bin();
+ let subtotal = bin.try_subtotal_for_count(4).expect("subtotal");
+ let total = bin.try_total_for_count(4).expect("total");
+
+ assert_eq!(subtotal.quantity_amount, Decimal::from(8u32));
+ assert_eq!(subtotal.price_amount.amount(), Decimal::from(40u32));
+ assert_eq!(total.quantity_amount, subtotal.quantity_amount);
+ assert_eq!(total.price_amount, subtotal.price_amount);
+ }
+
+ #[test]
+ fn try_total_for_count_propagates_subtotal_errors() {
+ let bin = OperationalListingBin {
+ bin_id: bin_id("bin-1"),
+ quantity: Quantity::try_new(Decimal::from(1u32), Unit::MassG).unwrap(),
+ price_per_canonical_unit: QuantityPrice::try_new(
+ Money::try_new(Decimal::from(10u32), Currency::USD).unwrap(),
+ Quantity::try_new(Decimal::from(1u32), Unit::Each).unwrap(),
+ )
+ .unwrap(),
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ display_price: None,
+ display_price_unit: None,
+ };
+
+ let err = bin.try_total_for_count(1).unwrap_err();
+ assert_eq!(
+ err,
+ PricingError::UnitMismatch {
+ have: Unit::MassG,
+ want: Unit::Each,
+ }
+ );
+ }
+}
diff --git a/crates/sdk/src/listing/operational_listing/validation.rs b/crates/sdk/src/listing/operational_listing/validation.rs
@@ -212,7 +212,6 @@ fn validate_listing_bin(
Ok(())
}
-#[cfg_attr(coverage_nightly, coverage(off))]
fn validate_listing_location_geohash(
geohash: &str,
) -> Result<(), OperationalListingValidationError> {
@@ -224,3 +223,766 @@ fn validate_listing_location_geohash(
}
Ok(())
}
+
+#[cfg(all(test, feature = "local-signing"))]
+mod tests {
+ const FIXTURE_ALICE_SECRET_KEY_HEX: &str =
+ "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5";
+ const FIXTURE_ALICE_PUBLIC_KEY_HEX: &str =
+ "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df";
+ const FIXTURE_BOB_SECRET_KEY_HEX: &str =
+ "59392e9068f66431b12f70218fb61281cb6b433d7f27c55d61f1a63fe1a96ff8";
+ const FIXTURE_BOB_PUBLIC_KEY_HEX: &str =
+ "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af";
+ use super::{
+ OperationalListingValidationError, validate_operational_listing_event,
+ validate_operational_listing_model,
+ };
+ use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
+ use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit};
+ use radroots_event::{
+ envelope::EventEnvelope,
+ envelope::EventEnvelopeParts,
+ envelope::kind::KIND_CLASSIFIED_LISTING,
+ farm::FarmRef,
+ id::{DTag, InventoryBinId},
+ listing::operational::{
+ OperationalListing, OperationalListingAvailability, OperationalListingBin,
+ OperationalListingDeliveryMethod, OperationalListingProduct,
+ OperationalListingPublicLocation,
+ },
+ wire::Nip01EventWire,
+ };
+ use radroots_event_codec::verify::{RadrootsSignatureVerifiedEvent, verify_nip01_event};
+ use radroots_identity::PublicKey;
+
+ const SELLER: &str = FIXTURE_ALICE_PUBLIC_KEY_HEX;
+ const OTHER_SELLER: &str = FIXTURE_BOB_PUBLIC_KEY_HEX;
+
+ fn d_tag(raw: &str) -> DTag {
+ DTag::parse(raw).expect("d tag")
+ }
+
+ fn bin_id(raw: &str) -> InventoryBinId {
+ InventoryBinId::parse(raw).expect("bin id")
+ }
+
+ fn seller_pubkey() -> PublicKey {
+ PublicKey::from_hex(SELLER).expect("seller pubkey")
+ }
+
+ fn other_seller_pubkey() -> PublicKey {
+ PublicKey::from_hex(OTHER_SELLER).expect("other seller pubkey")
+ }
+
+ fn base_listing() -> OperationalListing {
+ OperationalListing {
+ d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"),
+ published_at: None,
+ farm: FarmRef {
+ pubkey: SELLER.into(),
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(),
+ },
+ product: OperationalListingProduct {
+ key: "coffee".into(),
+ title: "Coffee".into(),
+ category: "coffee".into(),
+ summary: Some("Single origin coffee".into()),
+ process: None,
+ lot: None,
+ location: None,
+ profile: None,
+ year: None,
+ },
+ primary_bin_id: bin_id("bin-1"),
+ bins: vec![OperationalListingBin {
+ bin_id: bin_id("bin-1"),
+ quantity: Quantity::try_new(Decimal::from(1000u32), Unit::MassG).unwrap(),
+ price_per_canonical_unit: QuantityPrice::try_new(
+ Money::try_new(Decimal::from(20u32), Currency::USD).unwrap(),
+ Quantity::try_new(Decimal::from(1u32), Unit::MassG).unwrap(),
+ )
+ .unwrap(),
+ display_amount: None,
+ display_unit: None,
+ display_label: None,
+ display_price: None,
+ display_price_unit: None,
+ }],
+ resource_area: None,
+ plot: None,
+ discounts: None,
+ inventory_available: Some(Decimal::from(5u32)),
+ availability: Some(OperationalListingAvailability::Status {
+ status: radroots_event::listing::operational::OperationalListingStatus::Active,
+ }),
+ delivery_method: Some(OperationalListingDeliveryMethod::Pickup),
+ location: Some(OperationalListingPublicLocation {
+ primary: "Farm".into(),
+ city: Some("Town".into()),
+ region: Some("Region".into()),
+ country: Some("US".into()),
+ geohash: "9q8yy".into(),
+ }),
+ images: None,
+ }
+ }
+
+ fn base_event(listing: &OperationalListing) -> RadrootsSignatureVerifiedEvent {
+ let mut tags = vec![
+ vec!["d".into(), listing.d_tag.to_string()],
+ vec!["p".into(), listing.farm.pubkey.clone()],
+ vec![
+ "a".into(),
+ format!("30340:{}:{}", listing.farm.pubkey, listing.farm.d_tag),
+ ],
+ vec!["key".into(), listing.product.key.clone()],
+ vec!["title".into(), listing.product.title.clone()],
+ vec!["category".into(), listing.product.category.clone()],
+ vec![
+ "summary".into(),
+ listing.product.summary.clone().unwrap_or_default(),
+ ],
+ vec![
+ "radroots:primary_bin".into(),
+ listing.primary_bin_id.to_string(),
+ ],
+ ];
+ for bin in &listing.bins {
+ tags.push(vec![
+ "radroots:bin".into(),
+ bin.bin_id.to_string(),
+ bin.quantity.amount().to_string(),
+ bin.quantity.unit().code().to_string(),
+ ]);
+ tags.push(vec![
+ "radroots:price".into(),
+ bin.bin_id.to_string(),
+ bin.price_per_canonical_unit.amount().amount().to_string(),
+ bin.price_per_canonical_unit
+ .amount()
+ .currency()
+ .as_str()
+ .to_string(),
+ bin.price_per_canonical_unit.quantity().amount().to_string(),
+ bin.price_per_canonical_unit
+ .quantity()
+ .unit()
+ .code()
+ .to_string(),
+ ]);
+ }
+ if let Some(inventory) = listing.inventory_available {
+ tags.push(vec!["inventory".into(), inventory.to_string()]);
+ }
+ if let Some(availability) = &listing.availability {
+ match availability {
+ OperationalListingAvailability::Status { status } => tags.push(vec![
+ "status".into(),
+ match status {
+ radroots_event::listing::operational::OperationalListingStatus::Active => {
+ "active".into()
+ }
+ radroots_event::listing::operational::OperationalListingStatus::Sold => {
+ "sold".into()
+ }
+ radroots_event::listing::operational::OperationalListingStatus::Other {
+ value,
+ } => value.clone(),
+ },
+ ]),
+ OperationalListingAvailability::Window { start, end } => {
+ if let Some(start) = start {
+ tags.push(vec![
+ "radroots:availability_start".into(),
+ start.to_string(),
+ ]);
+ }
+ if let Some(end) = end {
+ tags.push(vec!["expires_at".into(), end.to_string()]);
+ }
+ }
+ }
+ }
+ if let Some(delivery) = &listing.delivery_method {
+ let mut tag = vec!["delivery".into()];
+ match delivery {
+ OperationalListingDeliveryMethod::Pickup => tag.push("pickup".into()),
+ OperationalListingDeliveryMethod::LocalDelivery => {
+ tag.push("local_delivery".into())
+ }
+ OperationalListingDeliveryMethod::Shipping => tag.push("shipping".into()),
+ OperationalListingDeliveryMethod::Other { method } => {
+ tag.push("other".into());
+ tag.push(method.clone());
+ }
+ }
+ tags.push(tag);
+ }
+ if let Some(location) = &listing.location {
+ tags.push(vec![
+ "location".into(),
+ location.primary.clone(),
+ location.city.clone().unwrap_or_default(),
+ location.region.clone().unwrap_or_default(),
+ location.country.clone().unwrap_or_default(),
+ ]);
+ tags.push(vec!["g".into(), location.geohash.clone()]);
+ }
+
+ event_with_parts(SELLER, KIND_CLASSIFIED_LISTING, tags, String::new())
+ }
+
+ fn event_with_parts(
+ author: &str,
+ kind: u32,
+ tags: Vec<Vec<String>>,
+ content: String,
+ ) -> RadrootsSignatureVerifiedEvent {
+ let secret = match author {
+ SELLER => FIXTURE_ALICE_SECRET_KEY_HEX,
+ OTHER_SELLER => FIXTURE_BOB_SECRET_KEY_HEX,
+ _ => panic!("test author must be an approved fixture identity"),
+ };
+ let keys = Keys::parse(secret).expect("fixture signing key");
+ let tags = tags
+ .into_iter()
+ .map(|tag| Tag::parse(tag).expect("test tag"))
+ .collect::<Vec<_>>();
+ let event = EventBuilder::new(
+ Kind::Custom(u16::try_from(kind).expect("test kind")),
+ content,
+ )
+ .tags(tags)
+ .allow_self_tagging()
+ .custom_created_at(Timestamp::from_secs(1))
+ .sign_with_keys(&keys)
+ .expect("signed test event");
+ let raw_json = event.as_json();
+ let envelope = Nip01EventWire::parse_json(raw_json.as_str())
+ .expect("canonical event wire")
+ .into_envelope()
+ .expect("event envelope");
+ verify_nip01_event(envelope).expect("verified test event")
+ }
+
+ fn assert_validation_err(
+ listing: OperationalListing,
+ expected: OperationalListingValidationError,
+ ) {
+ let event = base_event(&listing);
+ let err = validate_operational_listing_event(&event).unwrap_err();
+ assert_eq!(format!("{err}"), format!("{expected}"));
+ }
+
+ fn assert_secondary_bin_model_error(
+ update: impl FnOnce(&mut OperationalListingBin),
+ expected: OperationalListingValidationError,
+ ) {
+ let mut listing = listing_with_secondary_bin();
+ update(&mut listing.bins[1]);
+
+ assert_eq!(
+ validate_operational_listing_model(listing, &seller_pubkey())
+ .expect_err("invalid secondary bin"),
+ expected
+ );
+ }
+
+ fn listing_with_secondary_bin() -> OperationalListing {
+ let mut listing = base_listing();
+ let mut secondary_bin = listing.bins[0].clone();
+ secondary_bin.bin_id = bin_id("bin-2");
+ listing.bins.push(secondary_bin);
+ listing
+ }
+
+ #[test]
+ fn validate_listing_ok() {
+ let listing = base_listing();
+ let event = base_event(&listing);
+ assert!(validate_operational_listing_event(&event).is_ok());
+ }
+
+ #[test]
+ #[cfg(any())]
+ fn model_and_verified_event_validation_return_the_same_projection() {
+ let listing = listing_with_secondary_bin();
+ let event_projection =
+ validate_operational_listing_event(&base_event(&listing)).expect("event projection");
+ let model_projection = validate_operational_listing_model(listing, &seller_pubkey())
+ .expect("model projection");
+
+ assert_eq!(
+ serde_json::to_value(model_projection).expect("model projection JSON"),
+ serde_json::to_value(event_projection).expect("event projection JSON")
+ );
+ }
+
+ #[test]
+ fn model_and_verified_event_validation_return_the_same_semantic_errors() {
+ let mut listing = base_listing();
+ listing.inventory_available = None;
+ let event_error = validate_operational_listing_event(&base_event(&listing))
+ .expect_err("event inventory error");
+ let model_error = validate_operational_listing_model(listing, &seller_pubkey())
+ .expect_err("model inventory error");
+ assert_eq!(model_error, event_error);
+
+ let listing = base_listing();
+ let event = event_with_parts(
+ OTHER_SELLER,
+ KIND_CLASSIFIED_LISTING,
+ base_event(&listing).event().tags_as_vec(),
+ String::new(),
+ );
+ let event_error =
+ validate_operational_listing_event(&event).expect_err("event seller error");
+ let model_error = validate_operational_listing_model(listing, &other_seller_pubkey())
+ .expect_err("model seller error");
+ assert_eq!(model_error, event_error);
+ }
+
+ #[test]
+ fn model_validation_reports_errors_before_event_encoding() {
+ let mut listing = base_listing();
+ listing.bins.clear();
+ assert_eq!(
+ validate_operational_listing_model(listing, &seller_pubkey())
+ .expect_err("missing bins"),
+ OperationalListingValidationError::MissingBins
+ );
+
+ let mut listing = base_listing();
+ listing.primary_bin_id = bin_id("missing");
+ assert_eq!(
+ validate_operational_listing_model(listing, &seller_pubkey())
+ .expect_err("missing primary bin"),
+ OperationalListingValidationError::MissingPrimaryBin
+ );
+
+ let mut listing = base_listing();
+ listing.bins.push(listing.bins[0].clone());
+ assert_eq!(
+ validate_operational_listing_model(listing, &seller_pubkey())
+ .expect_err("duplicate bin ID"),
+ OperationalListingValidationError::InvalidBin
+ );
+
+ let mut listing = base_listing();
+ listing.location.as_mut().expect("location").geohash = " ".into();
+ assert_eq!(
+ validate_operational_listing_model(listing, &seller_pubkey())
+ .expect_err("missing geohash"),
+ OperationalListingValidationError::MissingLocationGeohash
+ );
+
+ let mut listing = base_listing();
+ listing.location.as_mut().expect("location").geohash = "9q8yyz".into();
+ assert_eq!(
+ validate_operational_listing_model(listing, &seller_pubkey())
+ .expect_err("invalid geohash"),
+ OperationalListingValidationError::InvalidLocationGeohash
+ );
+ }
+
+ #[test]
+ fn model_validation_rejects_invalid_secondary_bin_quantities() {
+ assert_secondary_bin_model_error(
+ |bin| bin.quantity = Quantity::try_new(Decimal::ONE, Unit::MassKg).unwrap(),
+ OperationalListingValidationError::InvalidBin,
+ );
+ }
+
+ #[test]
+ fn model_validation_rejects_invalid_secondary_bin_prices() {
+ assert_secondary_bin_model_error(
+ |bin| {
+ bin.price_per_canonical_unit = QuantityPrice::try_new(
+ bin.price_per_canonical_unit.amount().clone(),
+ Quantity::try_new(Decimal::from(2u32), Unit::MassG).unwrap(),
+ )
+ .unwrap();
+ },
+ OperationalListingValidationError::InvalidPrice,
+ );
+ assert_secondary_bin_model_error(
+ |bin| {
+ bin.price_per_canonical_unit = QuantityPrice::try_new(
+ bin.price_per_canonical_unit.amount().clone(),
+ Quantity::try_new(Decimal::ONE, Unit::MassKg).unwrap(),
+ )
+ .unwrap();
+ },
+ OperationalListingValidationError::InvalidPrice,
+ );
+ assert_secondary_bin_model_error(
+ |bin| {
+ bin.price_per_canonical_unit = QuantityPrice::try_new(
+ bin.price_per_canonical_unit.amount().clone(),
+ Quantity::try_new(Decimal::ONE, Unit::Each).unwrap(),
+ )
+ .unwrap();
+ },
+ OperationalListingValidationError::InvalidPrice,
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_retired_kind() {
+ let listing = base_listing();
+ let event = event_with_parts(
+ SELLER,
+ 30403,
+ base_event(&listing).event().tags_as_vec(),
+ String::new(),
+ );
+ let err = validate_operational_listing_event(&event).unwrap_err();
+ assert_eq!(
+ err,
+ OperationalListingValidationError::InvalidKind { kind: 30403 }
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_d_tag() {
+ let event = event_with_parts(SELLER, KIND_CLASSIFIED_LISTING, Vec::new(), String::new());
+ let err = validate_operational_listing_event(&event).unwrap_err();
+ assert_eq!(err, OperationalListingValidationError::InvalidProfile);
+ }
+
+ #[test]
+ fn validate_listing_rejects_invalid_currency() {
+ let event = event_with_parts(
+ SELLER,
+ KIND_CLASSIFIED_LISTING,
+ vec![
+ vec!["d".into(), "AAAAAAAAAAAAAAAAAAAAAg".into()],
+ vec!["p".into(), SELLER.into()],
+ vec!["a".into(), format!("30340:{SELLER}:AAAAAAAAAAAAAAAAAAAAAA")],
+ vec!["key".into(), "coffee".into()],
+ vec!["title".into(), "Coffee".into()],
+ vec!["category".into(), "coffee".into()],
+ vec!["summary".into(), "Single origin".into()],
+ vec!["radroots:primary_bin".into(), "bin-1".into()],
+ vec![
+ "quantity".into(),
+ "1".into(),
+ "lb".into(),
+ "bag".into(),
+ "5".into(),
+ ],
+ vec![
+ "price".into(),
+ "20".into(),
+ "US".into(),
+ "1".into(),
+ "lb".into(),
+ ],
+ vec![
+ "location".into(),
+ "Farm".into(),
+ "Town".into(),
+ "Region".into(),
+ ],
+ vec!["status".into(), "active".into()],
+ vec!["delivery".into(), "pickup".into()],
+ ],
+ String::new(),
+ );
+ let err = validate_operational_listing_event(&event).unwrap_err();
+ assert!(format!("{err:?}").starts_with("ParseError"));
+ }
+
+ #[test]
+ fn validate_listing_rejects_mismatched_seller() {
+ let listing = base_listing();
+ let event = event_with_parts(
+ OTHER_SELLER,
+ KIND_CLASSIFIED_LISTING,
+ base_event(&listing).event().tags_as_vec(),
+ String::new(),
+ );
+ let err = validate_operational_listing_event(&event).unwrap_err();
+ assert_eq!(err, OperationalListingValidationError::InvalidSeller);
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_inventory() {
+ let mut listing = base_listing();
+ listing.inventory_available = None;
+ let event = base_event(&listing);
+ let err = validate_operational_listing_event(&event).unwrap_err();
+ assert_eq!(err, OperationalListingValidationError::MissingInventory);
+ }
+
+ #[test]
+ fn validate_listing_rejects_invalid_kind() {
+ let listing = base_listing();
+ let event = event_with_parts(
+ SELLER,
+ 0,
+ base_event(&listing).event().tags_as_vec(),
+ String::new(),
+ );
+ let err = validate_operational_listing_event(&event).unwrap_err();
+ assert_eq!(
+ err,
+ OperationalListingValidationError::InvalidKind { kind: 0 }
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_title() {
+ let mut listing = base_listing();
+ listing.product.title = " ".into();
+ assert_validation_err(listing, OperationalListingValidationError::MissingTitle);
+ }
+
+ #[test]
+ fn tampered_envelope_cannot_reach_operational_validation() {
+ let verified = base_event(&base_listing());
+ let event = verified.into_event();
+ let tampered = EventEnvelope::new(EventEnvelopeParts {
+ id: event.id_hex(),
+ author: event.author().to_hex().to_owned(),
+ created_at: event.created_at_u64(),
+ kind: event.kind_u32(),
+ tags: event.tags_as_vec(),
+ content: "tampered".to_owned(),
+ sig: event.signature_hex(),
+ })
+ .expect("well-shaped tampered envelope");
+
+ assert!(verify_nip01_event(tampered).is_err());
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_description() {
+ let mut listing = base_listing();
+ listing.product.summary = Some(" ".into());
+ assert_validation_err(
+ listing,
+ OperationalListingValidationError::MissingDescription,
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_product_type() {
+ let mut listing = base_listing();
+ listing.product.category = " ".into();
+ listing.product.key = " ".into();
+ assert_validation_err(
+ listing,
+ OperationalListingValidationError::MissingProductType,
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_bins() {
+ let mut listing = base_listing();
+ listing.bins.clear();
+ assert_validation_err(
+ listing,
+ OperationalListingValidationError::ParseError {
+ error:
+ radroots_event::listing::operational::OperationalListingParseError::InvalidTag(
+ "radroots:primary_bin".into(),
+ ),
+ },
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_primary_bin_id() {
+ assert!(InventoryBinId::parse(" ").is_err());
+ }
+
+ #[test]
+ fn validate_listing_rejects_primary_bin_not_found() {
+ let mut listing = base_listing();
+ listing.primary_bin_id = bin_id("missing");
+ assert_validation_err(
+ listing,
+ OperationalListingValidationError::ParseError {
+ error:
+ radroots_event::listing::operational::OperationalListingParseError::InvalidTag(
+ "radroots:primary_bin".into(),
+ ),
+ },
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_non_canonical_quantity() {
+ let mut listing = base_listing();
+ listing.bins[0].quantity = Quantity::try_new(Decimal::ONE, Unit::MassKg).unwrap();
+ assert_validation_err(
+ listing,
+ OperationalListingValidationError::ParseError {
+ error:
+ radroots_event::listing::operational::OperationalListingParseError::InvalidTag(
+ "radroots:bin".into(),
+ ),
+ },
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_non_canonical_price_quantity() {
+ let mut listing = base_listing();
+ listing.bins[0].price_per_canonical_unit = QuantityPrice::try_new(
+ listing.bins[0].price_per_canonical_unit.amount().clone(),
+ Quantity::try_new(Decimal::ONE, Unit::MassKg).unwrap(),
+ )
+ .unwrap();
+ assert_validation_err(
+ listing,
+ OperationalListingValidationError::ParseError {
+ error:
+ radroots_event::listing::operational::OperationalListingParseError::InvalidTag(
+ "radroots:price".into(),
+ ),
+ },
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_price_unit_mismatch() {
+ let mut listing = base_listing();
+ listing.bins[0].price_per_canonical_unit = QuantityPrice::try_new(
+ listing.bins[0].price_per_canonical_unit.amount().clone(),
+ Quantity::try_new(Decimal::ONE, Unit::Each).unwrap(),
+ )
+ .unwrap();
+ assert_validation_err(
+ listing,
+ OperationalListingValidationError::ParseError {
+ error:
+ radroots_event::listing::operational::OperationalListingParseError::InvalidTag(
+ "radroots:price".into(),
+ ),
+ },
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_negative_inventory() {
+ let mut listing = base_listing();
+ listing.inventory_available = Some("-1".parse().unwrap());
+ assert_validation_err(listing, OperationalListingValidationError::InvalidInventory);
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_availability() {
+ let mut listing = base_listing();
+ listing.availability = None;
+ assert_validation_err(
+ listing,
+ OperationalListingValidationError::MissingAvailability,
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_location() {
+ let mut listing = base_listing();
+ listing.location = None;
+ assert_validation_err(listing, OperationalListingValidationError::MissingLocation);
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_location_locality() {
+ let mut listing = base_listing();
+ let location = listing.location.as_mut().expect("location");
+ location.city = None;
+ location.region = None;
+ location.country = None;
+ assert_validation_err(
+ listing,
+ OperationalListingValidationError::MissingLocationLocality,
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_location_geohash() {
+ let mut listing = base_listing();
+ listing.location.as_mut().expect("location").geohash = " ".into();
+ assert_validation_err(
+ listing,
+ OperationalListingValidationError::ParseError {
+ error:
+ radroots_event::listing::operational::OperationalListingParseError::InvalidTag(
+ "g".to_string(),
+ ),
+ },
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_invalid_location_geohash() {
+ let mut listing = base_listing();
+ listing.location.as_mut().expect("location").geohash = "9q8yyz".into();
+ assert_validation_err(
+ listing,
+ OperationalListingValidationError::ParseError {
+ error:
+ radroots_event::listing::operational::OperationalListingParseError::InvalidTag(
+ "g".to_string(),
+ ),
+ },
+ );
+ }
+
+ #[test]
+ fn validate_listing_rejects_missing_delivery_method() {
+ let mut listing = base_listing();
+ listing.delivery_method = None;
+ assert_validation_err(
+ listing,
+ OperationalListingValidationError::MissingDeliveryMethod,
+ );
+ }
+
+ #[test]
+ fn validation_error_display_covers_all_variants() {
+ let errors = vec![
+ OperationalListingValidationError::InvalidKind { kind: 9 },
+ OperationalListingValidationError::InvalidProfile,
+ OperationalListingValidationError::MissingListingId,
+ OperationalListingValidationError::ListingEventNotFound {
+ listing_addr: "addr".into(),
+ },
+ OperationalListingValidationError::ListingEventFetchFailed {
+ listing_addr: "addr".into(),
+ },
+ OperationalListingValidationError::ParseError {
+ error:
+ radroots_event::listing::operational::OperationalListingParseError::InvalidTag(
+ "d".into(),
+ ),
+ },
+ OperationalListingValidationError::InvalidSeller,
+ OperationalListingValidationError::MissingFarmProfile,
+ OperationalListingValidationError::MissingFarmRecord,
+ OperationalListingValidationError::MissingTitle,
+ OperationalListingValidationError::MissingDescription,
+ OperationalListingValidationError::MissingProductType,
+ OperationalListingValidationError::MissingBins,
+ OperationalListingValidationError::MissingPrimaryBin,
+ OperationalListingValidationError::InvalidBin,
+ OperationalListingValidationError::MissingPrice,
+ OperationalListingValidationError::InvalidPrice,
+ OperationalListingValidationError::MissingInventory,
+ OperationalListingValidationError::InvalidInventory,
+ OperationalListingValidationError::MissingAvailability,
+ OperationalListingValidationError::MissingLocation,
+ OperationalListingValidationError::MissingLocationLocality,
+ OperationalListingValidationError::MissingLocationGeohash,
+ OperationalListingValidationError::InvalidLocationGeohash,
+ OperationalListingValidationError::MissingDeliveryMethod,
+ ];
+ for error in errors {
+ assert!(!error.to_string().trim().is_empty());
+ }
+ }
+}