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