lib

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

comment.rs (18187B)


      1 use radroots_events::{
      2     comment::RadrootsComment,
      3     kinds::{KIND_ARTICLE, KIND_COMMENT, KIND_POST},
      4     social::RadrootsSocialTarget,
      5     tags::{TAG_E_PREV, TAG_E_ROOT},
      6 };
      7 use radroots_events_codec::comment::decode::{
      8     comment_from_tags, data_from_event, parsed_from_event,
      9 };
     10 use radroots_events_codec::comment::encode::{
     11     comment_build_tags, to_wire_parts, to_wire_parts_with_kind,
     12 };
     13 use radroots_events_codec::error::{EventEncodeError, EventParseError};
     14 
     15 const ROOT_ID: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
     16 const PARENT_ID: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
     17 const AUTHOR: &str = "author_pubkey";
     18 const PARENT_AUTHOR: &str = "parent_pubkey";
     19 const D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA";
     20 
     21 fn event_target(id: &str, author: &str, kind: u32) -> RadrootsSocialTarget {
     22     RadrootsSocialTarget::Event {
     23         id: id.to_string(),
     24         author: Some(author.to_string()),
     25         event_kind: Some(kind),
     26         relays: Some(vec!["wss://relay.example.test".to_string()]),
     27     }
     28 }
     29 
     30 fn address_target(author: &str, kind: u32, d_tag: &str) -> RadrootsSocialTarget {
     31     RadrootsSocialTarget::Address {
     32         address: format!("{kind}:{author}:{d_tag}"),
     33         author: Some(author.to_string()),
     34         event_kind: Some(kind),
     35         relays: Some(vec!["wss://relay2.example.test".to_string()]),
     36     }
     37 }
     38 
     39 fn external_target(id: &str, kind: &str) -> RadrootsSocialTarget {
     40     RadrootsSocialTarget::External {
     41         id: id.to_string(),
     42         external_kind: kind.to_string(),
     43         hint: Some("https://example.test/object".to_string()),
     44     }
     45 }
     46 
     47 fn event_comment_tags() -> Vec<Vec<String>> {
     48     vec![
     49         vec!["E".to_string(), ROOT_ID.to_string()],
     50         vec!["P".to_string(), AUTHOR.to_string()],
     51         vec!["K".to_string(), KIND_ARTICLE.to_string()],
     52         vec!["e".to_string(), PARENT_ID.to_string()],
     53         vec!["p".to_string(), PARENT_AUTHOR.to_string()],
     54         vec!["k".to_string(), KIND_ARTICLE.to_string()],
     55     ]
     56 }
     57 
     58 fn assert_event_target(target: &RadrootsSocialTarget, id: &str, author: &str, kind: u32) {
     59     match target {
     60         RadrootsSocialTarget::Event {
     61             id: actual_id,
     62             author: actual_author,
     63             event_kind,
     64             relays,
     65         } => {
     66             assert_eq!(actual_id, id);
     67             assert_eq!(actual_author.as_deref(), Some(author));
     68             assert_eq!(*event_kind, Some(kind));
     69             assert_eq!(relays.as_ref().map(Vec::len), Some(1));
     70         }
     71         _ => panic!("expected event target"),
     72     }
     73 }
     74 
     75 fn assert_address_target(target: &RadrootsSocialTarget, author: &str, kind: u32, d_tag: &str) {
     76     match target {
     77         RadrootsSocialTarget::Address {
     78             address,
     79             author: actual_author,
     80             event_kind,
     81             relays,
     82         } => {
     83             assert_eq!(address, &format!("{kind}:{author}:{d_tag}"));
     84             assert_eq!(actual_author.as_deref(), Some(author));
     85             assert_eq!(*event_kind, Some(kind));
     86             assert_eq!(relays.as_ref().map(Vec::len), Some(1));
     87         }
     88         _ => panic!("expected address target"),
     89     }
     90 }
     91 
     92 #[test]
     93 fn comment_build_tags_requires_strict_nip22_target_fields() {
     94     let comment = RadrootsComment {
     95         root: RadrootsSocialTarget::Event {
     96             id: "not-hex".to_string(),
     97             author: Some(AUTHOR.to_string()),
     98             event_kind: Some(KIND_ARTICLE),
     99             relays: None,
    100         },
    101         parent: event_target(PARENT_ID, PARENT_AUTHOR, KIND_ARTICLE),
    102         content: "hello".to_string(),
    103     };
    104     assert!(matches!(
    105         comment_build_tags(&comment),
    106         Err(EventEncodeError::InvalidField("root"))
    107     ));
    108 
    109     let comment = RadrootsComment {
    110         root: RadrootsSocialTarget::Event {
    111             id: ROOT_ID.to_string(),
    112             author: None,
    113             event_kind: Some(KIND_ARTICLE),
    114             relays: None,
    115         },
    116         parent: event_target(PARENT_ID, PARENT_AUTHOR, KIND_ARTICLE),
    117         content: "hello".to_string(),
    118     };
    119     assert!(matches!(
    120         comment_build_tags(&comment),
    121         Err(EventEncodeError::EmptyRequiredField("root"))
    122     ));
    123 
    124     let comment = RadrootsComment {
    125         root: RadrootsSocialTarget::Event {
    126             id: ROOT_ID.to_string(),
    127             author: Some(AUTHOR.to_string()),
    128             event_kind: None,
    129             relays: None,
    130         },
    131         parent: event_target(PARENT_ID, PARENT_AUTHOR, KIND_ARTICLE),
    132         content: "hello".to_string(),
    133     };
    134     assert!(matches!(
    135         comment_build_tags(&comment),
    136         Err(EventEncodeError::EmptyRequiredField("root"))
    137     ));
    138 
    139     let comment = RadrootsComment {
    140         root: RadrootsSocialTarget::Address {
    141             address: "not-an-address".to_string(),
    142             author: Some(AUTHOR.to_string()),
    143             event_kind: Some(KIND_ARTICLE),
    144             relays: None,
    145         },
    146         parent: event_target(PARENT_ID, PARENT_AUTHOR, KIND_ARTICLE),
    147         content: "hello".to_string(),
    148     };
    149     assert!(matches!(
    150         comment_build_tags(&comment),
    151         Err(EventEncodeError::InvalidField("root"))
    152     ));
    153 
    154     let comment = RadrootsComment {
    155         root: RadrootsSocialTarget::Address {
    156             address: format!("{KIND_ARTICLE}:{AUTHOR}:{D_TAG}"),
    157             author: Some(AUTHOR.to_string()),
    158             event_kind: Some(KIND_COMMENT),
    159             relays: None,
    160         },
    161         parent: event_target(PARENT_ID, PARENT_AUTHOR, KIND_ARTICLE),
    162         content: "hello".to_string(),
    163     };
    164     assert!(matches!(
    165         comment_build_tags(&comment),
    166         Err(EventEncodeError::InvalidField("root"))
    167     ));
    168 
    169     let comment = RadrootsComment {
    170         root: RadrootsSocialTarget::Address {
    171             address: format!("{KIND_ARTICLE}:{AUTHOR}:{D_TAG}"),
    172             author: Some("other_author".to_string()),
    173             event_kind: Some(KIND_ARTICLE),
    174             relays: None,
    175         },
    176         parent: event_target(PARENT_ID, PARENT_AUTHOR, KIND_ARTICLE),
    177         content: "hello".to_string(),
    178     };
    179     assert!(matches!(
    180         comment_build_tags(&comment),
    181         Err(EventEncodeError::InvalidField("root"))
    182     ));
    183 
    184     let comment = RadrootsComment {
    185         root: RadrootsSocialTarget::External {
    186             id: "external-root".to_string(),
    187             external_kind: "1".to_string(),
    188             hint: None,
    189         },
    190         parent: event_target(PARENT_ID, PARENT_AUTHOR, KIND_ARTICLE),
    191         content: "hello".to_string(),
    192     };
    193     assert!(matches!(
    194         comment_build_tags(&comment),
    195         Err(EventEncodeError::InvalidField("root"))
    196     ));
    197 }
    198 
    199 #[test]
    200 fn comment_to_wire_parts_requires_content() {
    201     let comment = RadrootsComment {
    202         root: event_target(ROOT_ID, AUTHOR, KIND_ARTICLE),
    203         parent: event_target(PARENT_ID, PARENT_AUTHOR, KIND_ARTICLE),
    204         content: "   ".to_string(),
    205     };
    206 
    207     let err = to_wire_parts(&comment).unwrap_err();
    208     assert!(matches!(
    209         err,
    210         EventEncodeError::EmptyRequiredField("content")
    211     ));
    212 }
    213 
    214 #[test]
    215 fn comment_roundtrips_event_and_address_targets() {
    216     let comment = RadrootsComment {
    217         root: event_target(ROOT_ID, AUTHOR, KIND_ARTICLE),
    218         parent: address_target(PARENT_AUTHOR, KIND_ARTICLE, D_TAG),
    219         content: "hello".to_string(),
    220     };
    221     let parts = to_wire_parts(&comment).unwrap();
    222 
    223     assert_eq!(parts.kind, KIND_COMMENT);
    224     assert!(parts.tags.iter().any(|tag| tag[0] == "E"));
    225     assert!(parts.tags.iter().any(|tag| tag[0] == "P"));
    226     assert!(parts.tags.iter().any(|tag| tag[0] == "K"));
    227     assert!(parts.tags.iter().any(|tag| tag[0] == "a"));
    228     assert!(parts.tags.iter().any(|tag| tag[0] == "p"));
    229     assert!(parts.tags.iter().any(|tag| tag[0] == "k"));
    230 
    231     let parsed = comment_from_tags(parts.kind, &parts.tags, &parts.content).unwrap();
    232     assert_event_target(&parsed.root, ROOT_ID, AUTHOR, KIND_ARTICLE);
    233     assert_address_target(&parsed.parent, PARENT_AUTHOR, KIND_ARTICLE, D_TAG);
    234     assert_eq!(parsed.content, "hello");
    235 
    236     assert!(matches!(
    237         to_wire_parts_with_kind(&comment, KIND_POST),
    238         Err(EventEncodeError::InvalidKind(KIND_POST))
    239     ));
    240 }
    241 
    242 #[test]
    243 fn comment_rejects_short_text_note_targets() {
    244     let root_kind_one = RadrootsComment {
    245         root: event_target(ROOT_ID, AUTHOR, KIND_POST),
    246         parent: event_target(PARENT_ID, PARENT_AUTHOR, KIND_ARTICLE),
    247         content: "note reply".to_string(),
    248     };
    249     assert!(matches!(
    250         comment_build_tags(&root_kind_one),
    251         Err(EventEncodeError::InvalidField("root"))
    252     ));
    253 
    254     let parent_kind_one = RadrootsComment {
    255         root: event_target(ROOT_ID, AUTHOR, KIND_ARTICLE),
    256         parent: event_target(PARENT_ID, PARENT_AUTHOR, KIND_POST),
    257         content: "note reply".to_string(),
    258     };
    259     assert!(matches!(
    260         comment_build_tags(&parent_kind_one),
    261         Err(EventEncodeError::InvalidField("parent"))
    262     ));
    263 
    264     let root_kind_one_tags = vec![
    265         vec!["E".to_string(), ROOT_ID.to_string()],
    266         vec!["P".to_string(), AUTHOR.to_string()],
    267         vec!["K".to_string(), KIND_POST.to_string()],
    268         vec!["e".to_string(), PARENT_ID.to_string()],
    269         vec!["p".to_string(), PARENT_AUTHOR.to_string()],
    270         vec!["k".to_string(), KIND_ARTICLE.to_string()],
    271     ];
    272     assert!(matches!(
    273         comment_from_tags(KIND_COMMENT, &root_kind_one_tags, "note reply"),
    274         Err(EventParseError::InvalidTag("K"))
    275     ));
    276 
    277     let parent_kind_one_tags = vec![
    278         vec!["E".to_string(), ROOT_ID.to_string()],
    279         vec!["P".to_string(), AUTHOR.to_string()],
    280         vec!["K".to_string(), KIND_ARTICLE.to_string()],
    281         vec!["e".to_string(), PARENT_ID.to_string()],
    282         vec!["p".to_string(), PARENT_AUTHOR.to_string()],
    283         vec!["k".to_string(), KIND_POST.to_string()],
    284     ];
    285     assert!(matches!(
    286         comment_from_tags(KIND_COMMENT, &parent_kind_one_tags, "note reply"),
    287         Err(EventParseError::InvalidTag("k"))
    288     ));
    289 }
    290 
    291 #[test]
    292 fn comment_roundtrips_external_targets() {
    293     let comment = RadrootsComment {
    294         root: external_target("https://example.test/root", "web"),
    295         parent: external_target("https://example.test/parent", "web"),
    296         content: "external comment".to_string(),
    297     };
    298     let parts = to_wire_parts(&comment).unwrap();
    299     let parsed = comment_from_tags(parts.kind, &parts.tags, &parts.content).unwrap();
    300 
    301     match parsed.root {
    302         RadrootsSocialTarget::External {
    303             id,
    304             external_kind,
    305             hint,
    306         } => {
    307             assert_eq!(id, "https://example.test/root");
    308             assert_eq!(external_kind, "web");
    309             assert_eq!(hint.as_deref(), Some("https://example.test/object"));
    310         }
    311         _ => panic!("expected external root"),
    312     }
    313     match parsed.parent {
    314         RadrootsSocialTarget::External { id, .. } => {
    315             assert_eq!(id, "https://example.test/parent");
    316         }
    317         _ => panic!("expected external parent"),
    318     }
    319 }
    320 
    321 #[test]
    322 fn comment_build_tags_covers_optional_target_branches() {
    323     let comment = RadrootsComment {
    324         root: RadrootsSocialTarget::Address {
    325             address: format!("{KIND_ARTICLE}:{AUTHOR}:{D_TAG}"),
    326             author: None,
    327             event_kind: None,
    328             relays: Some(vec!["wss://root-relay.example.test".to_string()]),
    329         },
    330         parent: RadrootsSocialTarget::External {
    331             id: "https://example.test/parent".to_string(),
    332             external_kind: "web".to_string(),
    333             hint: None,
    334         },
    335         content: "hello".to_string(),
    336     };
    337     let tags = comment_build_tags(&comment).unwrap();
    338     assert!(tags.iter().any(|tag| {
    339         tag.first().map(|value| value.as_str()) == Some("A")
    340             && tag
    341                 .iter()
    342                 .any(|value| value == "wss://root-relay.example.test")
    343     }));
    344     assert!(
    345         tags.iter()
    346             .any(|tag| { tag.first().map(|value| value.as_str()) == Some("i") && tag.len() == 2 })
    347     );
    348 }
    349 
    350 #[test]
    351 fn comment_from_tags_covers_target_decode_edges() {
    352     let mut tags = event_comment_tags();
    353     tags.push(vec![
    354         "A".to_string(),
    355         format!("{KIND_ARTICLE}:{AUTHOR}:{D_TAG}"),
    356     ]);
    357     assert!(matches!(
    358         comment_from_tags(KIND_COMMENT, &tags, "hello"),
    359         Err(EventParseError::InvalidTag("E"))
    360     ));
    361 
    362     let mut tags = event_comment_tags();
    363     tags[0] = vec!["X".to_string(), ROOT_ID.to_string()];
    364     assert!(matches!(
    365         comment_from_tags(KIND_COMMENT, &tags, "hello"),
    366         Err(EventParseError::MissingTag("E"))
    367     ));
    368 
    369     let mut tags = event_comment_tags();
    370     tags[0] = vec!["E".to_string()];
    371     assert!(matches!(
    372         comment_from_tags(KIND_COMMENT, &tags, "hello"),
    373         Err(EventParseError::InvalidTag("E"))
    374     ));
    375 
    376     let mut tags = event_comment_tags();
    377     tags[1] = vec!["P".to_string(), " ".to_string()];
    378     assert!(matches!(
    379         comment_from_tags(KIND_COMMENT, &tags, "hello"),
    380         Err(EventParseError::InvalidTag("P"))
    381     ));
    382 
    383     let mut tags = event_comment_tags();
    384     tags[2] = vec!["K".to_string(), " ".to_string()];
    385     assert!(matches!(
    386         comment_from_tags(KIND_COMMENT, &tags, "hello"),
    387         Err(EventParseError::InvalidTag("K"))
    388     ));
    389 
    390     let mut tags = event_comment_tags();
    391     tags[0] = vec!["A".to_string(), format!("{KIND_ARTICLE}:{AUTHOR}:{D_TAG}")];
    392     let parsed = comment_from_tags(KIND_COMMENT, &tags, "hello").unwrap();
    393     match parsed.root {
    394         RadrootsSocialTarget::Address { relays, .. } => {
    395             assert_eq!(relays, None);
    396         }
    397         _ => panic!("expected address target"),
    398     }
    399 
    400     let mut tags = event_comment_tags();
    401     tags[0] = vec!["A".to_string(), format!("{KIND_ARTICLE}:{AUTHOR}:{D_TAG}")];
    402     tags[2] = vec!["K".to_string(), KIND_COMMENT.to_string()];
    403     assert!(matches!(
    404         comment_from_tags(KIND_COMMENT, &tags, "hello"),
    405         Err(EventParseError::InvalidTag("K"))
    406     ));
    407 
    408     let mut tags = event_comment_tags();
    409     tags[0] = vec!["A".to_string(), format!("{KIND_ARTICLE}:{AUTHOR}:{D_TAG}")];
    410     tags[1] = vec!["P".to_string(), "other_pubkey".to_string()];
    411     assert!(matches!(
    412         comment_from_tags(KIND_COMMENT, &tags, "hello"),
    413         Err(EventParseError::InvalidTag("P"))
    414     ));
    415 
    416     let mut tags = event_comment_tags();
    417     tags[0] = vec!["I".to_string(), " ".to_string()];
    418     tags[2] = vec!["K".to_string(), "web".to_string()];
    419     assert!(matches!(
    420         comment_from_tags(KIND_COMMENT, &tags, "hello"),
    421         Err(EventParseError::InvalidTag("I"))
    422     ));
    423 
    424     let mut tags = event_comment_tags();
    425     tags[0] = vec!["I".to_string(), "https://example.test/root".to_string()];
    426     tags[2] = vec!["K".to_string(), "1".to_string()];
    427     assert!(matches!(
    428         comment_from_tags(KIND_COMMENT, &tags, "hello"),
    429         Err(EventParseError::InvalidTag("K"))
    430     ));
    431 }
    432 
    433 #[test]
    434 fn comment_from_tags_rejects_legacy_and_missing_shapes() {
    435     let legacy_tags = vec![vec![
    436         TAG_E_ROOT.to_string(),
    437         ROOT_ID.to_string(),
    438         AUTHOR.to_string(),
    439         KIND_ARTICLE.to_string(),
    440     ]];
    441     assert!(matches!(
    442         comment_from_tags(KIND_COMMENT, &legacy_tags, "hello"),
    443         Err(EventParseError::InvalidTag(TAG_E_ROOT))
    444     ));
    445 
    446     let legacy_parent_tags = vec![
    447         vec!["E".to_string(), ROOT_ID.to_string()],
    448         vec!["P".to_string(), AUTHOR.to_string()],
    449         vec!["K".to_string(), KIND_ARTICLE.to_string()],
    450         vec![
    451             TAG_E_PREV.to_string(),
    452             PARENT_ID.to_string(),
    453             PARENT_AUTHOR.to_string(),
    454             KIND_ARTICLE.to_string(),
    455         ],
    456     ];
    457     assert!(matches!(
    458         comment_from_tags(KIND_COMMENT, &legacy_parent_tags, "hello"),
    459         Err(EventParseError::InvalidTag(TAG_E_PREV))
    460     ));
    461 
    462     let missing_parent_tags = vec![
    463         vec!["E".to_string(), ROOT_ID.to_string()],
    464         vec!["P".to_string(), AUTHOR.to_string()],
    465         vec!["K".to_string(), KIND_ARTICLE.to_string()],
    466     ];
    467     assert!(matches!(
    468         comment_from_tags(KIND_COMMENT, &missing_parent_tags, "hello"),
    469         Err(EventParseError::MissingTag("e"))
    470     ));
    471 }
    472 
    473 #[test]
    474 fn comment_from_tags_rejects_empty_content_and_wrong_kind() {
    475     let tags = comment_build_tags(&RadrootsComment {
    476         root: event_target(ROOT_ID, AUTHOR, KIND_ARTICLE),
    477         parent: event_target(PARENT_ID, PARENT_AUTHOR, KIND_ARTICLE),
    478         content: "hello".to_string(),
    479     })
    480     .unwrap();
    481 
    482     assert!(matches!(
    483         comment_from_tags(KIND_COMMENT, &tags, "   "),
    484         Err(EventParseError::InvalidTag("content"))
    485     ));
    486     assert!(matches!(
    487         comment_from_tags(KIND_POST, &tags, "hello"),
    488         Err(EventParseError::InvalidKind {
    489             expected: "1111",
    490             got: KIND_POST
    491         })
    492     ));
    493 }
    494 
    495 #[test]
    496 fn comment_metadata_and_index_from_event_roundtrip() {
    497     let parts = to_wire_parts(&RadrootsComment {
    498         root: event_target(ROOT_ID, AUTHOR, KIND_ARTICLE),
    499         parent: address_target(PARENT_AUTHOR, KIND_ARTICLE, D_TAG),
    500         content: "hello".to_string(),
    501     })
    502     .unwrap();
    503 
    504     let metadata = data_from_event(
    505         "id".to_string(),
    506         "author".to_string(),
    507         77,
    508         KIND_COMMENT,
    509         parts.content.clone(),
    510         parts.tags.clone(),
    511     )
    512     .unwrap();
    513     assert_eq!(metadata.id, "id");
    514     assert_eq!(metadata.published_at, 77);
    515     assert_event_target(&metadata.data.root, ROOT_ID, AUTHOR, KIND_ARTICLE);
    516 
    517     let err = parsed_from_event(
    518         "id".to_string(),
    519         "author".to_string(),
    520         77,
    521         KIND_POST,
    522         parts.content.clone(),
    523         parts.tags.clone(),
    524         "sig".to_string(),
    525     )
    526     .unwrap_err();
    527     assert!(matches!(
    528         err,
    529         EventParseError::InvalidKind {
    530             expected: "1111",
    531             got: KIND_POST
    532         }
    533     ));
    534 
    535     let index = parsed_from_event(
    536         "id".to_string(),
    537         "author".to_string(),
    538         77,
    539         KIND_COMMENT,
    540         parts.content,
    541         parts.tags,
    542         "sig".to_string(),
    543     )
    544     .unwrap();
    545     assert_eq!(index.event.created_at, 77);
    546     assert_eq!(index.event.sig, "sig");
    547     assert_address_target(&index.data.data.parent, PARENT_AUTHOR, KIND_ARTICLE, D_TAG);
    548 }