lib

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

reaction.rs (12952B)


      1 use radroots_events::{
      2     kinds::{KIND_ARTICLE, KIND_COMMENT, KIND_POST, KIND_REACTION},
      3     reaction::RadrootsReaction,
      4     social::RadrootsSocialTarget,
      5     tags::TAG_E_ROOT,
      6 };
      7 use radroots_events_codec::error::{EventEncodeError, EventParseError};
      8 use radroots_events_codec::reaction::decode::{
      9     data_from_event, parsed_from_event, reaction_from_tags,
     10 };
     11 use radroots_events_codec::reaction::encode::{
     12     reaction_build_tags, to_wire_parts, to_wire_parts_with_kind,
     13 };
     14 
     15 const EVENT_ID: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
     16 const AUTHOR: &str = "author_pubkey";
     17 const D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA";
     18 
     19 fn event_target() -> RadrootsSocialTarget {
     20     RadrootsSocialTarget::Event {
     21         id: EVENT_ID.to_string(),
     22         author: Some(AUTHOR.to_string()),
     23         event_kind: Some(KIND_ARTICLE),
     24         relays: Some(vec!["wss://relay.example.test".to_string()]),
     25     }
     26 }
     27 
     28 fn address_target() -> RadrootsSocialTarget {
     29     RadrootsSocialTarget::Address {
     30         address: format!("{}:{AUTHOR}:{D_TAG}", KIND_ARTICLE),
     31         author: Some(AUTHOR.to_string()),
     32         event_kind: Some(KIND_ARTICLE),
     33         relays: Some(vec!["wss://relay2.example.test".to_string()]),
     34     }
     35 }
     36 
     37 fn assert_event_target(target: &RadrootsSocialTarget) {
     38     match target {
     39         RadrootsSocialTarget::Event {
     40             id,
     41             author,
     42             event_kind,
     43             relays,
     44         } => {
     45             assert_eq!(id, EVENT_ID);
     46             assert_eq!(author.as_deref(), Some(AUTHOR));
     47             assert_eq!(*event_kind, Some(KIND_ARTICLE));
     48             assert_eq!(relays.as_ref().map(Vec::len), Some(1));
     49         }
     50         _ => panic!("expected event target"),
     51     }
     52 }
     53 
     54 fn assert_address_target(target: &RadrootsSocialTarget) {
     55     match target {
     56         RadrootsSocialTarget::Address {
     57             address,
     58             author,
     59             event_kind,
     60             relays,
     61         } => {
     62             assert_eq!(address, &format!("{}:{AUTHOR}:{D_TAG}", KIND_ARTICLE));
     63             assert_eq!(author.as_deref(), Some(AUTHOR));
     64             assert_eq!(*event_kind, Some(KIND_ARTICLE));
     65             assert_eq!(relays.as_ref().map(Vec::len), Some(1));
     66         }
     67         _ => panic!("expected address target"),
     68     }
     69 }
     70 
     71 #[test]
     72 fn reaction_build_tags_requires_valid_event_or_address_target() {
     73     let reaction = RadrootsReaction {
     74         target: RadrootsSocialTarget::Event {
     75             id: "not-hex".to_string(),
     76             author: Some(AUTHOR.to_string()),
     77             event_kind: Some(KIND_ARTICLE),
     78             relays: None,
     79         },
     80         content: "+".to_string(),
     81     };
     82     assert!(matches!(
     83         reaction_build_tags(&reaction),
     84         Err(EventEncodeError::InvalidField("target.id"))
     85     ));
     86 
     87     let reaction = RadrootsReaction {
     88         target: RadrootsSocialTarget::External {
     89             id: "https://example.test".to_string(),
     90             external_kind: "web".to_string(),
     91             hint: None,
     92         },
     93         content: "+".to_string(),
     94     };
     95     assert!(matches!(
     96         reaction_build_tags(&reaction),
     97         Err(EventEncodeError::InvalidField("target"))
     98     ));
     99 
    100     let reaction = RadrootsReaction {
    101         target: RadrootsSocialTarget::Event {
    102             id: EVENT_ID.to_string(),
    103             author: Some(" ".to_string()),
    104             event_kind: Some(KIND_ARTICLE),
    105             relays: None,
    106         },
    107         content: "+".to_string(),
    108     };
    109     assert!(matches!(
    110         reaction_build_tags(&reaction),
    111         Err(EventEncodeError::EmptyRequiredField("target.author"))
    112     ));
    113 
    114     let reaction = RadrootsReaction {
    115         target: RadrootsSocialTarget::Address {
    116             address: format!("{}:{AUTHOR}:{D_TAG}", KIND_ARTICLE),
    117             author: Some(AUTHOR.to_string()),
    118             event_kind: Some(KIND_COMMENT),
    119             relays: None,
    120         },
    121         content: "+".to_string(),
    122     };
    123     assert!(matches!(
    124         reaction_build_tags(&reaction),
    125         Err(EventEncodeError::InvalidField("target.kind"))
    126     ));
    127 
    128     let reaction = RadrootsReaction {
    129         target: RadrootsSocialTarget::Address {
    130             address: format!("{}:{AUTHOR}:{D_TAG}", KIND_ARTICLE),
    131             author: Some("other_author".to_string()),
    132             event_kind: Some(KIND_ARTICLE),
    133             relays: None,
    134         },
    135         content: "+".to_string(),
    136     };
    137     assert!(matches!(
    138         reaction_build_tags(&reaction),
    139         Err(EventEncodeError::InvalidField("target.author"))
    140     ));
    141 }
    142 
    143 #[test]
    144 fn reaction_to_wire_parts_accepts_empty_plus_minus_emoji_and_custom_content() {
    145     for content in ["", "+", "-", "🔥", "harvest"] {
    146         let reaction = RadrootsReaction {
    147             target: event_target(),
    148             content: content.to_string(),
    149         };
    150         let parts = to_wire_parts(&reaction).unwrap();
    151         assert_eq!(parts.kind, KIND_REACTION);
    152         assert_eq!(parts.content, content);
    153         assert!(parts.tags.iter().any(|tag| tag[0] == "e"));
    154     }
    155 }
    156 
    157 #[test]
    158 fn reaction_build_tags_covers_optional_target_branches() {
    159     let reaction = RadrootsReaction {
    160         target: RadrootsSocialTarget::Event {
    161             id: EVENT_ID.to_string(),
    162             author: None,
    163             event_kind: None,
    164             relays: Some(vec!["wss://event-relay.example.test".to_string()]),
    165         },
    166         content: "+".to_string(),
    167     };
    168     let tags = reaction_build_tags(&reaction).unwrap();
    169     assert!(tags.iter().any(|tag| {
    170         tag.first().map(|value| value.as_str()) == Some("e")
    171             && tag
    172                 .iter()
    173                 .any(|value| value == "wss://event-relay.example.test")
    174     }));
    175     assert!(!tags.iter().any(|tag| tag[0] == "p"));
    176     assert!(!tags.iter().any(|tag| tag[0] == "k"));
    177 
    178     let reaction = RadrootsReaction {
    179         target: RadrootsSocialTarget::Address {
    180             address: format!("{}:{AUTHOR}:{D_TAG}", KIND_ARTICLE),
    181             author: None,
    182             event_kind: None,
    183             relays: Some(vec!["wss://address-relay.example.test".to_string()]),
    184         },
    185         content: "+".to_string(),
    186     };
    187     let tags = reaction_build_tags(&reaction).unwrap();
    188     assert!(tags.iter().any(|tag| {
    189         tag.first().map(|value| value.as_str()) == Some("a")
    190             && tag
    191                 .iter()
    192                 .any(|value| value == "wss://address-relay.example.test")
    193     }));
    194 
    195     let reaction = RadrootsReaction {
    196         target: RadrootsSocialTarget::Address {
    197             address: format!("{}:{AUTHOR}:{D_TAG}", KIND_ARTICLE),
    198             author: None,
    199             event_kind: None,
    200             relays: None,
    201         },
    202         content: "+".to_string(),
    203     };
    204     let tags = reaction_build_tags(&reaction).unwrap();
    205     let address_tag = tags
    206         .iter()
    207         .find(|tag| tag.first().map(String::as_str) == Some("a"))
    208         .expect("address tag");
    209     assert_eq!(address_tag.len(), 2);
    210 }
    211 
    212 #[test]
    213 fn reaction_to_wire_parts_with_kind_rejects_non_reaction_kind() {
    214     let reaction = RadrootsReaction {
    215         target: event_target(),
    216         content: "+".to_string(),
    217     };
    218     assert!(matches!(
    219         to_wire_parts_with_kind(&reaction, KIND_COMMENT),
    220         Err(EventEncodeError::InvalidKind(KIND_COMMENT))
    221     ));
    222 }
    223 
    224 #[test]
    225 fn reaction_roundtrips_event_target() {
    226     let reaction = RadrootsReaction {
    227         target: event_target(),
    228         content: "+".to_string(),
    229     };
    230     let parts = to_wire_parts(&reaction).unwrap();
    231     let parsed = reaction_from_tags(parts.kind, &parts.tags, &parts.content).unwrap();
    232 
    233     assert_event_target(&parsed.target);
    234     assert_eq!(parsed.content, "+");
    235 }
    236 
    237 #[test]
    238 fn reaction_roundtrips_address_target() {
    239     let reaction = RadrootsReaction {
    240         target: address_target(),
    241         content: "".to_string(),
    242     };
    243     let parts = to_wire_parts(&reaction).unwrap();
    244     let parsed = reaction_from_tags(parts.kind, &parts.tags, &parts.content).unwrap();
    245 
    246     assert_address_target(&parsed.target);
    247     assert_eq!(parsed.content, "");
    248 }
    249 
    250 #[test]
    251 fn reaction_from_tags_rejects_missing_legacy_and_mismatched_targets() {
    252     assert!(matches!(
    253         reaction_from_tags(
    254             KIND_REACTION,
    255             &[vec!["p".to_string(), AUTHOR.to_string()]],
    256             "+"
    257         ),
    258         Err(EventParseError::MissingTag("e"))
    259     ));
    260 
    261     assert!(matches!(
    262         reaction_from_tags(
    263             KIND_REACTION,
    264             &[vec![TAG_E_ROOT.to_string(), EVENT_ID.to_string()]],
    265             "+"
    266         ),
    267         Err(EventParseError::InvalidTag(TAG_E_ROOT))
    268     ));
    269 
    270     assert!(matches!(
    271         reaction_from_tags(
    272             KIND_REACTION,
    273             &[
    274                 vec!["e".to_string(), EVENT_ID.to_string()],
    275                 vec![
    276                     "a".to_string(),
    277                     format!("{}:{AUTHOR}:{D_TAG}", KIND_ARTICLE)
    278                 ]
    279             ],
    280             "+"
    281         ),
    282         Err(EventParseError::InvalidTag("e"))
    283     ));
    284 
    285     assert!(matches!(
    286         reaction_from_tags(
    287             KIND_REACTION,
    288             &[
    289                 vec![
    290                     "a".to_string(),
    291                     format!("{}:{AUTHOR}:{D_TAG}", KIND_ARTICLE)
    292                 ],
    293                 vec!["p".to_string(), "other_author".to_string()]
    294             ],
    295             "+"
    296         ),
    297         Err(EventParseError::InvalidTag("p"))
    298     ));
    299 }
    300 
    301 #[test]
    302 fn reaction_from_tags_covers_optional_decode_branches() {
    303     let event = reaction_from_tags(
    304         KIND_REACTION,
    305         &[vec!["e".to_string(), EVENT_ID.to_string()]],
    306         "+",
    307     )
    308     .unwrap();
    309     assert_eq!(event.content, "+");
    310     match event.target {
    311         RadrootsSocialTarget::Event {
    312             id,
    313             author,
    314             event_kind,
    315             relays,
    316         } => {
    317             assert_eq!(id, EVENT_ID);
    318             assert_eq!(author, None);
    319             assert_eq!(event_kind, None);
    320             assert_eq!(relays, None);
    321         }
    322         _ => panic!("expected event target"),
    323     }
    324 
    325     let address = format!("{}:{AUTHOR}:{D_TAG}", KIND_ARTICLE);
    326     let reaction = reaction_from_tags(
    327         KIND_REACTION,
    328         &[vec!["a".to_string(), address.clone()]],
    329         "-",
    330     )
    331     .unwrap();
    332     assert_eq!(reaction.content, "-");
    333     match reaction.target {
    334         RadrootsSocialTarget::Address {
    335             address: parsed,
    336             author,
    337             event_kind,
    338             relays,
    339         } => {
    340             assert_eq!(parsed, address);
    341             assert_eq!(author.as_deref(), Some(AUTHOR));
    342             assert_eq!(event_kind, Some(KIND_ARTICLE));
    343             assert_eq!(relays, None);
    344         }
    345         _ => panic!("expected address target"),
    346     }
    347 
    348     assert!(matches!(
    349         reaction_from_tags(
    350             KIND_REACTION,
    351             &[
    352                 vec!["e".to_string(), EVENT_ID.to_string()],
    353                 vec!["p".to_string(), " ".to_string()]
    354             ],
    355             "+"
    356         ),
    357         Err(EventParseError::InvalidTag("p"))
    358     ));
    359     assert!(matches!(
    360         reaction_from_tags(
    361             KIND_REACTION,
    362             &[
    363                 vec![
    364                     "a".to_string(),
    365                     format!("{}:{AUTHOR}:{D_TAG}", KIND_ARTICLE)
    366                 ],
    367                 vec!["k".to_string(), KIND_COMMENT.to_string()]
    368             ],
    369             "+"
    370         ),
    371         Err(EventParseError::InvalidTag("k"))
    372     ));
    373     assert!(matches!(
    374         reaction_from_tags(
    375             KIND_REACTION,
    376             &[
    377                 vec!["e".to_string(), EVENT_ID.to_string()],
    378                 vec!["k".to_string(), "not-a-kind".to_string()]
    379             ],
    380             "+"
    381         ),
    382         Err(EventParseError::InvalidNumber("k", _))
    383     ));
    384 }
    385 
    386 #[test]
    387 fn reaction_from_tags_rejects_invalid_kind() {
    388     let tags = reaction_build_tags(&RadrootsReaction {
    389         target: event_target(),
    390         content: "+".to_string(),
    391     })
    392     .unwrap();
    393 
    394     assert!(matches!(
    395         reaction_from_tags(KIND_POST, &tags, "+"),
    396         Err(EventParseError::InvalidKind {
    397             expected: "7",
    398             got: KIND_POST
    399         })
    400     ));
    401 }
    402 
    403 #[test]
    404 fn reaction_metadata_and_index_from_event_roundtrip() {
    405     let parts = to_wire_parts(&RadrootsReaction {
    406         target: event_target(),
    407         content: "".to_string(),
    408     })
    409     .unwrap();
    410 
    411     let metadata = data_from_event(
    412         "id".to_string(),
    413         "author".to_string(),
    414         99,
    415         KIND_REACTION,
    416         parts.content.clone(),
    417         parts.tags.clone(),
    418     )
    419     .unwrap();
    420     assert_eq!(metadata.id, "id");
    421     assert_eq!(metadata.kind, KIND_REACTION);
    422     assert_event_target(&metadata.data.target);
    423     assert_eq!(metadata.data.content, "");
    424 
    425     let index = parsed_from_event(
    426         "id".to_string(),
    427         "author".to_string(),
    428         99,
    429         KIND_REACTION,
    430         parts.content,
    431         parts.tags,
    432         "sig".to_string(),
    433     )
    434     .unwrap();
    435     assert_eq!(index.event.kind, KIND_REACTION);
    436     assert_eq!(index.event.sig, "sig");
    437     assert_event_target(&index.data.data.target);
    438 }