tests.rs (12943B)
1 use super::*; 2 3 fn h(character: char) -> String { 4 crate::test_fixtures::fixture_public_key_hex(character) 5 } 6 7 #[test] 8 fn tolerant_inbound_accepts_blank_content_and_absent_participants() { 9 let projection = project_nip10_reply_parts( 10 KIND_POST, 11 &[vec![ 12 "e".to_string(), 13 h('a'), 14 String::new(), 15 "root".to_string(), 16 ]], 17 "", 18 10, 19 ) 20 .expect("optional participants and blank content do not erase a Reply"); 21 22 assert!(projection.is_direct()); 23 assert!(projection.participants().is_empty()); 24 assert_eq!( 25 projection 26 .diagnostics() 27 .iter() 28 .map(RadrootsNip10ReplyDiagnostic::code) 29 .collect::<Vec<_>>(), 30 vec!["reply_author_missing_ignored"] 31 ); 32 assert_eq!(projection.diagnostics()[0].tag_index(), None); 33 assert_eq!(projection.diagnostics()[0].raw_tag(), None); 34 } 35 36 #[test] 37 fn tolerant_inbound_retains_raw_optional_metadata_and_orders_diagnostics() { 38 let root_id = h('a'); 39 let parent_id = h('d'); 40 let participant = h('b'); 41 let parent_author_hint = h('c'); 42 let tags = vec![ 43 vec![ 44 "e".to_string(), 45 root_id.clone(), 46 "https://relay.example".to_string(), 47 "root".to_string(), 48 "not-a-pubkey".to_string(), 49 ], 50 vec![ 51 "e".to_string(), 52 parent_id.clone(), 53 String::new(), 54 "reply".to_string(), 55 parent_author_hint, 56 ], 57 vec!["p".to_string()], 58 vec!["p".to_string(), "not-a-pubkey".to_string()], 59 vec![ 60 "p".to_string(), 61 participant.clone(), 62 "https://relay.example".to_string(), 63 ], 64 vec!["p".to_string(), participant.clone()], 65 ]; 66 67 let projection = project_nip10_reply_parts(KIND_POST, &tags, "Reply", 10) 68 .expect("malformed optional metadata must not erase a Reply"); 69 70 assert!(!projection.is_direct()); 71 assert_eq!(projection.root().tag_index(), 0); 72 assert_eq!(projection.root().raw_tag(), tags[0]); 73 assert_eq!(projection.root().event_id().to_hex(), root_id); 74 assert!(projection.root().relay().is_none()); 75 assert!(projection.root().author_hint().is_none()); 76 let parent = projection.reply_reference().expect("reply reference"); 77 assert_eq!(parent.tag_index(), 1); 78 assert_eq!(parent.raw_tag(), tags[1]); 79 assert_eq!(parent.event_id().to_hex(), parent_id); 80 assert_eq!(projection.participants().len(), 1); 81 assert_eq!(projection.participants()[0].tag_index(), 4); 82 assert_eq!(projection.participants()[0].raw_tag(), tags[4]); 83 assert_eq!(projection.participants()[0].pubkey().to_hex(), participant); 84 assert!(projection.participants()[0].relay().is_none()); 85 86 assert_eq!( 87 projection 88 .diagnostics() 89 .iter() 90 .map(RadrootsNip10ReplyDiagnostic::code) 91 .collect::<Vec<_>>(), 92 vec![ 93 "reply_reference_relay_ignored", 94 "reply_reference_author_ignored", 95 "reply_author_shape_ignored", 96 "reply_author_invalid_ignored", 97 "reply_author_relay_ignored", 98 "reply_author_duplicate_ignored", 99 "reply_author_mismatch_ignored", 100 ] 101 ); 102 assert_eq!( 103 projection 104 .diagnostics() 105 .iter() 106 .map(RadrootsNip10ReplyDiagnostic::tag_index) 107 .collect::<Vec<_>>(), 108 vec![ 109 Some(0), 110 Some(0), 111 Some(2), 112 Some(3), 113 Some(4), 114 Some(5), 115 Some(1), 116 ] 117 ); 118 for diagnostic in projection.diagnostics() { 119 let Some(tag_index) = diagnostic.tag_index() else { 120 continue; 121 }; 122 assert_eq!(diagnostic.raw_tag().expect("source tag"), tags[tag_index]); 123 } 124 } 125 126 #[test] 127 fn inbound_projection_uses_the_canonical_relay_hint_profile() { 128 let root_id = h('a'); 129 let root_author = h('b'); 130 for relay in [ 131 "wss://%65xample.com", 132 "wss://127.1", 133 "wss://relay.example:01", 134 "wss://[2001:0db8::1]", 135 "wss://relay.example/%2f", 136 ] { 137 let root_tag = vec![ 138 "e".to_string(), 139 root_id.clone(), 140 relay.to_string(), 141 "root".to_string(), 142 ]; 143 let tags = vec![root_tag.clone(), vec!["p".to_string(), root_author.clone()]]; 144 let projection = project_nip10_reply_parts(KIND_POST, &tags, "Reply", 10) 145 .unwrap_or_else(|error| panic!("{relay} must remain advisory: {error}")); 146 147 assert!(projection.root().relay().is_none(), "{relay}"); 148 assert_eq!(projection.root().raw_tag(), root_tag); 149 assert_eq!( 150 projection 151 .diagnostics() 152 .iter() 153 .map(RadrootsNip10ReplyDiagnostic::code) 154 .collect::<Vec<_>>(), 155 vec!["reply_reference_relay_ignored"], 156 "{relay}" 157 ); 158 assert_eq!( 159 projection.diagnostics()[0].raw_tag(), 160 Some(root_tag.as_slice()), 161 "{relay}" 162 ); 163 } 164 165 let tags = vec![ 166 vec![ 167 "e".to_string(), 168 root_id, 169 "wss://[2001:db8::1]:65535/nostr?region=ca-bc".to_string(), 170 "root".to_string(), 171 ], 172 vec![ 173 "p".to_string(), 174 root_author, 175 "ws://127.0.0.1:21003".to_string(), 176 ], 177 ]; 178 let projection = project_nip10_reply_parts(KIND_POST, &tags, "Reply", 10) 179 .expect("canonical reference and participant relays"); 180 assert_eq!( 181 projection.root().relay().expect("root relay").as_str(), 182 "wss://[2001:db8::1]:65535/nostr?region=ca-bc" 183 ); 184 assert_eq!( 185 projection.participants()[0] 186 .relay() 187 .expect("participant relay") 188 .as_str(), 189 "ws://127.0.0.1:21003" 190 ); 191 assert!(projection.diagnostics().is_empty()); 192 } 193 194 #[test] 195 fn inbound_relay_syntax_and_tag_element_budgets_remain_separate() { 196 let prefix = "wss://relay.example/"; 197 let relay = format!( 198 "{prefix}{}", 199 "a".repeat(RADROOTS_POST_TAG_ELEMENT_MAX_BYTES + 1 - prefix.len()) 200 ); 201 assert_eq!(relay.len(), RADROOTS_POST_TAG_ELEMENT_MAX_BYTES + 1); 202 NostrRelayHint::parse(&relay).expect("relay syntax has no Reply wire budget"); 203 204 let tags = vec![ 205 vec!["e".to_string(), h('a'), relay, "root".to_string()], 206 vec!["p".to_string(), h('b')], 207 ]; 208 assert!(matches!( 209 project_nip10_reply_parts(KIND_POST, &tags, "Reply", 10), 210 Err(RadrootsNip10ReplyProjectionError::TagElementTooLarge { 211 max: RADROOTS_POST_TAG_ELEMENT_MAX_BYTES, 212 actual, 213 tag_index: 0, 214 element_index: 2, 215 }) if actual == RADROOTS_POST_TAG_ELEMENT_MAX_BYTES + 1 216 )); 217 } 218 219 #[test] 220 fn tolerant_inbound_keeps_reference_ids_and_markers_as_hard_gates() { 221 let author = h('b'); 222 for (tag, expected) in [ 223 ( 224 vec![ 225 "e".to_string(), 226 "not-an-event-id".to_string(), 227 String::new(), 228 "root".to_string(), 229 ], 230 "reply_event_id_invalid", 231 ), 232 ( 233 vec![ 234 "e".to_string(), 235 h('a'), 236 String::new(), 237 "mention".to_string(), 238 ], 239 "reply_marker_missing", 240 ), 241 ] { 242 let error = project_nip10_reply_parts( 243 KIND_POST, 244 &[tag, vec!["p".to_string(), author.clone()]], 245 "Reply", 246 10, 247 ) 248 .unwrap_err(); 249 assert_eq!(error.code(), expected); 250 } 251 } 252 253 #[test] 254 fn marked_inbound_retains_citations_and_ignores_malformed_supplements() { 255 let root_id = h('a'); 256 let citation_id = h('c'); 257 let tags = vec![ 258 vec!["e".to_string()], 259 vec![ 260 "e".to_string(), 261 citation_id.clone(), 262 "wss://relay.example".to_string(), 263 String::new(), 264 h('b'), 265 ], 266 vec!["e".to_string(), "not-an-event-id".to_string()], 267 vec![ 268 "e".to_string(), 269 h('d'), 270 String::new(), 271 "mention".to_string(), 272 ], 273 vec![ 274 "e".to_string(), 275 root_id.clone(), 276 String::new(), 277 "root".to_string(), 278 ], 279 ]; 280 281 let projection = project_nip10_reply_parts(KIND_POST, &tags, "Reply", 10) 282 .expect("supplemental references must not erase a marked Reply"); 283 284 assert_eq!(projection.root().event_id().to_hex(), root_id); 285 assert_eq!(projection.citations().len(), 1); 286 assert_eq!(projection.citations()[0].tag_index(), 1); 287 assert_eq!(projection.citations()[0].raw_tag(), tags[1]); 288 assert_eq!(projection.citations()[0].event_id().to_hex(), citation_id); 289 assert_eq!( 290 projection.citations()[0] 291 .relay() 292 .expect("citation relay") 293 .as_str(), 294 "wss://relay.example" 295 ); 296 assert_eq!( 297 projection.citations()[0] 298 .author_hint() 299 .expect("citation author") 300 .to_hex(), 301 h('b') 302 ); 303 assert_eq!( 304 projection 305 .diagnostics() 306 .iter() 307 .map(RadrootsNip10ReplyDiagnostic::code) 308 .collect::<Vec<_>>(), 309 vec![ 310 "reply_citation_shape_ignored", 311 "reply_citation_event_id_ignored", 312 "reply_citation_marker_ignored", 313 "reply_author_missing_ignored", 314 "reply_author_mismatch_ignored", 315 ] 316 ); 317 assert_eq!( 318 projection 319 .diagnostics() 320 .iter() 321 .map(RadrootsNip10ReplyDiagnostic::tag_index) 322 .collect::<Vec<_>>(), 323 vec![Some(0), Some(2), Some(3), None, Some(1)] 324 ); 325 } 326 327 #[test] 328 fn positional_inbound_accepts_empty_markers_and_tolerates_middle_citations() { 329 let root_id = h('a'); 330 let root_author = h('b'); 331 let direct_tags = vec![ 332 vec![ 333 "e".to_string(), 334 root_id.clone(), 335 "wss://root.relay.example".to_string(), 336 String::new(), 337 root_author.clone(), 338 ], 339 vec!["p".to_string(), root_author.clone()], 340 ]; 341 let direct = project_nip10_reply_parts(KIND_POST, &direct_tags, "Direct", 10) 342 .expect("empty marker and fifth author are valid positional input"); 343 assert_eq!(direct.style(), RadrootsNip10ReplyStyle::LegacyPositional); 344 assert!(direct.is_direct()); 345 assert_eq!( 346 direct 347 .root() 348 .author_hint() 349 .expect("root author hint") 350 .to_hex(), 351 root_author 352 ); 353 assert!(direct.diagnostics().is_empty()); 354 355 let parent_id = h('c'); 356 let parent_author = h('d'); 357 let citation_id = h('e'); 358 let citation_author = h('f'); 359 let nested_tags = vec![ 360 vec![ 361 "e".to_string(), 362 root_id, 363 String::new(), 364 String::new(), 365 root_author.clone(), 366 ], 367 vec![ 368 "e".to_string(), 369 "1".repeat(64), 370 String::new(), 371 "mention".to_string(), 372 ], 373 vec![ 374 "e".to_string(), 375 citation_id.clone(), 376 String::new(), 377 String::new(), 378 citation_author.clone(), 379 ], 380 vec![ 381 "e".to_string(), 382 parent_id.clone(), 383 "https://parent.relay.example".to_string(), 384 String::new(), 385 parent_author.clone(), 386 ], 387 vec!["p".to_string(), root_author], 388 vec!["p".to_string(), parent_author], 389 vec!["p".to_string(), citation_author], 390 ]; 391 let nested = project_nip10_reply_parts(KIND_POST, &nested_tags, "Nested", 10) 392 .expect("malformed middle citations must not erase positional anchors"); 393 assert_eq!(nested.style(), RadrootsNip10ReplyStyle::LegacyPositional); 394 assert_eq!(nested.parent().event_id().to_hex(), parent_id); 395 assert_eq!(nested.citations().len(), 1); 396 assert_eq!(nested.citations()[0].tag_index(), 2); 397 assert_eq!(nested.citations()[0].event_id().to_hex(), citation_id); 398 assert_eq!( 399 nested 400 .diagnostics() 401 .iter() 402 .map(RadrootsNip10ReplyDiagnostic::code) 403 .collect::<Vec<_>>(), 404 vec![ 405 "reply_citation_marker_ignored", 406 "reply_reference_relay_ignored", 407 ] 408 ); 409 assert_eq!(nested.diagnostics()[0].tag_index(), Some(1)); 410 assert_eq!( 411 nested.diagnostics()[0].raw_tag(), 412 Some(nested_tags[1].as_slice()) 413 ); 414 assert_eq!(nested.diagnostics()[1].tag_index(), Some(3)); 415 assert_eq!( 416 nested.diagnostics()[1].raw_tag(), 417 Some(nested_tags[3].as_slice()) 418 ); 419 }