lib

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

mutation.rs (18897B)


      1 //! Mutation plan preparation for Radroots Listing v1.
      2 
      3 #![forbid(unsafe_code)]
      4 
      5 use core::fmt;
      6 
      7 use std::string::{String, ToString};
      8 
      9 use radroots_event::id::ClassifiedListingAddress;
     10 use radroots_event::{
     11     GenericEventDraft, draft::DraftError, envelope::kind::KIND_CLASSIFIED_LISTING,
     12 };
     13 use radroots_event_codec::{
     14     authoring::AuthoredEventPlan, encode::operational_listing::to_wire_parts_with_kind,
     15 };
     16 
     17 use crate::listing::operational_listing::draft::RadrootsOperationalListingCanonicalEdit;
     18 
     19 /// Listing v1 mutation intent for draft preparation only.
     20 ///
     21 /// Publish and update target the public listing event, while local-only draft
     22 /// persistence and archive are intentionally unsupported as wire events.
     23 #[derive(Clone, Debug)]
     24 pub enum RadrootsOperationalListingMutation {
     25     Publish {
     26         draft: RadrootsOperationalListingCanonicalEdit,
     27     },
     28     Update {
     29         draft: RadrootsOperationalListingCanonicalEdit,
     30     },
     31     SaveDraft {
     32         draft: RadrootsOperationalListingCanonicalEdit,
     33     },
     34     Archive {
     35         listing_addr: ClassifiedListingAddress,
     36     },
     37 }
     38 
     39 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     40 pub enum RadrootsOperationalListingLifecycleState {
     41     Draft,
     42     Published,
     43 }
     44 
     45 #[derive(Clone, Debug, PartialEq, Eq)]
     46 pub enum RadrootsOperationalListingMutationError {
     47     UnsupportedMutation,
     48     EncodeListing(String),
     49     AuthoredPlan(DraftError),
     50 }
     51 
     52 impl fmt::Display for RadrootsOperationalListingMutationError {
     53     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
     54         match self {
     55             Self::UnsupportedMutation => f.write_str("listing mutation is not supported"),
     56             Self::EncodeListing(error) => {
     57                 write!(f, "failed to encode listing mutation: {error}")
     58             }
     59             Self::AuthoredPlan(error) => {
     60                 write!(f, "failed to build listing authored plan: {error}")
     61             }
     62         }
     63     }
     64 }
     65 
     66 impl core::error::Error for RadrootsOperationalListingMutationError {}
     67 
     68 const OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID: &str = "radroots.operational_listing.published.v1";
     69 
     70 impl RadrootsOperationalListingMutation {
     71     pub fn publish(draft: RadrootsOperationalListingCanonicalEdit) -> Self {
     72         Self::Publish { draft }
     73     }
     74 
     75     pub fn update(draft: RadrootsOperationalListingCanonicalEdit) -> Self {
     76         Self::Update { draft }
     77     }
     78 
     79     pub fn save_draft(draft: RadrootsOperationalListingCanonicalEdit) -> Self {
     80         Self::SaveDraft { draft }
     81     }
     82 
     83     pub fn archive(listing_addr: ClassifiedListingAddress) -> Self {
     84         Self::Archive { listing_addr }
     85     }
     86 
     87     pub fn lifecycle_state(
     88         &self,
     89     ) -> Result<RadrootsOperationalListingLifecycleState, RadrootsOperationalListingMutationError>
     90     {
     91         match self {
     92             Self::Publish { .. } | Self::Update { .. } => {
     93                 Ok(RadrootsOperationalListingLifecycleState::Published)
     94             }
     95             Self::SaveDraft { .. } => Ok(RadrootsOperationalListingLifecycleState::Draft),
     96             Self::Archive { .. } => {
     97                 Err(RadrootsOperationalListingMutationError::UnsupportedMutation)
     98             }
     99         }
    100     }
    101 
    102     pub fn canonical_draft(
    103         &self,
    104     ) -> Result<&RadrootsOperationalListingCanonicalEdit, RadrootsOperationalListingMutationError>
    105     {
    106         match self {
    107             Self::Publish { draft } | Self::Update { draft } | Self::SaveDraft { draft } => {
    108                 Ok(draft)
    109             }
    110             Self::Archive { .. } => {
    111                 Err(RadrootsOperationalListingMutationError::UnsupportedMutation)
    112             }
    113         }
    114     }
    115 
    116     pub fn listing_addr(
    117         &self,
    118     ) -> Result<&ClassifiedListingAddress, RadrootsOperationalListingMutationError> {
    119         match self {
    120             Self::Publish { draft } | Self::Update { draft } => Ok(draft.public_listing_addr()),
    121             Self::SaveDraft { draft } => Ok(draft.public_listing_addr()),
    122             Self::Archive { .. } => {
    123                 Err(RadrootsOperationalListingMutationError::UnsupportedMutation)
    124             }
    125         }
    126     }
    127 }
    128 
    129 pub fn build_operational_listing_mutation_plan(
    130     mutation: &RadrootsOperationalListingMutation,
    131     created_at: u64,
    132 ) -> Result<AuthoredEventPlan, RadrootsOperationalListingMutationError> {
    133     let (draft, kind, contract_id) = match mutation {
    134         RadrootsOperationalListingMutation::Publish { draft }
    135         | RadrootsOperationalListingMutation::Update { draft } => (
    136             draft,
    137             KIND_CLASSIFIED_LISTING,
    138             OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID,
    139         ),
    140         RadrootsOperationalListingMutation::SaveDraft { .. }
    141         | RadrootsOperationalListingMutation::Archive { .. } => {
    142             return Err(RadrootsOperationalListingMutationError::UnsupportedMutation);
    143         }
    144     };
    145     let parts = to_wire_parts_with_kind(draft.listing(), kind).map_err(|error| {
    146         RadrootsOperationalListingMutationError::EncodeListing(error.to_string())
    147     })?;
    148     AuthoredEventPlan::from_generic(
    149         GenericEventDraft::new(
    150             contract_id,
    151             parts.kind,
    152             created_at,
    153             parts.tags,
    154             parts.content,
    155             draft.seller_pubkey().to_hex(),
    156         )
    157         .map_err(RadrootsOperationalListingMutationError::AuthoredPlan)?,
    158     )
    159     .map_err(RadrootsOperationalListingMutationError::AuthoredPlan)
    160 }
    161 
    162 #[cfg(all(test, feature = "local-signing"))]
    163 mod tests {
    164     const FIXTURE_ALICE_SECRET_KEY_HEX: &str =
    165         "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5";
    166     const FIXTURE_ALICE_PUBLIC_KEY_HEX: &str =
    167         "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df";
    168     use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
    169     use radroots_core::{Currency, Decimal, Money, Quantity, QuantityPrice, Unit};
    170     use radroots_event::{
    171         contract::validate_event_contract_shape,
    172         envelope::kind::KIND_CLASSIFIED_LISTING,
    173         farm::FarmRef,
    174         farm::resource_area::ResourceAreaRef,
    175         id::{ClassifiedListingAddress, DTag, InventoryBinId},
    176         listing::operational::{
    177             OperationalListing, OperationalListingAvailability, OperationalListingBin,
    178             OperationalListingDeliveryMethod, OperationalListingProduct,
    179             OperationalListingPublicLocation, OperationalListingStatus,
    180         },
    181         wire::Nip01EventWire,
    182     };
    183     use radroots_event_codec::{authoring::AuthoredEventPlan, verify::verify_nip01_event};
    184     use radroots_identity::PublicKey;
    185 
    186     use crate::listing::operational_listing::draft::RadrootsOperationalListingCanonicalEdit;
    187     use crate::listing::operational_listing::validation::validate_operational_listing_event;
    188 
    189     use super::{
    190         OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID, RadrootsOperationalListingLifecycleState,
    191         RadrootsOperationalListingMutation, RadrootsOperationalListingMutationError,
    192         build_operational_listing_mutation_plan,
    193     };
    194 
    195     const SELLER: &str = FIXTURE_ALICE_PUBLIC_KEY_HEX;
    196 
    197     fn d_tag(raw: &str) -> DTag {
    198         DTag::parse(raw).expect("d tag")
    199     }
    200 
    201     fn bin_id(raw: &str) -> InventoryBinId {
    202         InventoryBinId::parse(raw).expect("bin id")
    203     }
    204 
    205     fn sign_plan(plan: &AuthoredEventPlan) -> radroots_event::envelope::EventEnvelope {
    206         let keys = Keys::parse(FIXTURE_ALICE_SECRET_KEY_HEX).expect("fixture signing key");
    207         assert_eq!(keys.public_key().to_hex(), plan.author().to_hex());
    208         let tags = plan
    209             .body()
    210             .tags()
    211             .iter()
    212             .cloned()
    213             .map(|tag| Tag::parse(tag).expect("plan tag"))
    214             .collect::<Vec<_>>();
    215         let event = EventBuilder::new(
    216             Kind::Custom(u16::try_from(plan.body().kind()).expect("NIP-01 kind")),
    217             plan.body().content(),
    218         )
    219         .tags(tags)
    220         .allow_self_tagging()
    221         .custom_created_at(Timestamp::from_secs(plan.created_at()))
    222         .sign_with_keys(&keys)
    223         .expect("signed listing event");
    224         assert_eq!(event.id.to_hex(), plan.expected_event_id().to_hex());
    225         let raw_json = event.as_json();
    226         Nip01EventWire::parse_json(raw_json.as_str())
    227             .expect("canonical event wire")
    228             .into_envelope()
    229             .expect("event envelope")
    230     }
    231 
    232     fn listing() -> OperationalListing {
    233         OperationalListing {
    234             d_tag: d_tag("AAAAAAAAAAAAAAAAAAAAAg"),
    235             published_at: None,
    236             farm: FarmRef {
    237                 pubkey: SELLER.to_string(),
    238                 d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(),
    239             },
    240             product: OperationalListingProduct {
    241                 key: "coffee".to_string(),
    242                 title: "Coffee".to_string(),
    243                 category: "coffee".to_string(),
    244                 summary: Some("Single origin coffee".to_string()),
    245                 process: None,
    246                 lot: None,
    247                 location: None,
    248                 profile: None,
    249                 year: None,
    250             },
    251             primary_bin_id: bin_id("bin-1"),
    252             bins: vec![OperationalListingBin {
    253                 bin_id: bin_id("bin-1"),
    254                 quantity: Quantity::try_new(Decimal::from(1000u32), Unit::MassG).unwrap(),
    255                 price_per_canonical_unit: QuantityPrice::try_new(
    256                     Money::try_new(Decimal::from(20u32), Currency::USD).unwrap(),
    257                     Quantity::try_new(Decimal::from(1u32), Unit::MassG).unwrap(),
    258                 )
    259                 .unwrap(),
    260                 display_amount: None,
    261                 display_unit: None,
    262                 display_label: None,
    263                 display_price: None,
    264                 display_price_unit: None,
    265             }],
    266             resource_area: None,
    267             plot: None,
    268             discounts: None,
    269             inventory_available: Some(Decimal::from(5u32)),
    270             availability: Some(OperationalListingAvailability::Status {
    271                 status: OperationalListingStatus::Active,
    272             }),
    273             delivery_method: Some(OperationalListingDeliveryMethod::Pickup),
    274             location: Some(OperationalListingPublicLocation {
    275                 primary: "Farm".to_string(),
    276                 city: Some("Town".to_string()),
    277                 region: Some("Region".to_string()),
    278                 country: Some("US".to_string()),
    279                 geohash: "9q8yy".to_string(),
    280             }),
    281             images: None,
    282         }
    283     }
    284 
    285     fn canonical_draft() -> RadrootsOperationalListingCanonicalEdit {
    286         RadrootsOperationalListingCanonicalEdit::new(
    287             listing(),
    288             PublicKey::from_hex(SELLER).expect("seller"),
    289         )
    290         .expect("canonical listing edit")
    291     }
    292 
    293     #[test]
    294     fn supported_mutations_report_lifecycle_states() {
    295         assert_eq!(
    296             RadrootsOperationalListingMutation::publish(canonical_draft())
    297                 .lifecycle_state()
    298                 .expect("state"),
    299             RadrootsOperationalListingLifecycleState::Published
    300         );
    301         assert_eq!(
    302             RadrootsOperationalListingMutation::update(canonical_draft())
    303                 .lifecycle_state()
    304                 .expect("state"),
    305             RadrootsOperationalListingLifecycleState::Published
    306         );
    307         assert_eq!(
    308             RadrootsOperationalListingMutation::save_draft(canonical_draft())
    309                 .lifecycle_state()
    310                 .expect("state"),
    311             RadrootsOperationalListingLifecycleState::Draft
    312         );
    313     }
    314 
    315     #[test]
    316     fn supported_mutations_expose_canonical_drafts() {
    317         let publish = RadrootsOperationalListingMutation::publish(canonical_draft());
    318         let update = RadrootsOperationalListingMutation::update(canonical_draft());
    319         let save_draft = RadrootsOperationalListingMutation::save_draft(canonical_draft());
    320 
    321         assert_eq!(
    322             publish
    323                 .canonical_draft()
    324                 .expect("draft")
    325                 .seller_pubkey()
    326                 .to_hex(),
    327             SELLER
    328         );
    329         assert_eq!(
    330             update
    331                 .canonical_draft()
    332                 .expect("draft")
    333                 .seller_pubkey()
    334                 .to_hex(),
    335             SELLER
    336         );
    337         assert_eq!(
    338             save_draft
    339                 .canonical_draft()
    340                 .expect("draft")
    341                 .seller_pubkey()
    342                 .to_hex(),
    343             SELLER
    344         );
    345         assert_eq!(
    346             publish
    347                 .canonical_draft()
    348                 .expect("draft")
    349                 .listing()
    350                 .d_tag
    351                 .as_str(),
    352             "AAAAAAAAAAAAAAAAAAAAAg"
    353         );
    354     }
    355 
    356     #[test]
    357     fn supported_mutations_report_listing_addresses() {
    358         let publish = RadrootsOperationalListingMutation::publish(canonical_draft());
    359         let update = RadrootsOperationalListingMutation::update(canonical_draft());
    360         let save_draft = RadrootsOperationalListingMutation::save_draft(canonical_draft());
    361 
    362         assert_eq!(
    363             publish.listing_addr().expect("address").as_str(),
    364             format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")
    365         );
    366         assert_eq!(
    367             update.listing_addr().expect("address").as_str(),
    368             format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")
    369         );
    370         assert_eq!(
    371             save_draft.listing_addr().expect("address").as_str(),
    372             format!("{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")
    373         );
    374     }
    375 
    376     #[test]
    377     fn archive_is_explicitly_unsupported() {
    378         let archive = RadrootsOperationalListingMutation::archive(
    379             ClassifiedListingAddress::parse(format!(
    380                 "{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"
    381             ))
    382             .expect("listing address"),
    383         );
    384 
    385         assert_eq!(
    386             archive.lifecycle_state().unwrap_err(),
    387             RadrootsOperationalListingMutationError::UnsupportedMutation
    388         );
    389         assert_eq!(
    390             archive.canonical_draft().unwrap_err(),
    391             RadrootsOperationalListingMutationError::UnsupportedMutation
    392         );
    393         assert_eq!(
    394             archive.listing_addr().unwrap_err(),
    395             RadrootsOperationalListingMutationError::UnsupportedMutation
    396         );
    397     }
    398 
    399     #[test]
    400     fn build_operational_listing_mutation_plan_maps_publish_and_update_to_published_listing() {
    401         let publish = RadrootsOperationalListingMutation::publish(canonical_draft());
    402         let update = RadrootsOperationalListingMutation::update(canonical_draft());
    403 
    404         let publish_draft =
    405             build_operational_listing_mutation_plan(&publish, 1_700_000_000).expect("draft");
    406         let update_draft =
    407             build_operational_listing_mutation_plan(&update, 1_700_000_000).expect("draft");
    408 
    409         assert_eq!(publish_draft.body().kind(), KIND_CLASSIFIED_LISTING);
    410         assert_eq!(
    411             publish_draft.body().contract().contract_id().as_str(),
    412             OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID
    413         );
    414         assert_eq!(publish_draft.author().to_hex(), SELLER);
    415         assert_eq!(publish_draft.created_at(), 1_700_000_000);
    416         assert_eq!(
    417             publish_draft.body().content(),
    418             "# Coffee\n\nSingle origin coffee"
    419         );
    420         assert_eq!(update_draft.body().kind(), KIND_CLASSIFIED_LISTING);
    421         assert_eq!(
    422             update_draft.body().contract().contract_id().as_str(),
    423             OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID
    424         );
    425         assert_eq!(update_draft.author().to_hex(), SELLER);
    426     }
    427 
    428     #[test]
    429     fn build_operational_listing_mutation_plan_rejects_save_draft() {
    430         let save_draft = RadrootsOperationalListingMutation::save_draft(canonical_draft());
    431 
    432         assert_eq!(
    433             build_operational_listing_mutation_plan(&save_draft, 1_700_000_000).unwrap_err(),
    434             RadrootsOperationalListingMutationError::UnsupportedMutation
    435         );
    436     }
    437 
    438     #[test]
    439     fn build_operational_listing_mutation_plan_rejects_archive() {
    440         let archive = RadrootsOperationalListingMutation::archive(
    441             ClassifiedListingAddress::parse(format!(
    442                 "{KIND_CLASSIFIED_LISTING}:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg"
    443             ))
    444             .expect("listing address"),
    445         );
    446 
    447         assert_eq!(
    448             build_operational_listing_mutation_plan(&archive, 1_700_000_000).unwrap_err(),
    449             RadrootsOperationalListingMutationError::UnsupportedMutation
    450         );
    451     }
    452 
    453     #[test]
    454     fn build_operational_listing_mutation_plan_reports_encode_errors() {
    455         let mut listing = listing();
    456         listing.resource_area = Some(ResourceAreaRef {
    457             pubkey: SELLER.to_string(),
    458             d_tag: "bad d tag".to_string(),
    459         });
    460         let draft = RadrootsOperationalListingCanonicalEdit::new(
    461             listing,
    462             PublicKey::from_hex(SELLER).expect("seller"),
    463         )
    464         .expect("canonical listing edit");
    465         let publish = RadrootsOperationalListingMutation::publish(draft);
    466 
    467         let err = build_operational_listing_mutation_plan(&publish, 1_700_000_000).unwrap_err();
    468 
    469         assert!(matches!(
    470             err,
    471             RadrootsOperationalListingMutationError::EncodeListing(_)
    472         ));
    473     }
    474 
    475     #[test]
    476     fn build_operational_listing_mutation_plan_event_id_is_stable_for_fixed_input() {
    477         let publish = RadrootsOperationalListingMutation::publish(canonical_draft());
    478 
    479         let first =
    480             build_operational_listing_mutation_plan(&publish, 1_700_000_000).expect("draft");
    481         let second =
    482             build_operational_listing_mutation_plan(&publish, 1_700_000_000).expect("draft");
    483 
    484         assert_eq!(first.expected_event_id(), second.expected_event_id());
    485         assert_eq!(first.expected_event_id().to_hex().len(), 64);
    486         assert_eq!(first.body().tags(), second.body().tags());
    487         assert_eq!(first.body().content(), second.body().content());
    488     }
    489 
    490     #[test]
    491     fn build_operational_listing_mutation_plan_output_validates_as_operational_listing() {
    492         let publish = RadrootsOperationalListingMutation::publish(canonical_draft());
    493         let draft =
    494             build_operational_listing_mutation_plan(&publish, 1_700_000_000).expect("draft");
    495 
    496         let signed = sign_plan(&draft);
    497         validate_event_contract_shape(&signed, OPERATIONAL_LISTING_PUBLISHED_CONTRACT_ID)
    498             .expect("operational listing contract");
    499         let verified = verify_nip01_event(signed).expect("verified listing");
    500         let validated = validate_operational_listing_event(&verified).expect("validated listing");
    501 
    502         assert_eq!(validated.seller_pubkey, SELLER);
    503         assert!(
    504             validated
    505                 .listing_addr
    506                 .as_str()
    507                 .contains(&format!(":{SELLER}:"))
    508         );
    509     }
    510 }