lib

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

reaction.rs (12859B)


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