lib

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

repost.rs (14928B)


      1 #![cfg(feature = "json")]
      2 
      3 use radroots_event::{
      4     envelope::kind::{KIND_ARTICLE, KIND_GENERIC_REPOST, KIND_POST, KIND_REACTION, KIND_REPOST},
      5     post::repost::{GenericRepost, Repost},
      6     social::SocialTarget,
      7     tag::name::{TAG_A, TAG_E, TAG_K, TAG_P},
      8 };
      9 use radroots_event_codec::decode::{
     10     EventParseError,
     11     repost::{
     12         generic_repost_data_from_event, generic_repost_from_event,
     13         generic_repost_parsed_from_event, repost_data_from_event, repost_from_event,
     14         repost_parsed_from_event,
     15     },
     16 };
     17 use radroots_event_codec::encode::{
     18     EventEncodeError,
     19     repost::{
     20         generic_repost_build_tags, generic_repost_to_wire_parts,
     21         generic_repost_to_wire_parts_with_kind, repost_build_tags, repost_to_wire_parts,
     22         repost_to_wire_parts_with_kind,
     23     },
     24 };
     25 
     26 const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
     27 const AUTHOR: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df";
     28 const EVENT_SIG: &str = concat!(
     29     "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
     30     "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
     31 );
     32 const ARTICLE_D_TAG: &str = "DDDDDDDDDDDDDDDDDDDDDA";
     33 
     34 fn note_repost() -> Repost {
     35     Repost {
     36         target: SocialTarget::Event {
     37             id: EVENT_ID.to_string(),
     38             author: Some(AUTHOR.to_string()),
     39             event_kind: Some(KIND_POST),
     40             relays: Some(vec!["wss://relay.example.test".to_string()]),
     41         },
     42         content: None,
     43     }
     44 }
     45 
     46 fn generic_article_repost() -> GenericRepost {
     47     GenericRepost {
     48         target: SocialTarget::Address {
     49             address: format!("{KIND_ARTICLE}:{AUTHOR}:{ARTICLE_D_TAG}"),
     50             author: Some(AUTHOR.to_string()),
     51             event_kind: Some(KIND_ARTICLE),
     52             relays: Some(vec!["wss://relay.example.test".to_string()]),
     53         },
     54         target_kind: KIND_ARTICLE,
     55         content: Some("{\"kind\":30023}".to_string()),
     56     }
     57 }
     58 
     59 fn has_tag(tags: &[Vec<String>], key: &str, value: &str) -> bool {
     60     tags.iter().any(|tag| {
     61         tag.first().map(|entry| entry.as_str()) == Some(key)
     62             && tag.get(1).map(|entry| entry.as_str()) == Some(value)
     63     })
     64 }
     65 
     66 fn replace_tag_value(tags: &mut [Vec<String>], key: &str, value: &str) {
     67     let tag = tags
     68         .iter_mut()
     69         .find(|tag| tag.first().map(|entry| entry.as_str()) == Some(key))
     70         .expect("tag");
     71     tag[1] = value.to_string();
     72 }
     73 
     74 #[test]
     75 fn repost_to_wire_parts_roundtrips_kind_one_target() {
     76     let repost = note_repost();
     77     let parts = repost_to_wire_parts(&repost).unwrap();
     78 
     79     assert_eq!(parts.kind, KIND_REPOST);
     80     assert!(parts.content.is_empty());
     81     assert!(has_tag(&parts.tags, TAG_E, EVENT_ID));
     82     assert!(has_tag(&parts.tags, TAG_P, AUTHOR));
     83 
     84     let decoded = repost_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
     85     assert!(matches!(
     86         decoded.target,
     87         SocialTarget::Event {
     88             event_kind: Some(KIND_POST),
     89             ..
     90         }
     91     ));
     92     assert!(decoded.content.is_none());
     93 }
     94 
     95 #[test]
     96 fn generic_repost_to_wire_parts_roundtrips_address_target() {
     97     let repost = generic_article_repost();
     98     let parts = generic_repost_to_wire_parts(&repost).unwrap();
     99 
    100     assert_eq!(parts.kind, KIND_GENERIC_REPOST);
    101     assert_eq!(parts.content, "{\"kind\":30023}");
    102     assert!(has_tag(
    103         &parts.tags,
    104         TAG_A,
    105         format!("{KIND_ARTICLE}:{AUTHOR}:{ARTICLE_D_TAG}").as_str()
    106     ));
    107     assert!(has_tag(
    108         &parts.tags,
    109         TAG_K,
    110         KIND_ARTICLE.to_string().as_str()
    111     ));
    112 
    113     let decoded = generic_repost_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
    114     assert_eq!(decoded.target_kind, KIND_ARTICLE);
    115     assert!(matches!(
    116         decoded.target,
    117         SocialTarget::Address {
    118             event_kind: Some(KIND_ARTICLE),
    119             ..
    120         }
    121     ));
    122     assert_eq!(decoded.content.as_deref(), Some("{\"kind\":30023}"));
    123 }
    124 
    125 #[test]
    126 fn repost_codecs_reject_wrong_kind_and_wrong_target_kind() {
    127     assert!(matches!(
    128         repost_to_wire_parts_with_kind(&note_repost(), KIND_GENERIC_REPOST),
    129         Err(EventEncodeError::InvalidKind(KIND_GENERIC_REPOST))
    130     ));
    131     assert!(matches!(
    132         generic_repost_to_wire_parts_with_kind(&generic_article_repost(), KIND_REPOST),
    133         Err(EventEncodeError::InvalidKind(KIND_REPOST))
    134     ));
    135 
    136     let mut repost = note_repost();
    137     if let SocialTarget::Event { event_kind, .. } = &mut repost.target {
    138         *event_kind = Some(KIND_ARTICLE);
    139     }
    140     assert!(matches!(
    141         repost_build_tags(&repost),
    142         Err(EventEncodeError::InvalidField("target_kind"))
    143     ));
    144 
    145     let mut generic = generic_article_repost();
    146     generic.target_kind = KIND_POST;
    147     assert!(matches!(
    148         generic_repost_build_tags(&generic),
    149         Err(EventEncodeError::InvalidField("target_kind"))
    150     ));
    151 
    152     let err = repost_from_event(KIND_GENERIC_REPOST, &[], "").unwrap_err();
    153     assert!(matches!(
    154         err,
    155         EventParseError::InvalidKind {
    156             expected: "6",
    157             got: KIND_GENERIC_REPOST
    158         }
    159     ));
    160 
    161     let err = generic_repost_from_event(KIND_GENERIC_REPOST, &[], "").unwrap_err();
    162     assert!(matches!(err, EventParseError::MissingTag(TAG_K)));
    163 }
    164 
    165 #[test]
    166 fn repost_event_target_codecs_cover_optional_and_error_edges() {
    167     let mut no_relay = note_repost();
    168     no_relay.content = Some("fresh note".to_string());
    169     if let SocialTarget::Event { author, relays, .. } = &mut no_relay.target {
    170         *author = None;
    171         *relays = None;
    172     }
    173     let parts = repost_to_wire_parts(&no_relay).unwrap();
    174     assert_eq!(parts.content, "fresh note");
    175     assert!(!parts.tags.iter().any(|tag| {
    176         tag.first().map(|entry| entry.as_str()) == Some(TAG_P)
    177             || tag
    178                 .get(2)
    179                 .map(|entry| !entry.trim().is_empty())
    180                 .unwrap_or(false)
    181     }));
    182     let decoded = repost_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
    183     assert_eq!(decoded.content.as_deref(), Some("fresh note"));
    184     assert!(matches!(
    185         decoded.target,
    186         SocialTarget::Event { relays: None, .. }
    187     ));
    188 
    189     let mut invalid_target = note_repost();
    190     invalid_target.target = SocialTarget::Address {
    191         address: format!("{KIND_ARTICLE}:{AUTHOR}:{ARTICLE_D_TAG}"),
    192         author: Some(AUTHOR.to_string()),
    193         event_kind: Some(KIND_ARTICLE),
    194         relays: None,
    195     };
    196     assert!(matches!(
    197         repost_build_tags(&invalid_target),
    198         Err(EventEncodeError::InvalidField("target"))
    199     ));
    200 
    201     let mut invalid_id = note_repost();
    202     if let SocialTarget::Event { id, .. } = &mut invalid_id.target {
    203         *id = "not-a-lowercase-hex-id".to_string();
    204     }
    205     assert!(matches!(
    206         repost_build_tags(&invalid_id),
    207         Err(EventEncodeError::InvalidField("target.id"))
    208     ));
    209 
    210     let mut invalid_author = note_repost();
    211     if let SocialTarget::Event { author, .. } = &mut invalid_author.target {
    212         *author = Some(" ".to_string());
    213     }
    214     assert!(matches!(
    215         repost_build_tags(&invalid_author),
    216         Err(EventEncodeError::EmptyRequiredField("target.author"))
    217     ));
    218 
    219     let mut tags = repost_build_tags(&note_repost()).unwrap();
    220     let event_tag = tags
    221         .iter_mut()
    222         .find(|tag| tag.first().map(String::as_str) == Some(TAG_E))
    223         .expect("event tag");
    224     event_tag.truncate(1);
    225     assert!(matches!(
    226         repost_from_event(KIND_REPOST, &tags, ""),
    227         Err(EventParseError::InvalidTag(TAG_E))
    228     ));
    229 
    230     let mut tags = repost_build_tags(&note_repost()).unwrap();
    231     replace_tag_value(&mut tags, TAG_E, "not-a-lowercase-hex-id");
    232     assert!(matches!(
    233         repost_from_event(KIND_REPOST, &tags, ""),
    234         Err(EventParseError::InvalidTag(TAG_E))
    235     ));
    236 }
    237 
    238 #[test]
    239 fn generic_repost_codecs_cover_event_targets_and_error_edges() {
    240     let generic = GenericRepost {
    241         target: SocialTarget::Event {
    242             id: EVENT_ID.to_string(),
    243             author: Some(AUTHOR.to_string()),
    244             event_kind: Some(KIND_REACTION),
    245             relays: Some(vec![
    246                 " ".to_string(),
    247                 "wss://relay.example.test".to_string(),
    248             ]),
    249         },
    250         target_kind: KIND_REACTION,
    251         content: None,
    252     };
    253     let parts = generic_repost_to_wire_parts(&generic).unwrap();
    254     assert!(has_tag(&parts.tags, TAG_E, EVENT_ID));
    255     assert!(has_tag(
    256         &parts.tags,
    257         TAG_K,
    258         KIND_REACTION.to_string().as_str()
    259     ));
    260     let decoded = generic_repost_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
    261     assert!(decoded.content.is_none());
    262     assert!(matches!(
    263         decoded.target,
    264         SocialTarget::Event {
    265             event_kind: Some(KIND_REACTION),
    266             ..
    267         }
    268     ));
    269 
    270     let no_author_event = GenericRepost {
    271         target: SocialTarget::Event {
    272             id: EVENT_ID.to_string(),
    273             author: None,
    274             event_kind: Some(KIND_REACTION),
    275             relays: None,
    276         },
    277         target_kind: KIND_REACTION,
    278         content: None,
    279     };
    280     let parts = generic_repost_to_wire_parts(&no_author_event).unwrap();
    281     assert!(has_tag(&parts.tags, TAG_E, EVENT_ID));
    282     assert!(!parts.tags.iter().any(|tag| {
    283         tag.first().map(String::as_str) == Some(TAG_P)
    284             || tag.get(2).map(|value| !value.is_empty()).unwrap_or(false)
    285     }));
    286 
    287     let mut generic = generic_article_repost();
    288     if let SocialTarget::Address { author, relays, .. } = &mut generic.target {
    289         *author = None;
    290         *relays = None;
    291     }
    292     let parts = generic_repost_to_wire_parts(&generic).unwrap();
    293     let address = parts
    294         .tags
    295         .iter()
    296         .find(|tag| tag.first().map(String::as_str) == Some(TAG_A))
    297         .expect("address tag");
    298     assert_eq!(address.len(), 2);
    299 
    300     let wrong_kind = generic_repost_from_event(KIND_REPOST, &parts.tags, "").unwrap_err();
    301     assert!(matches!(
    302         wrong_kind,
    303         EventParseError::InvalidKind {
    304             expected: "16",
    305             got: KIND_REPOST
    306         }
    307     ));
    308 
    309     let mut tags = parts.tags.clone();
    310     replace_tag_value(&mut tags, TAG_K, KIND_POST.to_string().as_str());
    311     assert!(matches!(
    312         generic_repost_from_event(KIND_GENERIC_REPOST, &tags, ""),
    313         Err(EventParseError::InvalidTag(TAG_K))
    314     ));
    315 
    316     let mut tags = parts.tags.clone();
    317     replace_tag_value(&mut tags, TAG_K, "not-a-number");
    318     assert!(matches!(
    319         generic_repost_from_event(KIND_GENERIC_REPOST, &tags, ""),
    320         Err(EventParseError::InvalidNumber(TAG_K, _))
    321     ));
    322 
    323     let tags = vec![vec![TAG_K.to_string(), KIND_REACTION.to_string()]];
    324     assert!(matches!(
    325         generic_repost_from_event(KIND_GENERIC_REPOST, &tags, ""),
    326         Err(EventParseError::MissingTag(TAG_E))
    327     ));
    328 
    329     let mut tags = generic_repost_build_tags(&generic_article_repost()).unwrap();
    330     replace_tag_value(
    331         &mut tags,
    332         TAG_A,
    333         format!("{KIND_REACTION}:{AUTHOR}:{ARTICLE_D_TAG}").as_str(),
    334     );
    335     assert!(matches!(
    336         generic_repost_from_event(KIND_GENERIC_REPOST, &tags, ""),
    337         Err(EventParseError::InvalidTag(TAG_A))
    338     ));
    339 
    340     let mut tags = generic_repost_build_tags(&no_author_event).unwrap();
    341     let event_tag = tags
    342         .iter_mut()
    343         .find(|tag| tag.first().map(String::as_str) == Some(TAG_E))
    344         .expect("event tag");
    345     event_tag.truncate(1);
    346     assert!(matches!(
    347         generic_repost_from_event(KIND_GENERIC_REPOST, &tags, ""),
    348         Err(EventParseError::InvalidTag(TAG_E))
    349     ));
    350 
    351     let mut tags = generic_repost_build_tags(&no_author_event).unwrap();
    352     replace_tag_value(&mut tags, TAG_E, "not-a-lowercase-hex-id");
    353     assert!(matches!(
    354         generic_repost_from_event(KIND_GENERIC_REPOST, &tags, ""),
    355         Err(EventParseError::InvalidTag(TAG_E))
    356     ));
    357 
    358     let mut generic = generic_article_repost();
    359     generic.target_kind = KIND_REACTION;
    360     assert!(matches!(
    361         generic_repost_build_tags(&generic),
    362         Err(EventEncodeError::InvalidField("target_kind"))
    363     ));
    364 
    365     let mut generic = generic_article_repost();
    366     if let SocialTarget::Address { author, relays, .. } = &mut generic.target {
    367         *author = Some(" ".to_string());
    368         *relays = None;
    369     }
    370     assert!(matches!(
    371         generic_repost_build_tags(&generic),
    372         Err(EventEncodeError::EmptyRequiredField("target.author"))
    373     ));
    374 
    375     let mut generic = generic_article_repost();
    376     generic.target = SocialTarget::External {
    377         id: "https://example.test/repost-target".to_string(),
    378         external_kind: "web".to_string(),
    379         hint: None,
    380     };
    381     assert!(matches!(
    382         generic_repost_build_tags(&generic),
    383         Err(EventEncodeError::InvalidField("target"))
    384     ));
    385 
    386     let mut generic = GenericRepost {
    387         target: SocialTarget::Event {
    388             id: EVENT_ID.to_string(),
    389             author: None,
    390             event_kind: None,
    391             relays: None,
    392         },
    393         target_kind: KIND_REACTION,
    394         content: None,
    395     };
    396     assert!(matches!(
    397         generic_repost_build_tags(&generic),
    398         Err(EventEncodeError::InvalidField("target_kind"))
    399     ));
    400 
    401     if let SocialTarget::Event { event_kind, .. } = &mut generic.target {
    402         *event_kind = Some(KIND_REACTION);
    403     }
    404     generic.target_kind = KIND_POST;
    405     assert!(matches!(
    406         generic_repost_build_tags(&generic),
    407         Err(EventEncodeError::InvalidField("target_kind"))
    408     ));
    409 }
    410 
    411 #[test]
    412 fn repost_wrappers_preserve_event_metadata() {
    413     let parts = repost_to_wire_parts(&note_repost()).unwrap();
    414     let data = repost_data_from_event(
    415         "repost_id".to_string(),
    416         "author".to_string(),
    417         10,
    418         parts.kind,
    419         parts.content.clone(),
    420         parts.tags.clone(),
    421     )
    422     .unwrap();
    423     assert_eq!(data.kind, KIND_REPOST);
    424     assert_eq!(data.published_at, 10);
    425 
    426     let parsed = repost_parsed_from_event(
    427         EVENT_ID.to_string(),
    428         AUTHOR.to_string(),
    429         10,
    430         parts.kind,
    431         parts.content,
    432         parts.tags,
    433         EVENT_SIG.to_string(),
    434     )
    435     .unwrap();
    436     assert_eq!(parsed.event.signature_hex(), EVENT_SIG);
    437 
    438     let generic_parts = generic_repost_to_wire_parts(&generic_article_repost()).unwrap();
    439     let generic_data = generic_repost_data_from_event(
    440         "generic_id".to_string(),
    441         "author".to_string(),
    442         11,
    443         generic_parts.kind,
    444         generic_parts.content.clone(),
    445         generic_parts.tags.clone(),
    446     )
    447     .unwrap();
    448     assert_eq!(generic_data.data.target_kind, KIND_ARTICLE);
    449 
    450     let generic_parsed = generic_repost_parsed_from_event(
    451         EVENT_ID.to_string(),
    452         AUTHOR.to_string(),
    453         11,
    454         generic_parts.kind,
    455         generic_parts.content,
    456         generic_parts.tags,
    457         EVENT_SIG.to_string(),
    458     )
    459     .unwrap();
    460     assert_eq!(generic_parsed.event.created_at_u64(), 11);
    461 }