lib

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

mutation.rs (16427B)


      1 //! Mutation draft preparation for Radroots Listing v1.
      2 //!
      3 //! Publish and update produce the stable public NIP-99 listing-kind event with
      4 //! Radroots-specific JSON content, save-draft produces the stable listing-draft
      5 //! event, and archive remains unsupported because Listing v1 has no archive
      6 //! wire event. Strict NIP-99 Markdown-content interoperability is protocol-v2
      7 //! work.
      8 
      9 #![forbid(unsafe_code)]
     10 
     11 #[cfg(all(feature = "serde_json", not(feature = "std")))]
     12 use alloc::string::{String, ToString};
     13 
     14 #[cfg(all(feature = "serde_json", feature = "std"))]
     15 use std::string::{String, ToString};
     16 
     17 use radroots_events::ids::RadrootsListingAddress;
     18 #[cfg(feature = "serde_json")]
     19 use radroots_events::{
     20     draft::{RadrootsDraftError, RadrootsFrozenEventDraft},
     21     kinds::{KIND_LISTING, KIND_LISTING_DRAFT},
     22 };
     23 #[cfg(feature = "serde_json")]
     24 use radroots_events_codec::{listing::encode::to_wire_parts_with_kind, wire::to_frozen_draft};
     25 use thiserror::Error;
     26 
     27 use crate::listing::draft::RadrootsCanonicalListingDraft;
     28 
     29 /// Listing v1 mutation intent for draft preparation only.
     30 ///
     31 /// Publish and update target the public listing event, save-draft targets the
     32 /// secret listing-draft event, and archive is intentionally unsupported because
     33 /// listing v1 defines no archive wire event.
     34 #[derive(Clone, Debug)]
     35 pub enum RadrootsListingMutation {
     36     Publish {
     37         draft: RadrootsCanonicalListingDraft,
     38     },
     39     Update {
     40         draft: RadrootsCanonicalListingDraft,
     41     },
     42     SaveDraft {
     43         draft: RadrootsCanonicalListingDraft,
     44     },
     45     Archive {
     46         listing_addr: RadrootsListingAddress,
     47     },
     48 }
     49 
     50 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     51 pub enum RadrootsListingLifecycleState {
     52     Draft,
     53     Published,
     54 }
     55 
     56 #[derive(Clone, Debug, Error, PartialEq, Eq)]
     57 pub enum RadrootsListingMutationError {
     58     #[error("listing mutation is not supported")]
     59     UnsupportedMutation,
     60     #[cfg(feature = "serde_json")]
     61     #[error("failed to encode listing mutation: {0}")]
     62     EncodeListing(String),
     63     #[cfg(feature = "serde_json")]
     64     #[error("failed to build listing mutation draft: {0}")]
     65     FrozenDraft(RadrootsDraftError),
     66 }
     67 
     68 const LISTING_PUBLISHED_CONTRACT_ID: &str = "radroots.listing.published.v1";
     69 const LISTING_DRAFT_CONTRACT_ID: &str = "radroots.listing.draft.v1";
     70 
     71 impl RadrootsListingMutation {
     72     pub fn publish(draft: RadrootsCanonicalListingDraft) -> Self {
     73         Self::Publish { draft }
     74     }
     75 
     76     pub fn update(draft: RadrootsCanonicalListingDraft) -> Self {
     77         Self::Update { draft }
     78     }
     79 
     80     pub fn save_draft(draft: RadrootsCanonicalListingDraft) -> Self {
     81         Self::SaveDraft { draft }
     82     }
     83 
     84     pub fn archive(listing_addr: RadrootsListingAddress) -> Self {
     85         Self::Archive { listing_addr }
     86     }
     87 
     88     pub fn lifecycle_state(
     89         &self,
     90     ) -> Result<RadrootsListingLifecycleState, RadrootsListingMutationError> {
     91         match self {
     92             Self::Publish { .. } | Self::Update { .. } => {
     93                 Ok(RadrootsListingLifecycleState::Published)
     94             }
     95             Self::SaveDraft { .. } => Ok(RadrootsListingLifecycleState::Draft),
     96             Self::Archive { .. } => Err(RadrootsListingMutationError::UnsupportedMutation),
     97         }
     98     }
     99 
    100     pub fn canonical_draft(
    101         &self,
    102     ) -> Result<&RadrootsCanonicalListingDraft, RadrootsListingMutationError> {
    103         match self {
    104             Self::Publish { draft } | Self::Update { draft } | Self::SaveDraft { draft } => {
    105                 Ok(draft)
    106             }
    107             Self::Archive { .. } => Err(RadrootsListingMutationError::UnsupportedMutation),
    108         }
    109     }
    110 
    111     pub fn listing_addr(&self) -> Result<&RadrootsListingAddress, RadrootsListingMutationError> {
    112         match self {
    113             Self::Publish { draft } | Self::Update { draft } => Ok(draft.public_listing_addr()),
    114             Self::SaveDraft { draft } => Ok(draft.draft_listing_addr()),
    115             Self::Archive { .. } => Err(RadrootsListingMutationError::UnsupportedMutation),
    116         }
    117     }
    118 }
    119 
    120 #[cfg(feature = "serde_json")]
    121 pub fn build_listing_mutation_draft(
    122     mutation: &RadrootsListingMutation,
    123     created_at: u32,
    124 ) -> Result<RadrootsFrozenEventDraft, RadrootsListingMutationError> {
    125     let (draft, kind, contract_id) = match mutation {
    126         RadrootsListingMutation::Publish { draft } | RadrootsListingMutation::Update { draft } => {
    127             (draft, KIND_LISTING, LISTING_PUBLISHED_CONTRACT_ID)
    128         }
    129         RadrootsListingMutation::SaveDraft { draft } => {
    130             (draft, KIND_LISTING_DRAFT, LISTING_DRAFT_CONTRACT_ID)
    131         }
    132         RadrootsListingMutation::Archive { .. } => {
    133             return Err(RadrootsListingMutationError::UnsupportedMutation);
    134         }
    135     };
    136     let parts = to_wire_parts_with_kind(draft.listing(), kind)
    137         .map_err(|error| RadrootsListingMutationError::EncodeListing(error.to_string()))?;
    138     to_frozen_draft(
    139         parts,
    140         contract_id,
    141         draft.seller_pubkey().as_str(),
    142         created_at,
    143     )
    144     .map_err(RadrootsListingMutationError::FrozenDraft)
    145 }
    146 
    147 #[cfg(test)]
    148 mod tests {
    149     use radroots_core::{
    150         RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
    151         RadrootsCoreQuantityPrice, RadrootsCoreUnit,
    152     };
    153     use radroots_events::{
    154         RadrootsNostrEvent,
    155         farm::RadrootsFarmRef,
    156         ids::{RadrootsDTag, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsPublicKey},
    157         kinds::{KIND_LISTING, KIND_LISTING_DRAFT},
    158         listing::{
    159             RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
    160             RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingProduct,
    161             RadrootsListingStatus,
    162         },
    163         resource_area::RadrootsResourceAreaRef,
    164     };
    165 
    166     use crate::listing::draft::RadrootsCanonicalListingDraft;
    167     use crate::listing::validation::validate_listing_event;
    168 
    169     use super::{
    170         LISTING_DRAFT_CONTRACT_ID, LISTING_PUBLISHED_CONTRACT_ID, RadrootsListingLifecycleState,
    171         RadrootsListingMutation, RadrootsListingMutationError, build_listing_mutation_draft,
    172     };
    173 
    174     const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
    175 
    176     fn d_tag(raw: &str) -> RadrootsDTag {
    177         RadrootsDTag::parse(raw).expect("d tag")
    178     }
    179 
    180     fn bin_id(raw: &str) -> RadrootsInventoryBinId {
    181         RadrootsInventoryBinId::parse(raw).expect("bin id")
    182     }
    183 
    184     fn listing() -> RadrootsListing {
    185         RadrootsListing {
    186             d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"),
    187             published_at: None,
    188             farm: RadrootsFarmRef {
    189                 pubkey: SELLER.to_string(),
    190                 d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
    191             },
    192             product: RadrootsListingProduct {
    193                 key: "coffee".to_string(),
    194                 title: "Coffee".to_string(),
    195                 category: "coffee".to_string(),
    196                 summary: Some("Single origin coffee".to_string()),
    197                 process: None,
    198                 lot: None,
    199                 location: None,
    200                 profile: None,
    201                 year: None,
    202             },
    203             primary_bin_id: bin_id("bin-1"),
    204             bins: vec![RadrootsListingBin {
    205                 bin_id: bin_id("bin-1"),
    206                 quantity: RadrootsCoreQuantity::new(
    207                     RadrootsCoreDecimal::from(1000u32),
    208                     RadrootsCoreUnit::MassG,
    209                 ),
    210                 price_per_canonical_unit: RadrootsCoreQuantityPrice {
    211                     amount: RadrootsCoreMoney::new(
    212                         RadrootsCoreDecimal::from(20u32),
    213                         RadrootsCoreCurrency::USD,
    214                     ),
    215                     quantity: RadrootsCoreQuantity::new(
    216                         RadrootsCoreDecimal::from(1u32),
    217                         RadrootsCoreUnit::MassG,
    218                     ),
    219                 },
    220                 display_amount: None,
    221                 display_unit: None,
    222                 display_label: None,
    223                 display_price: None,
    224                 display_price_unit: None,
    225             }],
    226             resource_area: None,
    227             plot: None,
    228             discounts: None,
    229             inventory_available: Some(RadrootsCoreDecimal::from(5u32)),
    230             availability: Some(RadrootsListingAvailability::Status {
    231                 status: RadrootsListingStatus::Active,
    232             }),
    233             delivery_method: Some(RadrootsListingDeliveryMethod::Pickup),
    234             location: Some(RadrootsListingLocation {
    235                 primary: "Farm".to_string(),
    236                 city: None,
    237                 region: None,
    238                 country: None,
    239                 lat: None,
    240                 lng: None,
    241                 geohash: None,
    242             }),
    243             images: None,
    244         }
    245     }
    246 
    247     fn canonical_draft() -> RadrootsCanonicalListingDraft {
    248         RadrootsCanonicalListingDraft::new(
    249             listing(),
    250             RadrootsPublicKey::parse(SELLER).expect("seller"),
    251         )
    252         .expect("canonical listing draft")
    253     }
    254 
    255     #[test]
    256     fn supported_mutations_report_lifecycle_states() {
    257         assert_eq!(
    258             RadrootsListingMutation::publish(canonical_draft())
    259                 .lifecycle_state()
    260                 .expect("state"),
    261             RadrootsListingLifecycleState::Published
    262         );
    263         assert_eq!(
    264             RadrootsListingMutation::update(canonical_draft())
    265                 .lifecycle_state()
    266                 .expect("state"),
    267             RadrootsListingLifecycleState::Published
    268         );
    269         assert_eq!(
    270             RadrootsListingMutation::save_draft(canonical_draft())
    271                 .lifecycle_state()
    272                 .expect("state"),
    273             RadrootsListingLifecycleState::Draft
    274         );
    275     }
    276 
    277     #[test]
    278     fn supported_mutations_expose_canonical_drafts() {
    279         let publish = RadrootsListingMutation::publish(canonical_draft());
    280         let update = RadrootsListingMutation::update(canonical_draft());
    281         let save_draft = RadrootsListingMutation::save_draft(canonical_draft());
    282 
    283         assert_eq!(
    284             publish
    285                 .canonical_draft()
    286                 .expect("draft")
    287                 .seller_pubkey()
    288                 .as_str(),
    289             SELLER
    290         );
    291         assert_eq!(
    292             update
    293                 .canonical_draft()
    294                 .expect("draft")
    295                 .seller_pubkey()
    296                 .as_str(),
    297             SELLER
    298         );
    299         assert_eq!(
    300             save_draft
    301                 .canonical_draft()
    302                 .expect("draft")
    303                 .seller_pubkey()
    304                 .as_str(),
    305             SELLER
    306         );
    307         assert_eq!(
    308             publish
    309                 .canonical_draft()
    310                 .expect("draft")
    311                 .listing()
    312                 .d_tag
    313                 .as_str(),
    314             "AAAAAAAAAAAAAAAAAAAAAg"
    315         );
    316     }
    317 
    318     #[test]
    319     fn supported_mutations_report_listing_addresses() {
    320         let publish = RadrootsListingMutation::publish(canonical_draft());
    321         let update = RadrootsListingMutation::update(canonical_draft());
    322         let save_draft = RadrootsListingMutation::save_draft(canonical_draft());
    323 
    324         assert_eq!(
    325             publish.listing_addr().expect("address").as_str(),
    326             format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")
    327         );
    328         assert_eq!(
    329             update.listing_addr().expect("address").as_str(),
    330             format!("{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")
    331         );
    332         assert_eq!(
    333             save_draft.listing_addr().expect("address").as_str(),
    334             format!("{KIND_LISTING_DRAFT}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")
    335         );
    336     }
    337 
    338     #[test]
    339     fn archive_is_explicitly_unsupported() {
    340         let archive = RadrootsListingMutation::archive(
    341             RadrootsListingAddress::parse(format!(
    342                 "{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"
    343             ))
    344             .expect("listing address"),
    345         );
    346 
    347         assert_eq!(
    348             archive.lifecycle_state().unwrap_err(),
    349             RadrootsListingMutationError::UnsupportedMutation
    350         );
    351         assert_eq!(
    352             archive.canonical_draft().unwrap_err(),
    353             RadrootsListingMutationError::UnsupportedMutation
    354         );
    355         assert_eq!(
    356             archive.listing_addr().unwrap_err(),
    357             RadrootsListingMutationError::UnsupportedMutation
    358         );
    359     }
    360 
    361     #[test]
    362     fn build_listing_mutation_draft_maps_publish_and_update_to_published_listing() {
    363         let publish = RadrootsListingMutation::publish(canonical_draft());
    364         let update = RadrootsListingMutation::update(canonical_draft());
    365 
    366         let publish_draft = build_listing_mutation_draft(&publish, 1_700_000_000).expect("draft");
    367         let update_draft = build_listing_mutation_draft(&update, 1_700_000_000).expect("draft");
    368 
    369         assert_eq!(publish_draft.kind, KIND_LISTING);
    370         assert_eq!(publish_draft.contract_id, LISTING_PUBLISHED_CONTRACT_ID);
    371         assert_eq!(publish_draft.expected_pubkey, SELLER);
    372         assert_eq!(publish_draft.created_at, 1_700_000_000);
    373         assert_eq!(update_draft.kind, KIND_LISTING);
    374         assert_eq!(update_draft.contract_id, LISTING_PUBLISHED_CONTRACT_ID);
    375         assert_eq!(update_draft.expected_pubkey, SELLER);
    376     }
    377 
    378     #[test]
    379     fn build_listing_mutation_draft_maps_save_draft_to_listing_draft() {
    380         let save_draft = RadrootsListingMutation::save_draft(canonical_draft());
    381 
    382         let draft = build_listing_mutation_draft(&save_draft, 1_700_000_000).expect("draft");
    383 
    384         assert_eq!(draft.kind, KIND_LISTING_DRAFT);
    385         assert_eq!(draft.contract_id, LISTING_DRAFT_CONTRACT_ID);
    386         assert_eq!(draft.expected_pubkey, SELLER);
    387         assert_eq!(draft.created_at, 1_700_000_000);
    388     }
    389 
    390     #[test]
    391     fn build_listing_mutation_draft_rejects_archive() {
    392         let archive = RadrootsListingMutation::archive(
    393             RadrootsListingAddress::parse(format!(
    394                 "{KIND_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"
    395             ))
    396             .expect("listing address"),
    397         );
    398 
    399         assert_eq!(
    400             build_listing_mutation_draft(&archive, 1_700_000_000).unwrap_err(),
    401             RadrootsListingMutationError::UnsupportedMutation
    402         );
    403     }
    404 
    405     #[test]
    406     fn build_listing_mutation_draft_reports_encode_errors() {
    407         let mut listing = listing();
    408         listing.resource_area = Some(RadrootsResourceAreaRef {
    409             pubkey: SELLER.to_string(),
    410             d_tag: "bad d tag".to_string(),
    411         });
    412         let draft = RadrootsCanonicalListingDraft::new(
    413             listing,
    414             RadrootsPublicKey::parse(SELLER).expect("seller"),
    415         )
    416         .expect("canonical listing draft");
    417         let publish = RadrootsListingMutation::publish(draft);
    418 
    419         let err = build_listing_mutation_draft(&publish, 1_700_000_000).unwrap_err();
    420 
    421         assert!(matches!(
    422             err,
    423             RadrootsListingMutationError::EncodeListing(_)
    424         ));
    425     }
    426 
    427     #[test]
    428     fn build_listing_mutation_draft_event_id_is_stable_for_fixed_input() {
    429         let publish = RadrootsListingMutation::publish(canonical_draft());
    430 
    431         let first = build_listing_mutation_draft(&publish, 1_700_000_000).expect("draft");
    432         let second = build_listing_mutation_draft(&publish, 1_700_000_000).expect("draft");
    433 
    434         assert_eq!(first.expected_event_id, second.expected_event_id);
    435         assert_eq!(first.expected_event_id.len(), 64);
    436         assert_eq!(first.tags, second.tags);
    437         assert_eq!(first.content, second.content);
    438     }
    439 
    440     #[test]
    441     fn build_listing_mutation_draft_output_validates_as_trade_listing() {
    442         let publish = RadrootsListingMutation::publish(canonical_draft());
    443         let draft = build_listing_mutation_draft(&publish, 1_700_000_000).expect("draft");
    444 
    445         let event = RadrootsNostrEvent {
    446             id: String::new(),
    447             author: draft.expected_pubkey.clone(),
    448             created_at: draft.created_at,
    449             kind: draft.kind,
    450             tags: draft.tags,
    451             content: draft.content,
    452             sig: String::new(),
    453         };
    454         let validated = validate_listing_event(&event).expect("validated listing");
    455 
    456         assert_eq!(validated.seller_pubkey, SELLER);
    457         assert!(validated.listing_addr.contains(&format!(":{SELLER}:")));
    458     }
    459 }