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