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