lib

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

commit 7b0e4b3ef0f7fd41a2dd13318d974d973a04fcd7
parent 8bc581e6809e7065beda32cb31fbb91e8d6bf68c
Author: triesap <tyson@radroots.org>
Date:   Tue,  4 Aug 2026 15:32:17 +0000

test: preserve operational listing qualification

Diffstat:
Mcrates/sdk/Cargo.toml | 1+
Mcrates/sdk/src/listing/operational_listing/draft.rs | 379+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/listing/operational_listing/mod.rs | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/listing/operational_listing/mutation.rs | 349+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/listing/operational_listing/price_ext.rs | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/listing/operational_listing/validation.rs | 764++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
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()); + } + } +}