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 }