calendar.rs (25691B)
1 use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256}; 2 use radroots_event::{ 3 calendar::{ 4 AuthoredCalendar, AuthoredCalendarDateEvent, AuthoredCalendarEventRsvp, 5 AuthoredCalendarTimeEvent, CalendarAdmissionError, CalendarDate, CalendarEventError, 6 CalendarEventReference, CalendarEventRsvpStatus, CalendarParticipant, CalendarUid, 7 IanaTimeZoneId, RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS, RADROOTS_CALENDAR_SECONDS_PER_DAY, 8 covered_utc_days, 9 }, 10 envelope::EventTags, 11 envelope::kind::{ 12 KIND_CALENDAR, KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_EVENT_RSVP, 13 KIND_CALENDAR_TIME_EVENT, KIND_POST, 14 }, 15 media::AuthoredImage, 16 tag::name::{ 17 TAG_A, TAG_D, TAG_D_DAY, TAG_END, TAG_END_TZID, TAG_G, TAG_IMAGE, TAG_LOCATION, TAG_P, 18 TAG_R, TAG_START, TAG_START_TZID, TAG_SUMMARY, TAG_T, TAG_TITLE, 19 }, 20 wire::{DEFAULT_CONTENT_MAX_BYTES, DEFAULT_TAG_ELEMENT_MAX_BYTES, DEFAULT_TAG_MAX_COUNT}, 21 }; 22 use radroots_event_codec::decode::{ 23 EventParseError, 24 calendar::{ 25 admit_radroots_calendar_date_event, admit_radroots_calendar_time_event, 26 nip52_calendar_data_from_event, nip52_calendar_event_rsvp_data_from_event, 27 nip52_calendar_event_rsvp_parsed_from_event, nip52_calendar_parsed_from_event, 28 nip52_date_data_from_event, nip52_date_parsed_from_event, parse_nip52_calendar_date_event, 29 parse_nip52_calendar_time_event, 30 }, 31 }; 32 use radroots_event_codec::encode::{ 33 EventEncodeError, 34 calendar::{ 35 calendar_date_event_build_tags, calendar_time_event_build_tags, calendar_to_wire_parts, 36 calendar_to_wire_parts_with_kind, date_to_wire_parts, date_to_wire_parts_with_kind, 37 rsvp_to_wire_parts, rsvp_to_wire_parts_with_kind, time_to_wire_parts, 38 time_to_wire_parts_with_kind, 39 }, 40 }; 41 42 const DATE_D_TAG: &str = "fernwood-csa-pickup"; 43 const TIME_D_TAG: &str = "wash-pack-shift"; 44 const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; 45 const EVENT_AUTHOR: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; 46 const SECOND_AUTHOR: &str = "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; 47 const EVENT_SIG: &str = concat!( 48 "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", 49 "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" 50 ); 51 52 fn date(value: &str) -> CalendarDate { 53 CalendarDate::parse(value).unwrap() 54 } 55 56 fn authored_image() -> AuthoredImage { 57 let bytes = b"canonical-calendar-image"; 58 let hash = Sha256::digest(bytes); 59 let media_type = MediaType::parse("image/webp").unwrap(); 60 let descriptor = BlobDescriptor::new( 61 BlobUrl::parse(&format!("https://media.example.test/{hash}.webp")).unwrap(), 62 hash, 63 bytes.len() as u64, 64 media_type.clone(), 65 1_784_347_200, 66 ) 67 .unwrap() 68 .approve_reference() 69 .unwrap() 70 .verify_bytes(bytes, &media_type) 71 .unwrap(); 72 AuthoredImage::try_from(descriptor).unwrap() 73 } 74 75 fn sample_date_event() -> AuthoredCalendarDateEvent { 76 AuthoredCalendarDateEvent::new(DATE_D_TAG, "CSA pickup", date("2026-06-20")) 77 .unwrap() 78 .with_end(date("2026-06-21")) 79 .unwrap() 80 .with_description("Bring clean bins to the farm stand.") 81 .unwrap() 82 .with_locations(vec!["Farm stand".to_string()]) 83 .unwrap() 84 .with_geohash("c23nb62w20st") 85 .unwrap() 86 .with_summary("Weekly pickup") 87 .unwrap() 88 .with_image(authored_image()) 89 .unwrap() 90 .with_participants(vec![CalendarParticipant { 91 pubkey: EVENT_AUTHOR.to_string(), 92 relay: Some("wss://relay.example.test".to_string()), 93 role: Some("host".to_string()), 94 }]) 95 .unwrap() 96 .with_categories(vec!["csa".to_string(), "vegetables".to_string()]) 97 .unwrap() 98 } 99 100 fn sample_time_event() -> AuthoredCalendarTimeEvent { 101 AuthoredCalendarTimeEvent::new(TIME_D_TAG, "Wash pack shift", 86_399) 102 .unwrap() 103 .with_end(172_801) 104 .unwrap() 105 .with_description("Prepare CSA bins before pickup.") 106 .unwrap() 107 .with_start_tzid("America/Vancouver") 108 .unwrap() 109 .with_locations(vec!["Pack shed".to_string()]) 110 .unwrap() 111 .with_geohash("c23nb62w20st") 112 .unwrap() 113 .with_summary("Prepare CSA bins") 114 .unwrap() 115 .with_participants(vec![CalendarParticipant { 116 pubkey: EVENT_AUTHOR.to_string(), 117 relay: None, 118 role: Some("participant".to_string()), 119 }]) 120 .unwrap() 121 .with_categories(vec!["farm-work".to_string()]) 122 .unwrap() 123 } 124 125 fn baseline_common_tags(image: &str) -> Vec<Vec<String>> { 126 vec![ 127 vec![TAG_D.to_string(), DATE_D_TAG.to_string()], 128 vec![TAG_TITLE.to_string(), " CSA pickup ".to_string()], 129 vec![TAG_LOCATION.to_string(), "Farm stand".to_string()], 130 vec![TAG_LOCATION.to_string(), "Moss Street Market".to_string()], 131 vec![TAG_G.to_string(), "C23NB62W20ST".to_string()], 132 vec![TAG_SUMMARY.to_string(), " Weekly pickup ".to_string()], 133 vec![TAG_IMAGE.to_string(), image.to_string()], 134 vec![TAG_P.to_string(), EVENT_AUTHOR.to_string()], 135 vec![ 136 TAG_P.to_string(), 137 SECOND_AUTHOR.to_string(), 138 "wss://relay.example.test/events".to_string(), 139 ], 140 vec![ 141 TAG_P.to_string(), 142 EVENT_AUTHOR.to_string(), 143 String::new(), 144 " host ".to_string(), 145 ], 146 vec![TAG_T.to_string(), "vegetables".to_string()], 147 vec![TAG_T.to_string(), "CSA".to_string()], 148 vec![ 149 TAG_R.to_string(), 150 "ipfs://bafybeigdyrzt/reference".to_string(), 151 ], 152 vec![ 153 TAG_A.to_string(), 154 format!("31924:{EVENT_AUTHOR}:farm-calendar"), 155 "wss://relay.example.test".to_string(), 156 ], 157 vec!["name".to_string(), "Deprecated display name".to_string()], 158 ] 159 } 160 161 fn has_tag(tags: &[Vec<String>], key: &str, value: &str) -> bool { 162 tags.iter().any(|tag| { 163 tag.first().map(String::as_str) == Some(key) 164 && tag.get(1).map(String::as_str) == Some(value) 165 }) 166 } 167 168 fn tag_values<'a>(tags: &'a [Vec<String>], key: &str) -> Vec<&'a str> { 169 tags.iter() 170 .filter(|tag| tag.first().map(String::as_str) == Some(key)) 171 .map(|tag| tag[1].as_str()) 172 .collect() 173 } 174 175 fn replace_tag_value(tags: &mut [Vec<String>], key: &str, value: &str) { 176 let tag = tags 177 .iter_mut() 178 .find(|tag| tag.first().map(String::as_str) == Some(key)) 179 .expect("tag"); 180 tag[1] = value.to_string(); 181 } 182 183 #[test] 184 fn authored_date_event_emits_canonical_fields_and_no_uppercase_day_tag() { 185 let event = sample_date_event(); 186 let parts = date_to_wire_parts(&event).unwrap(); 187 188 assert_eq!(parts.kind, KIND_CALENDAR_DATE_EVENT); 189 assert_eq!(parts.content, "Bring clean bins to the farm stand."); 190 assert!(has_tag(&parts.tags, TAG_D, DATE_D_TAG)); 191 assert!(has_tag(&parts.tags, TAG_TITLE, "CSA pickup")); 192 assert!(has_tag(&parts.tags, TAG_START, "2026-06-20")); 193 assert!(has_tag(&parts.tags, TAG_END, "2026-06-21")); 194 assert!(has_tag(&parts.tags, TAG_LOCATION, "Farm stand")); 195 assert!(has_tag(&parts.tags, TAG_G, "c23nb62w20st")); 196 assert!(has_tag(&parts.tags, TAG_T, "csa")); 197 assert!(has_tag(&parts.tags, TAG_T, "vegetables")); 198 assert!(!parts.tags.iter().any(|tag| tag[0] == TAG_D_DAY)); 199 assert_eq!(calendar_date_event_build_tags(&event).unwrap(), parts.tags); 200 201 let parsed = parse_nip52_calendar_date_event(parts.kind, &parts.tags, &parts.content).unwrap(); 202 let admitted = admit_radroots_calendar_date_event(parsed).unwrap(); 203 assert_eq!(admitted.d_tag().as_str(), DATE_D_TAG); 204 assert!(admitted.blossom_image().is_some()); 205 } 206 207 #[test] 208 fn authored_time_event_derives_exact_ordered_days_and_iana_fallback() { 209 let event = sample_time_event(); 210 assert_eq!( 211 event.effective_end_tzid().map(IanaTimeZoneId::as_str), 212 Some("America/Vancouver") 213 ); 214 assert_eq!(event.end_tzid(), None); 215 216 let parts = time_to_wire_parts(&event).unwrap(); 217 assert_eq!(parts.kind, KIND_CALENDAR_TIME_EVENT); 218 assert_eq!(tag_values(&parts.tags, TAG_D_DAY), ["0", "1", "2"]); 219 assert!(has_tag(&parts.tags, TAG_START_TZID, "America/Vancouver")); 220 assert!(!parts.tags.iter().any(|tag| tag[0] == TAG_END_TZID)); 221 assert_eq!(calendar_time_event_build_tags(&event).unwrap(), parts.tags); 222 223 let parsed = parse_nip52_calendar_time_event(parts.kind, &parts.tags, &parts.content).unwrap(); 224 let admitted = admit_radroots_calendar_time_event(parsed).unwrap(); 225 assert_eq!(admitted.covered_utc_days(), [0, 1, 2]); 226 } 227 228 #[test] 229 fn baseline_date_parser_preserves_nip52_extensions_and_common_metadata() { 230 let image = "https://ordinary.example.test/images/market.jpg?size=large"; 231 let mut tags = baseline_common_tags(image); 232 tags.push(vec![TAG_START.to_string(), "2026-06-20".to_string()]); 233 tags.push(vec![TAG_END.to_string(), "2026-06-22".to_string()]); 234 tags.push(vec![ 235 TAG_D_DAY.to_string(), 236 "extension".to_string(), 237 "preserved".to_string(), 238 ]); 239 240 let parsed = 241 parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &tags, "Bring reusable bags.") 242 .unwrap(); 243 let common = parsed.common(); 244 assert_eq!(common.d_tag(), DATE_D_TAG); 245 assert_eq!(common.title(), " CSA pickup "); 246 assert_eq!(common.description(), Some("Bring reusable bags.")); 247 assert_eq!(common.locations(), ["Farm stand", "Moss Street Market"]); 248 assert_eq!(common.geohash(), Some("C23NB62W20ST")); 249 assert_eq!(common.summary(), Some(" Weekly pickup ")); 250 assert_eq!(common.image().unwrap().as_str(), image); 251 assert_eq!(common.participants().len(), 3); 252 assert_eq!(common.participants()[2].relay, None); 253 assert_eq!(common.participants()[2].role.as_deref(), Some(" host ")); 254 assert_eq!(common.categories(), ["vegetables", "CSA"]); 255 assert_eq!( 256 common.references()[0].as_str(), 257 "ipfs://bafybeigdyrzt/reference" 258 ); 259 assert_eq!( 260 common.calendar_requests()[0].calendar().as_str(), 261 format!("31924:{EVENT_AUTHOR}:farm-calendar") 262 ); 263 assert_eq!(common.legacy_name(), Some("Deprecated display name")); 264 assert_eq!(parsed.start().as_str(), "2026-06-20"); 265 assert_eq!(parsed.end().unwrap().as_str(), "2026-06-22"); 266 assert_eq!( 267 parsed.extension_day_tags(), 268 [vec![ 269 TAG_D_DAY.to_string(), 270 "extension".to_string(), 271 "preserved".to_string() 272 ]] 273 ); 274 } 275 276 #[test] 277 fn baseline_time_parser_accepts_nip52_should_level_variance() { 278 let mut tags = vec![ 279 vec![TAG_D.to_string(), TIME_D_TAG.to_string()], 280 vec![TAG_TITLE.to_string(), "Harvest window".to_string()], 281 vec![TAG_START.to_string(), "086399".to_string()], 282 vec![TAG_END.to_string(), "172801".to_string()], 283 vec![TAG_D_DAY.to_string(), "000".to_string()], 284 vec![TAG_D_DAY.to_string(), "2".to_string()], 285 vec![TAG_START_TZID.to_string(), "America/Vancouver".to_string()], 286 ]; 287 tags.extend([ 288 vec![TAG_T.to_string(), "harvest".to_string()], 289 vec![ 290 TAG_R.to_string(), 291 "https://example.test/harvest".to_string(), 292 ], 293 ]); 294 295 let parsed = parse_nip52_calendar_time_event(KIND_CALENDAR_TIME_EVENT, &tags, "").unwrap(); 296 assert_eq!(parsed.start_wire(), "086399"); 297 assert_eq!(parsed.start(), 86_399); 298 assert_eq!(parsed.end(), Some(172_801)); 299 assert_eq!( 300 parsed 301 .observed_day_indices() 302 .iter() 303 .map(|day| (day.wire_value(), day.index(), day.is_canonical())) 304 .collect::<Vec<_>>(), 305 vec![("000", 0, false), ("2", 2, true)] 306 ); 307 assert_eq!( 308 parsed.effective_end_tzid().map(IanaTimeZoneId::as_str), 309 Some("America/Vancouver") 310 ); 311 } 312 313 #[test] 314 fn strict_admission_rejects_date_extensions_noncanonical_metadata_and_non_blossom_images() { 315 let mut extension_tags = vec![ 316 vec![TAG_D.to_string(), DATE_D_TAG.to_string()], 317 vec![TAG_TITLE.to_string(), "CSA pickup".to_string()], 318 vec![TAG_START.to_string(), "2026-06-20".to_string()], 319 vec![TAG_D_DAY.to_string(), "20620".to_string()], 320 ]; 321 let parsed = 322 parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &extension_tags, "").unwrap(); 323 assert_eq!( 324 admit_radroots_calendar_date_event(parsed), 325 Err(CalendarAdmissionError::ForbiddenDateDayIndex) 326 ); 327 328 extension_tags.retain(|tag| tag[0] != TAG_D_DAY); 329 replace_tag_value(&mut extension_tags, TAG_TITLE, " CSA pickup "); 330 let parsed = 331 parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &extension_tags, "").unwrap(); 332 assert_eq!( 333 admit_radroots_calendar_date_event(parsed), 334 Err(CalendarAdmissionError::NonCanonicalField("metadata")) 335 ); 336 337 replace_tag_value(&mut extension_tags, TAG_TITLE, "CSA pickup"); 338 extension_tags.push(vec![ 339 TAG_IMAGE.to_string(), 340 "https://ordinary.example.test/market.jpg".to_string(), 341 ]); 342 let parsed = 343 parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &extension_tags, "").unwrap(); 344 assert_eq!( 345 admit_radroots_calendar_date_event(parsed), 346 Err(CalendarAdmissionError::NonBlossomImage) 347 ); 348 } 349 350 #[test] 351 fn strict_time_admission_requires_canonical_exact_coverage() { 352 let base = vec![ 353 vec![TAG_D.to_string(), TIME_D_TAG.to_string()], 354 vec![TAG_TITLE.to_string(), "Harvest window".to_string()], 355 vec![TAG_START.to_string(), "86399".to_string()], 356 vec![TAG_END.to_string(), "172801".to_string()], 357 ]; 358 359 for days in [ 360 vec!["0", "2"], 361 vec!["0", "2", "1"], 362 vec!["0", "1", "1", "2"], 363 vec!["00", "1", "2"], 364 ] { 365 let mut tags = base.clone(); 366 tags.extend( 367 days.into_iter() 368 .map(|day| vec![TAG_D_DAY.to_string(), day.to_string()]), 369 ); 370 let parsed = parse_nip52_calendar_time_event(KIND_CALENDAR_TIME_EVENT, &tags, "").unwrap(); 371 assert_eq!( 372 admit_radroots_calendar_time_event(parsed), 373 Err(CalendarAdmissionError::IncompleteDayCoverage) 374 ); 375 } 376 377 let mut noncanonical_timestamp = base.clone(); 378 replace_tag_value(&mut noncanonical_timestamp, TAG_START, "086399"); 379 noncanonical_timestamp 380 .extend([0, 1, 2].map(|day| vec![TAG_D_DAY.to_string(), day.to_string()])); 381 let parsed = 382 parse_nip52_calendar_time_event(KIND_CALENDAR_TIME_EVENT, &noncanonical_timestamp, "") 383 .unwrap(); 384 assert_eq!( 385 admit_radroots_calendar_time_event(parsed), 386 Err(CalendarAdmissionError::NonCanonicalField("timestamp")) 387 ); 388 } 389 390 #[test] 391 fn baseline_can_parse_overlong_ranges_that_strict_admission_rejects() { 392 let end = (RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS + 1) * RADROOTS_CALENDAR_SECONDS_PER_DAY; 393 let tags = vec![ 394 vec![TAG_D.to_string(), TIME_D_TAG.to_string()], 395 vec![TAG_TITLE.to_string(), "Long range".to_string()], 396 vec![TAG_START.to_string(), "0".to_string()], 397 vec![TAG_END.to_string(), end.to_string()], 398 vec![TAG_D_DAY.to_string(), "0".to_string()], 399 ]; 400 let parsed = parse_nip52_calendar_time_event(KIND_CALENDAR_TIME_EVENT, &tags, "").unwrap(); 401 assert_eq!(parsed.end(), Some(end)); 402 assert_eq!( 403 admit_radroots_calendar_time_event(parsed), 404 Err(CalendarAdmissionError::CoveredDayLimitExceeded { 405 max: RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS, 406 actual: RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS + 1, 407 }) 408 ); 409 } 410 411 #[test] 412 fn authored_construction_rejects_invalid_fields_before_encoding() { 413 assert_eq!( 414 AuthoredCalendarDateEvent::new(DATE_D_TAG, " CSA pickup ", date("2026-06-20")), 415 Err(CalendarEventError::InvalidTitle) 416 ); 417 assert_eq!( 418 AuthoredCalendarTimeEvent::new(TIME_D_TAG, "Shift", 10) 419 .unwrap() 420 .with_start_tzid("america/vancouver"), 421 Err(CalendarEventError::InvalidTimeZone) 422 ); 423 assert!(matches!( 424 AuthoredCalendarDateEvent::new(DATE_D_TAG, "Pickup", date("2026-06-20")) 425 .unwrap() 426 .with_summary("x".repeat(DEFAULT_TAG_ELEMENT_MAX_BYTES + 1)), 427 Err(CalendarEventError::TagElementTooLarge { 428 field: "summary", 429 .. 430 }) 431 )); 432 assert!(matches!( 433 AuthoredCalendarDateEvent::new(DATE_D_TAG, "Pickup", date("2026-06-20")) 434 .unwrap() 435 .with_description("x".repeat(DEFAULT_CONTENT_MAX_BYTES + 1)), 436 Err(CalendarEventError::ContentTooLarge { .. }) 437 )); 438 439 let invalid_participant = CalendarParticipant { 440 pubkey: EVENT_AUTHOR.to_ascii_uppercase(), 441 relay: None, 442 role: None, 443 }; 444 assert!(matches!( 445 AuthoredCalendarDateEvent::new(DATE_D_TAG, "Pickup", date("2026-06-20")) 446 .unwrap() 447 .with_participants(vec![invalid_participant]), 448 Err(CalendarEventError::InvalidParticipant { index: 0 }) 449 )); 450 } 451 452 #[test] 453 fn baseline_parser_rejects_malformed_fields_and_bounds_before_projection() { 454 let minimum = vec![ 455 vec![TAG_D.to_string(), DATE_D_TAG.to_string()], 456 vec![TAG_TITLE.to_string(), "CSA pickup".to_string()], 457 vec![TAG_START.to_string(), "2026-06-20".to_string()], 458 ]; 459 assert!(matches!( 460 parse_nip52_calendar_date_event(KIND_POST, &minimum, ""), 461 Err(EventParseError::InvalidKind { .. }) 462 )); 463 464 let mut duplicate = minimum.clone(); 465 duplicate.push(vec![TAG_TITLE.to_string(), "Duplicate".to_string()]); 466 assert!(matches!( 467 parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &duplicate, ""), 468 Err(EventParseError::DuplicateTag(TAG_TITLE)) 469 )); 470 471 let mut blank_d_tag = minimum.clone(); 472 replace_tag_value(&mut blank_d_tag, TAG_D, " \t"); 473 assert!(matches!( 474 parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &blank_d_tag, ""), 475 Err(EventParseError::InvalidTag(TAG_D)) 476 )); 477 478 let mut blank_title = minimum.clone(); 479 replace_tag_value(&mut blank_title, TAG_TITLE, " \t"); 480 assert!(matches!( 481 parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &blank_title, ""), 482 Err(EventParseError::InvalidTag(TAG_TITLE)) 483 )); 484 485 let mut impossible = minimum.clone(); 486 replace_tag_value(&mut impossible, TAG_START, "2026-02-30"); 487 assert!(matches!( 488 parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &impossible, ""), 489 Err(EventParseError::InvalidTag(TAG_START)) 490 )); 491 492 let mut relative_image = minimum.clone(); 493 relative_image.push(vec![TAG_IMAGE.to_string(), "/market.jpg".to_string()]); 494 assert!(matches!( 495 parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &relative_image, ""), 496 Err(EventParseError::InvalidTag(TAG_IMAGE)) 497 )); 498 499 let mut oversized_element = minimum.clone(); 500 replace_tag_value( 501 &mut oversized_element, 502 TAG_TITLE, 503 &"x".repeat(DEFAULT_TAG_ELEMENT_MAX_BYTES + 1), 504 ); 505 assert!(matches!( 506 parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &oversized_element, ""), 507 Err(EventParseError::InvalidEnvelope) 508 )); 509 510 let mut invalid_participant = minimum.clone(); 511 invalid_participant.push(vec![ 512 TAG_P.to_string(), 513 EVENT_AUTHOR.to_string(), 514 String::new(), 515 ]); 516 assert!(matches!( 517 parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &invalid_participant, ""), 518 Err(EventParseError::InvalidTag(TAG_P)) 519 )); 520 521 let invalid_tz = vec![ 522 vec![TAG_D.to_string(), TIME_D_TAG.to_string()], 523 vec![TAG_TITLE.to_string(), "Shift".to_string()], 524 vec![TAG_START.to_string(), "0".to_string()], 525 vec![TAG_D_DAY.to_string(), "0".to_string()], 526 vec![TAG_START_TZID.to_string(), "america/vancouver".to_string()], 527 ]; 528 assert!(matches!( 529 parse_nip52_calendar_time_event(KIND_CALENDAR_TIME_EVENT, &invalid_tz, ""), 530 Err(EventParseError::InvalidTag(TAG_START_TZID)) 531 )); 532 533 let missing_day = vec![ 534 vec![TAG_D.to_string(), TIME_D_TAG.to_string()], 535 vec![TAG_TITLE.to_string(), "Shift".to_string()], 536 vec![TAG_START.to_string(), "0".to_string()], 537 ]; 538 assert!(matches!( 539 parse_nip52_calendar_time_event(KIND_CALENDAR_TIME_EVENT, &missing_day, ""), 540 Err(EventParseError::MissingTag(TAG_D_DAY)) 541 )); 542 543 let mut out_of_range_day = missing_day; 544 out_of_range_day.push(vec![TAG_D_DAY.to_string(), "1".to_string()]); 545 assert!(matches!( 546 parse_nip52_calendar_time_event(KIND_CALENDAR_TIME_EVENT, &out_of_range_day, ""), 547 Err(EventParseError::InvalidTag(TAG_D_DAY)) 548 )); 549 550 let oversized_content = "x".repeat(DEFAULT_CONTENT_MAX_BYTES + 1); 551 assert!(matches!( 552 parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &minimum, &oversized_content), 553 Err(EventParseError::InvalidEnvelope) 554 )); 555 556 let mut too_many_tags = minimum; 557 too_many_tags.extend( 558 (too_many_tags.len()..=DEFAULT_TAG_MAX_COUNT) 559 .map(|_| vec!["x".to_string(), "y".to_string()]), 560 ); 561 assert!(EventTags::new(too_many_tags.clone()).is_err()); 562 assert!(matches!( 563 parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &too_many_tags, ""), 564 Err(EventParseError::InvalidEnvelope) 565 )); 566 } 567 568 #[test] 569 fn kind_specific_encoders_and_parsed_wrappers_preserve_envelopes() { 570 assert!(matches!( 571 date_to_wire_parts_with_kind(&sample_date_event(), KIND_POST), 572 Err(EventEncodeError::InvalidKind(KIND_POST)) 573 )); 574 assert!(matches!( 575 time_to_wire_parts_with_kind(&sample_time_event(), KIND_POST), 576 Err(EventEncodeError::InvalidKind(KIND_POST)) 577 )); 578 579 let parts = date_to_wire_parts(&sample_date_event()).unwrap(); 580 let data = nip52_date_data_from_event( 581 EVENT_ID.to_string(), 582 EVENT_AUTHOR.to_string(), 583 7, 584 parts.kind, 585 parts.content.clone(), 586 parts.tags.clone(), 587 ) 588 .unwrap(); 589 assert_eq!(data.data.common().title(), "CSA pickup"); 590 591 let parsed = nip52_date_parsed_from_event( 592 EVENT_ID.to_string(), 593 EVENT_AUTHOR.to_string(), 594 7, 595 parts.kind, 596 parts.content, 597 parts.tags, 598 EVENT_SIG.to_string(), 599 ) 600 .unwrap(); 601 assert_eq!(parsed.event.signature_hex(), EVENT_SIG); 602 assert_eq!(parsed.data.data.common().d_tag(), DATE_D_TAG); 603 604 let event_reference = CalendarEventReference::parse( 605 format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{TIME_D_TAG}"), 606 Some("wss://relay.example.test/events"), 607 ) 608 .unwrap(); 609 let calendar = AuthoredCalendar::new( 610 CalendarUid::parse("AAAAAAAAAAAAAAAAAAAAAA").unwrap(), 611 "Farm calendar", 612 "Shared farm schedule.", 613 vec![event_reference.clone()], 614 ) 615 .unwrap(); 616 assert!(matches!( 617 calendar_to_wire_parts_with_kind(&calendar, KIND_POST), 618 Err(EventEncodeError::InvalidKind(KIND_POST)) 619 )); 620 let parts = calendar_to_wire_parts(&calendar).unwrap(); 621 assert_eq!(parts.kind, KIND_CALENDAR); 622 let data = nip52_calendar_data_from_event( 623 EVENT_ID.to_string(), 624 EVENT_AUTHOR.to_string(), 625 8, 626 parts.kind, 627 parts.content.clone(), 628 parts.tags.clone(), 629 ) 630 .unwrap(); 631 assert_eq!( 632 data.data.event_references(), 633 core::slice::from_ref(&event_reference) 634 ); 635 let parsed = nip52_calendar_parsed_from_event( 636 EVENT_ID.to_string(), 637 EVENT_AUTHOR.to_string(), 638 8, 639 parts.kind, 640 parts.content, 641 parts.tags, 642 EVENT_SIG.to_string(), 643 ) 644 .unwrap(); 645 assert_eq!(parsed.event.signature_hex(), EVENT_SIG); 646 assert_eq!(parsed.data.data.title(), "Farm calendar"); 647 648 let rsvp = AuthoredCalendarEventRsvp::new( 649 CalendarUid::parse("DDDDDDDDDDDDDDDDDDDDDw").unwrap(), 650 event_reference, 651 CalendarEventRsvpStatus::Accepted, 652 ) 653 .unwrap(); 654 assert!(matches!( 655 rsvp_to_wire_parts_with_kind(&rsvp, KIND_POST), 656 Err(EventEncodeError::InvalidKind(KIND_POST)) 657 )); 658 let parts = rsvp_to_wire_parts(&rsvp).unwrap(); 659 assert_eq!(parts.kind, KIND_CALENDAR_EVENT_RSVP); 660 let data = nip52_calendar_event_rsvp_data_from_event( 661 EVENT_ID.to_string(), 662 EVENT_AUTHOR.to_string(), 663 9, 664 parts.kind, 665 parts.content.clone(), 666 parts.tags.clone(), 667 ) 668 .unwrap(); 669 assert_eq!(data.data.status(), &CalendarEventRsvpStatus::Accepted); 670 let parsed = nip52_calendar_event_rsvp_parsed_from_event( 671 EVENT_ID.to_string(), 672 EVENT_AUTHOR.to_string(), 673 9, 674 parts.kind, 675 parts.content, 676 parts.tags, 677 EVENT_SIG.to_string(), 678 ) 679 .unwrap(); 680 assert_eq!(parsed.event.signature_hex(), EVENT_SIG); 681 assert_eq!(parsed.data.data.d_tag(), "DDDDDDDDDDDDDDDDDDDDDw"); 682 } 683 684 #[test] 685 fn exclusive_end_day_math_is_bounded() { 686 assert_eq!( 687 covered_utc_days(86_399, Some(86_400)) 688 .unwrap() 689 .collect::<Vec<_>>(), 690 vec![0] 691 ); 692 assert_eq!( 693 covered_utc_days(86_399, Some(86_401)) 694 .unwrap() 695 .collect::<Vec<_>>(), 696 vec![0, 1] 697 ); 698 assert_eq!( 699 covered_utc_days(10, Some(10)), 700 Err(CalendarEventError::InvalidRange) 701 ); 702 }