calendar.rs (30883B)
1 #![cfg(feature = "serde_json")] 2 3 use radroots_events::{ 4 calendar::{ 5 RadrootsCalendar, RadrootsCalendarDateEvent, RadrootsCalendarEventRsvp, 6 RadrootsCalendarTimeEvent, 7 }, 8 kinds::{ 9 KIND_ARTICLE, KIND_CALENDAR, KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_EVENT_RSVP, 10 KIND_CALENDAR_TIME_EVENT, KIND_POST, 11 }, 12 social::{ 13 RadrootsCalendarDateValue, RadrootsCalendarEventFreeBusy, RadrootsCalendarEventRsvpStatus, 14 RadrootsCalendarParticipant, RadrootsSocialLocation, RadrootsSocialTarget, 15 }, 16 tags::{ 17 TAG_A, TAG_D, TAG_D_DAY, TAG_E, TAG_END, TAG_END_TZID, TAG_FREE_BUSY, TAG_G, TAG_IMAGE, 18 TAG_LOCATION, TAG_P, TAG_START, TAG_START_TZID, TAG_STATUS, TAG_SUMMARY, TAG_TITLE, 19 }, 20 }; 21 use radroots_events_codec::{ 22 calendar::{ 23 decode::{ 24 calendar_data_from_event, calendar_date_event_from_event, calendar_from_event, 25 calendar_parsed_from_event, calendar_time_event_from_event, date_data_from_event, 26 date_parsed_from_event, rsvp_data_from_event, rsvp_from_event, rsvp_parsed_from_event, 27 time_data_from_event, time_parsed_from_event, 28 }, 29 encode::{ 30 calendar_collection_build_tags, calendar_date_event_build_tags, 31 calendar_time_event_build_tags, calendar_to_wire_parts, 32 calendar_to_wire_parts_with_kind, date_to_wire_parts, date_to_wire_parts_with_kind, 33 rsvp_build_tags, rsvp_to_wire_parts, rsvp_to_wire_parts_with_kind, time_to_wire_parts, 34 time_to_wire_parts_with_kind, 35 }, 36 }, 37 error::{EventEncodeError, EventParseError}, 38 }; 39 40 const VALID_D_TAG: &str = "CCCCCCCCCCCCCCCCCCCCCA"; 41 const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; 42 const EVENT_AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; 43 const EVENT_D_TAG: &str = "EEEEEEEEEEEEEEEEEEEEEA"; 44 45 fn sample_date_event() -> RadrootsCalendarDateEvent { 46 RadrootsCalendarDateEvent { 47 d_tag: VALID_D_TAG.to_string(), 48 title: "CSA pickup".to_string(), 49 start: "2026-06-20".to_string(), 50 description: Some("Bring clean bins to the farm stand.".to_string()), 51 end: Some("2026-06-21".to_string()), 52 days: Some(vec![RadrootsCalendarDateValue { 53 value: "2026-06-20".to_string(), 54 }]), 55 location: Some(RadrootsSocialLocation { 56 name: Some("Farm stand".to_string()), 57 geohash: Some("c23nb62w20st".to_string()), 58 }), 59 summary: Some("Weekly pickup".to_string()), 60 image: Some("https://media.example.test/calendar.jpg".to_string()), 61 participants: Some(vec![RadrootsCalendarParticipant { 62 pubkey: "host_pubkey".to_string(), 63 relay: Some("wss://relay.example.test".to_string()), 64 role: Some("host".to_string()), 65 }]), 66 } 67 } 68 69 fn sample_time_event() -> RadrootsCalendarTimeEvent { 70 RadrootsCalendarTimeEvent { 71 d_tag: VALID_D_TAG.to_string(), 72 title: "Wash pack shift".to_string(), 73 start: 1_781_895_600, 74 dates: vec![RadrootsCalendarDateValue { 75 value: "2026-06-20".to_string(), 76 }], 77 description: Some("Prepare CSA bins before pickup.".to_string()), 78 end: Some(1_781_899_200), 79 start_tzid: Some("America/Vancouver".to_string()), 80 end_tzid: Some("America/Vancouver".to_string()), 81 location: Some(RadrootsSocialLocation { 82 name: Some("Pack shed".to_string()), 83 geohash: Some("c23nb62w20st".to_string()), 84 }), 85 summary: Some("Prepare CSA bins".to_string()), 86 image: None, 87 participants: Some(vec![RadrootsCalendarParticipant { 88 pubkey: "crew_pubkey".to_string(), 89 relay: None, 90 role: Some("participant".to_string()), 91 }]), 92 } 93 } 94 95 fn sample_calendar_collection() -> RadrootsCalendar { 96 RadrootsCalendar { 97 d_tag: VALID_D_TAG.to_string(), 98 title: "Farm calendar".to_string(), 99 events: vec![RadrootsSocialTarget::Address { 100 address: format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}"), 101 author: Some(EVENT_AUTHOR.to_string()), 102 event_kind: Some(KIND_CALENDAR_TIME_EVENT), 103 relays: Some(vec!["wss://relay.example.test".to_string()]), 104 }], 105 description: Some("Shared schedule for farm operations.".to_string()), 106 summary: Some("CSA and harvest schedule".to_string()), 107 image: Some("https://media.example.test/calendar.jpg".to_string()), 108 } 109 } 110 111 fn sample_rsvp() -> RadrootsCalendarEventRsvp { 112 RadrootsCalendarEventRsvp { 113 d_tag: VALID_D_TAG.to_string(), 114 event: RadrootsSocialTarget::Address { 115 address: format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}"), 116 author: Some(EVENT_AUTHOR.to_string()), 117 event_kind: Some(KIND_CALENDAR_TIME_EVENT), 118 relays: Some(vec!["wss://relay.example.test".to_string()]), 119 }, 120 event_id: Some(EVENT_ID.to_string()), 121 status: RadrootsCalendarEventRsvpStatus::Accepted, 122 free_busy: Some(RadrootsCalendarEventFreeBusy::Busy), 123 note: Some("I can attend after harvest".to_string()), 124 participants: Some(vec![RadrootsCalendarParticipant { 125 pubkey: "crew_pubkey".to_string(), 126 relay: None, 127 role: Some("participant".to_string()), 128 }]), 129 } 130 } 131 132 fn has_tag(tags: &[Vec<String>], key: &str, value: &str) -> bool { 133 tags.iter().any(|tag| { 134 tag.first().map(|entry| entry.as_str()) == Some(key) 135 && tag.get(1).map(|entry| entry.as_str()) == Some(value) 136 }) 137 } 138 139 fn replace_tag_value(tags: &mut [Vec<String>], key: &str, value: &str) { 140 let tag = tags 141 .iter_mut() 142 .find(|tag| tag.first().map(|entry| entry.as_str()) == Some(key)) 143 .expect("tag"); 144 tag[1] = value.to_string(); 145 } 146 147 #[test] 148 fn calendar_date_event_to_wire_parts_roundtrips_tags() { 149 let event = sample_date_event(); 150 let parts = date_to_wire_parts(&event).unwrap(); 151 152 assert_eq!(parts.kind, KIND_CALENDAR_DATE_EVENT); 153 assert_eq!(parts.content, "Bring clean bins to the farm stand."); 154 assert!(has_tag(&parts.tags, TAG_D, VALID_D_TAG)); 155 assert!(has_tag(&parts.tags, TAG_TITLE, "CSA pickup")); 156 assert!(has_tag(&parts.tags, TAG_START, "2026-06-20")); 157 assert!(has_tag(&parts.tags, TAG_END, "2026-06-21")); 158 assert!(has_tag(&parts.tags, TAG_D_DAY, "2026-06-20")); 159 assert!(has_tag(&parts.tags, TAG_LOCATION, "Farm stand")); 160 assert!(has_tag(&parts.tags, TAG_G, "c23nb62w20st")); 161 assert!(has_tag(&parts.tags, TAG_SUMMARY, "Weekly pickup")); 162 assert!(has_tag( 163 &parts.tags, 164 TAG_IMAGE, 165 "https://media.example.test/calendar.jpg" 166 )); 167 assert!(has_tag(&parts.tags, TAG_P, "host_pubkey")); 168 169 let decoded = calendar_date_event_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); 170 assert_eq!(decoded.d_tag, VALID_D_TAG); 171 assert_eq!(decoded.title, "CSA pickup"); 172 assert_eq!( 173 decoded.description.as_deref(), 174 Some("Bring clean bins to the farm stand.") 175 ); 176 assert_eq!(decoded.start, "2026-06-20"); 177 assert_eq!(decoded.end.as_deref(), Some("2026-06-21")); 178 assert_eq!(decoded.days.as_ref().map(Vec::len), Some(1)); 179 assert_eq!( 180 decoded 181 .location 182 .as_ref() 183 .and_then(|location| location.name.as_deref()), 184 Some("Farm stand") 185 ); 186 assert_eq!(decoded.participants.as_ref().map(Vec::len), Some(1)); 187 } 188 189 #[test] 190 fn calendar_time_event_to_wire_parts_roundtrips_tags() { 191 let event = sample_time_event(); 192 let parts = time_to_wire_parts(&event).unwrap(); 193 194 assert_eq!(parts.kind, KIND_CALENDAR_TIME_EVENT); 195 assert_eq!(parts.content, "Prepare CSA bins before pickup."); 196 assert!(has_tag(&parts.tags, TAG_D, VALID_D_TAG)); 197 assert!(has_tag(&parts.tags, TAG_TITLE, "Wash pack shift")); 198 assert!(has_tag(&parts.tags, TAG_START, "1781895600")); 199 assert!(has_tag(&parts.tags, TAG_D_DAY, "2026-06-20")); 200 assert!(has_tag(&parts.tags, TAG_END, "1781899200")); 201 assert!(has_tag(&parts.tags, TAG_START_TZID, "America/Vancouver")); 202 assert!(has_tag(&parts.tags, TAG_END_TZID, "America/Vancouver")); 203 assert!(has_tag(&parts.tags, TAG_LOCATION, "Pack shed")); 204 assert!(has_tag(&parts.tags, TAG_P, "crew_pubkey")); 205 206 let decoded = calendar_time_event_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); 207 assert_eq!(decoded.d_tag, VALID_D_TAG); 208 assert_eq!(decoded.title, "Wash pack shift"); 209 assert_eq!( 210 decoded.description.as_deref(), 211 Some("Prepare CSA bins before pickup.") 212 ); 213 assert_eq!(decoded.start, 1_781_895_600); 214 assert_eq!(decoded.dates.len(), 1); 215 assert_eq!(decoded.end, Some(1_781_899_200)); 216 assert_eq!(decoded.start_tzid.as_deref(), Some("America/Vancouver")); 217 assert_eq!(decoded.participants.as_ref().map(Vec::len), Some(1)); 218 } 219 220 #[test] 221 fn calendar_collection_to_wire_parts_roundtrips_event_addresses() { 222 let calendar = sample_calendar_collection(); 223 let parts = calendar_to_wire_parts(&calendar).unwrap(); 224 225 assert_eq!(parts.kind, KIND_CALENDAR); 226 assert_eq!(parts.content, "Shared schedule for farm operations."); 227 assert!(has_tag(&parts.tags, TAG_D, VALID_D_TAG)); 228 assert!(has_tag(&parts.tags, TAG_TITLE, "Farm calendar")); 229 assert!(has_tag( 230 &parts.tags, 231 TAG_A, 232 format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}").as_str() 233 )); 234 235 let decoded = calendar_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); 236 assert_eq!(decoded.d_tag, VALID_D_TAG); 237 assert_eq!(decoded.title, "Farm calendar"); 238 assert_eq!( 239 decoded.description.as_deref(), 240 Some("Shared schedule for farm operations.") 241 ); 242 assert_eq!(decoded.events.len(), 1); 243 assert!(matches!( 244 decoded.events[0], 245 RadrootsSocialTarget::Address { 246 event_kind: Some(KIND_CALENDAR_TIME_EVENT), 247 .. 248 } 249 )); 250 } 251 252 #[test] 253 fn calendar_rsvp_to_wire_parts_roundtrips_status_event_id_and_participants() { 254 let rsvp = sample_rsvp(); 255 let parts = rsvp_to_wire_parts(&rsvp).unwrap(); 256 257 assert_eq!(parts.kind, KIND_CALENDAR_EVENT_RSVP); 258 assert_eq!(parts.content, "I can attend after harvest"); 259 assert!(has_tag(&parts.tags, TAG_D, VALID_D_TAG)); 260 assert!(has_tag( 261 &parts.tags, 262 TAG_A, 263 format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}").as_str() 264 )); 265 assert!(has_tag(&parts.tags, TAG_E, EVENT_ID)); 266 assert!(has_tag(&parts.tags, TAG_STATUS, "accepted")); 267 assert!(has_tag(&parts.tags, TAG_FREE_BUSY, "busy")); 268 assert!(has_tag(&parts.tags, TAG_P, "crew_pubkey")); 269 270 let decoded = rsvp_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); 271 assert_eq!(decoded.event_id.as_deref(), Some(EVENT_ID)); 272 assert_eq!(decoded.status, RadrootsCalendarEventRsvpStatus::Accepted); 273 assert_eq!(decoded.free_busy, Some(RadrootsCalendarEventFreeBusy::Busy)); 274 assert_eq!(decoded.note.as_deref(), Some("I can attend after harvest")); 275 assert_eq!(decoded.participants.as_ref().map(Vec::len), Some(1)); 276 } 277 278 #[test] 279 fn calendar_encode_omits_absent_optional_fields() { 280 let mut time = sample_time_event(); 281 time.end = None; 282 time.location = None; 283 let tags = calendar_time_event_build_tags(&time).unwrap(); 284 assert!(!tags.iter().any(|tag| tag[0] == TAG_END)); 285 assert!(!tags.iter().any(|tag| tag[0] == TAG_LOCATION)); 286 287 let mut collection = sample_calendar_collection(); 288 collection.events[0] = RadrootsSocialTarget::Address { 289 address: format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}"), 290 author: Some(EVENT_AUTHOR.to_string()), 291 event_kind: None, 292 relays: None, 293 }; 294 let tags = calendar_collection_build_tags(&collection).unwrap(); 295 assert_eq!( 296 tags.iter() 297 .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A)) 298 .map(Vec::len), 299 Some(2) 300 ); 301 302 let mut rsvp = sample_rsvp(); 303 rsvp.event = RadrootsSocialTarget::Address { 304 address: format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}"), 305 author: Some(EVENT_AUTHOR.to_string()), 306 event_kind: None, 307 relays: None, 308 }; 309 rsvp.free_busy = None; 310 let tags = rsvp_build_tags(&rsvp).unwrap(); 311 assert_eq!( 312 tags.iter() 313 .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_E)) 314 .map(Vec::len), 315 Some(2) 316 ); 317 assert!(!tags.iter().any(|tag| tag[0] == TAG_FREE_BUSY)); 318 } 319 320 #[test] 321 fn calendar_codecs_reject_wrong_kind_invalid_dates_and_missing_time_dates() { 322 assert!(matches!( 323 date_to_wire_parts_with_kind(&sample_date_event(), KIND_POST), 324 Err(EventEncodeError::InvalidKind(KIND_POST)) 325 )); 326 assert!(matches!( 327 time_to_wire_parts_with_kind(&sample_time_event(), KIND_POST), 328 Err(EventEncodeError::InvalidKind(KIND_POST)) 329 )); 330 331 let mut event = sample_date_event(); 332 event.start = "2026-6-20".to_string(); 333 assert!(matches!( 334 calendar_date_event_build_tags(&event), 335 Err(EventEncodeError::InvalidField("start")) 336 )); 337 338 let mut event = sample_time_event(); 339 event.end = Some(event.start - 1); 340 assert!(matches!( 341 calendar_time_event_build_tags(&event), 342 Err(EventEncodeError::InvalidField("end")) 343 )); 344 345 let mut event = sample_time_event(); 346 event.dates.clear(); 347 assert!(matches!( 348 calendar_time_event_build_tags(&event), 349 Err(EventEncodeError::EmptyRequiredField("dates")) 350 )); 351 352 let tags = calendar_date_event_build_tags(&sample_date_event()).unwrap(); 353 let decoded = calendar_date_event_from_event(KIND_CALENDAR_DATE_EVENT, &tags, "body").unwrap(); 354 assert_eq!(decoded.description.as_deref(), Some("body")); 355 356 let mut tags = calendar_date_event_build_tags(&sample_date_event()).unwrap(); 357 let start = tags 358 .iter_mut() 359 .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_START)) 360 .expect("start tag"); 361 start[1] = "bad".to_string(); 362 assert!(matches!( 363 calendar_date_event_from_event(KIND_CALENDAR_DATE_EVENT, &tags, ""), 364 Err(EventParseError::InvalidTag(TAG_START)) 365 )); 366 367 let mut tags = calendar_time_event_build_tags(&sample_time_event()).unwrap(); 368 tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(TAG_D_DAY)); 369 assert!(matches!( 370 calendar_time_event_from_event(KIND_CALENDAR_TIME_EVENT, &tags, ""), 371 Err(EventParseError::MissingTag(TAG_D_DAY)) 372 )); 373 374 let err = calendar_time_event_from_event(KIND_POST, &tags, "").unwrap_err(); 375 assert!(matches!( 376 err, 377 EventParseError::InvalidKind { 378 expected: "31923", 379 got: KIND_POST 380 } 381 )); 382 } 383 384 #[test] 385 fn calendar_collection_and_rsvp_reject_missing_or_invalid_required_tags() { 386 assert!(matches!( 387 calendar_to_wire_parts_with_kind(&sample_calendar_collection(), KIND_POST), 388 Err(EventEncodeError::InvalidKind(KIND_POST)) 389 )); 390 assert!(matches!( 391 rsvp_to_wire_parts_with_kind(&sample_rsvp(), KIND_POST), 392 Err(EventEncodeError::InvalidKind(KIND_POST)) 393 )); 394 395 let mut calendar = sample_calendar_collection(); 396 calendar.events.clear(); 397 assert!(matches!( 398 calendar_collection_build_tags(&calendar), 399 Err(EventEncodeError::EmptyRequiredField("events")) 400 )); 401 402 let mut rsvp = sample_rsvp(); 403 if let RadrootsSocialTarget::Address { event_kind, .. } = &mut rsvp.event { 404 *event_kind = Some(KIND_ARTICLE); 405 } 406 assert!(matches!( 407 rsvp_build_tags(&rsvp), 408 Err(EventEncodeError::InvalidField("event")) 409 )); 410 411 let mut tags = calendar_collection_build_tags(&sample_calendar_collection()).unwrap(); 412 tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(TAG_A)); 413 assert!(matches!( 414 calendar_from_event(KIND_CALENDAR, &tags, ""), 415 Err(EventParseError::MissingTag(TAG_A)) 416 )); 417 418 let mut tags = rsvp_build_tags(&sample_rsvp()).unwrap(); 419 let status = tags 420 .iter_mut() 421 .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_STATUS)) 422 .expect("status tag"); 423 status[1] = "maybe".to_string(); 424 assert!(matches!( 425 rsvp_from_event(KIND_CALENDAR_EVENT_RSVP, &tags, ""), 426 Err(EventParseError::InvalidTag(TAG_STATUS)) 427 )); 428 429 let mut tags = rsvp_build_tags(&sample_rsvp()).unwrap(); 430 let free_busy = tags 431 .iter_mut() 432 .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_FREE_BUSY)) 433 .expect("fb tag"); 434 free_busy[1] = "unknown".to_string(); 435 assert!(matches!( 436 rsvp_from_event(KIND_CALENDAR_EVENT_RSVP, &tags, ""), 437 Err(EventParseError::InvalidTag(TAG_FREE_BUSY)) 438 )); 439 } 440 441 #[test] 442 fn calendar_date_codecs_cover_optional_and_error_edges() { 443 let date_tags = calendar_date_event_build_tags(&sample_date_event()).unwrap(); 444 let wrong_kind = calendar_date_event_from_event(KIND_POST, &date_tags, "").unwrap_err(); 445 assert!(matches!( 446 wrong_kind, 447 EventParseError::InvalidKind { 448 expected: "31922", 449 got: KIND_POST 450 } 451 )); 452 453 let mut minimal = sample_date_event(); 454 minimal.description = None; 455 minimal.end = None; 456 minimal.days = None; 457 minimal.location = None; 458 minimal.summary = None; 459 minimal.image = None; 460 minimal.participants = None; 461 let parts = date_to_wire_parts(&minimal).unwrap(); 462 assert_eq!(parts.content, ""); 463 assert!( 464 !parts 465 .tags 466 .iter() 467 .any(|tag| tag.first().map(String::as_str) == Some(TAG_D_DAY)) 468 ); 469 let decoded = calendar_date_event_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); 470 assert!(decoded.description.is_none()); 471 assert!(decoded.end.is_none()); 472 assert!(decoded.days.is_none()); 473 474 let mut tags = calendar_date_event_build_tags(&sample_date_event()).unwrap(); 475 replace_tag_value(&mut tags, TAG_END, "2026-06-19"); 476 assert!(matches!( 477 calendar_date_event_from_event(KIND_CALENDAR_DATE_EVENT, &tags, ""), 478 Err(EventParseError::InvalidTag(TAG_END)) 479 )); 480 481 let mut tags = calendar_date_event_build_tags(&sample_date_event()).unwrap(); 482 replace_tag_value(&mut tags, TAG_D_DAY, "2026-6-20"); 483 assert!(matches!( 484 calendar_date_event_from_event(KIND_CALENDAR_DATE_EVENT, &tags, ""), 485 Err(EventParseError::InvalidTag(TAG_D_DAY)) 486 )); 487 488 let mut event = sample_date_event(); 489 event.title.clear(); 490 assert!(matches!( 491 calendar_date_event_build_tags(&event), 492 Err(EventEncodeError::EmptyRequiredField("title")) 493 )); 494 495 let mut event = sample_date_event(); 496 event.end = Some("2026-6-21".to_string()); 497 assert!(matches!( 498 calendar_date_event_build_tags(&event), 499 Err(EventEncodeError::InvalidField("end")) 500 )); 501 502 let mut event = sample_date_event(); 503 event.end = Some("2026-06-19".to_string()); 504 assert!(matches!( 505 calendar_date_event_build_tags(&event), 506 Err(EventEncodeError::InvalidField("end")) 507 )); 508 } 509 510 #[test] 511 fn calendar_time_codecs_cover_numeric_and_validation_edges() { 512 let mut tags = calendar_time_event_build_tags(&sample_time_event()).unwrap(); 513 replace_tag_value(&mut tags, TAG_START, "not-a-number"); 514 assert!(matches!( 515 calendar_time_event_from_event(KIND_CALENDAR_TIME_EVENT, &tags, ""), 516 Err(EventParseError::InvalidNumber(TAG_START, _)) 517 )); 518 519 let mut tags = calendar_time_event_build_tags(&sample_time_event()).unwrap(); 520 replace_tag_value(&mut tags, TAG_END, "not-a-number"); 521 assert!(matches!( 522 calendar_time_event_from_event(KIND_CALENDAR_TIME_EVENT, &tags, ""), 523 Err(EventParseError::InvalidNumber(TAG_END, _)) 524 )); 525 526 let mut tags = calendar_time_event_build_tags(&sample_time_event()).unwrap(); 527 replace_tag_value(&mut tags, TAG_D_DAY, "2026-6-20"); 528 assert!(matches!( 529 calendar_time_event_from_event(KIND_CALENDAR_TIME_EVENT, &tags, ""), 530 Err(EventParseError::InvalidTag(TAG_D_DAY)) 531 )); 532 533 let mut event = sample_time_event(); 534 event.d_tag = "bad".to_string(); 535 assert!(matches!( 536 calendar_time_event_build_tags(&event), 537 Err(EventEncodeError::InvalidField("d_tag")) 538 )); 539 540 let mut event = sample_time_event(); 541 event.title.clear(); 542 assert!(matches!( 543 calendar_time_event_build_tags(&event), 544 Err(EventEncodeError::EmptyRequiredField("title")) 545 )); 546 547 let mut event = sample_time_event(); 548 event.dates[0].value = "2026-6-20".to_string(); 549 assert!(matches!( 550 calendar_time_event_build_tags(&event), 551 Err(EventEncodeError::InvalidField("dates")) 552 )); 553 } 554 555 #[test] 556 fn calendar_collection_codecs_cover_address_edges() { 557 let tags = calendar_collection_build_tags(&sample_calendar_collection()).unwrap(); 558 let wrong_kind = calendar_from_event(KIND_POST, &tags, "").unwrap_err(); 559 assert!(matches!( 560 wrong_kind, 561 EventParseError::InvalidKind { 562 expected: "31924", 563 got: KIND_POST 564 } 565 )); 566 567 let mut calendar = sample_calendar_collection(); 568 calendar.summary = None; 569 calendar.image = None; 570 calendar.description = None; 571 if let RadrootsSocialTarget::Address { relays, .. } = &mut calendar.events[0] { 572 *relays = None; 573 } 574 let parts = calendar_to_wire_parts(&calendar).unwrap(); 575 assert_eq!(parts.content, ""); 576 let decoded = calendar_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); 577 assert!(decoded.description.is_none()); 578 assert!(matches!( 579 &decoded.events[0], 580 RadrootsSocialTarget::Address { relays: None, .. } 581 )); 582 583 let mut tags = calendar_collection_build_tags(&sample_calendar_collection()).unwrap(); 584 let address = tags 585 .iter_mut() 586 .find(|tag| tag.first().map(String::as_str) == Some(TAG_A)) 587 .expect("address tag"); 588 address.truncate(1); 589 assert!(matches!( 590 calendar_from_event(KIND_CALENDAR, &tags, ""), 591 Err(EventParseError::InvalidTag(TAG_A)) 592 )); 593 594 let mut tags = calendar_collection_build_tags(&sample_calendar_collection()).unwrap(); 595 replace_tag_value( 596 &mut tags, 597 TAG_A, 598 format!("{KIND_ARTICLE}:{EVENT_AUTHOR}:{EVENT_D_TAG}").as_str(), 599 ); 600 assert!(matches!( 601 calendar_from_event(KIND_CALENDAR, &tags, ""), 602 Err(EventParseError::InvalidTag(TAG_A)) 603 )); 604 605 let mut calendar = sample_calendar_collection(); 606 calendar.title.clear(); 607 assert!(matches!( 608 calendar_collection_build_tags(&calendar), 609 Err(EventEncodeError::EmptyRequiredField("title")) 610 )); 611 612 let mut calendar = sample_calendar_collection(); 613 calendar.events[0] = RadrootsSocialTarget::Event { 614 id: EVENT_ID.to_string(), 615 author: Some(EVENT_AUTHOR.to_string()), 616 event_kind: Some(KIND_CALENDAR_TIME_EVENT), 617 relays: None, 618 }; 619 assert!(matches!( 620 calendar_collection_build_tags(&calendar), 621 Err(EventEncodeError::InvalidField("events")) 622 )); 623 624 let mut calendar = sample_calendar_collection(); 625 if let RadrootsSocialTarget::Address { address, .. } = &mut calendar.events[0] { 626 *address = "not-an-address".to_string(); 627 } 628 assert!(matches!( 629 calendar_collection_build_tags(&calendar), 630 Err(EventEncodeError::InvalidField("events")) 631 )); 632 633 let mut calendar = sample_calendar_collection(); 634 if let RadrootsSocialTarget::Address { 635 address, 636 event_kind, 637 .. 638 } = &mut calendar.events[0] 639 { 640 *address = format!("{KIND_ARTICLE}:{EVENT_AUTHOR}:{EVENT_D_TAG}"); 641 *event_kind = Some(KIND_ARTICLE); 642 } 643 assert!(matches!( 644 calendar_collection_build_tags(&calendar), 645 Err(EventEncodeError::InvalidField("events")) 646 )); 647 } 648 649 #[test] 650 fn calendar_rsvp_codecs_cover_status_and_target_edges() { 651 let tags = rsvp_build_tags(&sample_rsvp()).unwrap(); 652 let wrong_kind = rsvp_from_event(KIND_POST, &tags, "").unwrap_err(); 653 assert!(matches!( 654 wrong_kind, 655 EventParseError::InvalidKind { 656 expected: "31925", 657 got: KIND_POST 658 } 659 )); 660 661 let mut rsvp = sample_rsvp(); 662 rsvp.status = RadrootsCalendarEventRsvpStatus::Declined; 663 rsvp.free_busy = Some(RadrootsCalendarEventFreeBusy::Free); 664 rsvp.event_id = None; 665 rsvp.note = None; 666 rsvp.participants = None; 667 if let RadrootsSocialTarget::Address { relays, .. } = &mut rsvp.event { 668 *relays = None; 669 } 670 let parts = rsvp_to_wire_parts(&rsvp).unwrap(); 671 assert_eq!(parts.content, ""); 672 assert!(!parts.tags.iter().any(|tag| { 673 tag.first().map(String::as_str) == Some(TAG_E) 674 || tag.first().map(String::as_str) == Some(TAG_P) 675 })); 676 let decoded = rsvp_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); 677 assert_eq!(decoded.status, RadrootsCalendarEventRsvpStatus::Declined); 678 assert_eq!(decoded.free_busy, Some(RadrootsCalendarEventFreeBusy::Free)); 679 assert!(decoded.note.is_none()); 680 681 let mut rsvp = sample_rsvp(); 682 rsvp.status = RadrootsCalendarEventRsvpStatus::Tentative; 683 let parts = rsvp_to_wire_parts(&rsvp).unwrap(); 684 assert!(has_tag(&parts.tags, TAG_STATUS, "tentative")); 685 let decoded = rsvp_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); 686 assert_eq!(decoded.status, RadrootsCalendarEventRsvpStatus::Tentative); 687 688 let mut rsvp = sample_rsvp(); 689 rsvp.event_id = Some("not-a-lowercase-hex-id".to_string()); 690 assert!(matches!( 691 rsvp_build_tags(&rsvp), 692 Err(EventEncodeError::InvalidField("event_id")) 693 )); 694 695 let mut tags = rsvp_build_tags(&sample_rsvp()).unwrap(); 696 replace_tag_value(&mut tags, TAG_E, "not-a-lowercase-hex-id"); 697 assert!(matches!( 698 rsvp_from_event(KIND_CALENDAR_EVENT_RSVP, &tags, ""), 699 Err(EventParseError::InvalidTag(TAG_E)) 700 )); 701 } 702 703 #[test] 704 fn calendar_wrappers_preserve_event_metadata() { 705 let date = sample_date_event(); 706 let date_parts = date_to_wire_parts(&date).unwrap(); 707 let date_data = date_data_from_event( 708 "date_id".to_string(), 709 "author".to_string(), 710 7, 711 date_parts.kind, 712 date_parts.content.clone(), 713 date_parts.tags.clone(), 714 ) 715 .unwrap(); 716 assert_eq!(date_data.kind, KIND_CALENDAR_DATE_EVENT); 717 assert_eq!(date_data.data.title, "CSA pickup"); 718 719 let err = date_parsed_from_event( 720 "date_id".to_string(), 721 "author".to_string(), 722 7, 723 KIND_POST, 724 date_parts.content.clone(), 725 date_parts.tags.clone(), 726 "sig".to_string(), 727 ) 728 .unwrap_err(); 729 assert!(matches!( 730 err, 731 EventParseError::InvalidKind { 732 expected: "31922", 733 got: KIND_POST 734 } 735 )); 736 737 let date_parsed = date_parsed_from_event( 738 "date_id".to_string(), 739 "author".to_string(), 740 7, 741 date_parts.kind, 742 date_parts.content, 743 date_parts.tags, 744 "sig".to_string(), 745 ) 746 .unwrap(); 747 assert_eq!(date_parsed.event.sig, "sig"); 748 749 let time = sample_time_event(); 750 let time_parts = time_to_wire_parts(&time).unwrap(); 751 let time_data = time_data_from_event( 752 "time_id".to_string(), 753 "author".to_string(), 754 8, 755 time_parts.kind, 756 time_parts.content.clone(), 757 time_parts.tags.clone(), 758 ) 759 .unwrap(); 760 assert_eq!(time_data.kind, KIND_CALENDAR_TIME_EVENT); 761 assert_eq!(time_data.data.title, "Wash pack shift"); 762 763 let err = time_parsed_from_event( 764 "time_id".to_string(), 765 "author".to_string(), 766 8, 767 KIND_POST, 768 time_parts.content.clone(), 769 time_parts.tags.clone(), 770 "sig".to_string(), 771 ) 772 .unwrap_err(); 773 assert!(matches!( 774 err, 775 EventParseError::InvalidKind { 776 expected: "31923", 777 got: KIND_POST 778 } 779 )); 780 781 let time_parsed = time_parsed_from_event( 782 "time_id".to_string(), 783 "author".to_string(), 784 8, 785 time_parts.kind, 786 time_parts.content, 787 time_parts.tags, 788 "sig".to_string(), 789 ) 790 .unwrap(); 791 assert_eq!(time_parsed.event.created_at, 8); 792 793 let calendar = sample_calendar_collection(); 794 let calendar_parts = calendar_to_wire_parts(&calendar).unwrap(); 795 let calendar_data = calendar_data_from_event( 796 "calendar_id".to_string(), 797 "author".to_string(), 798 9, 799 calendar_parts.kind, 800 calendar_parts.content.clone(), 801 calendar_parts.tags.clone(), 802 ) 803 .unwrap(); 804 assert_eq!(calendar_data.kind, KIND_CALENDAR); 805 assert_eq!(calendar_data.data.title, "Farm calendar"); 806 807 let err = calendar_parsed_from_event( 808 "calendar_id".to_string(), 809 "author".to_string(), 810 9, 811 KIND_POST, 812 calendar_parts.content.clone(), 813 calendar_parts.tags.clone(), 814 "sig".to_string(), 815 ) 816 .unwrap_err(); 817 assert!(matches!( 818 err, 819 EventParseError::InvalidKind { 820 expected: "31924", 821 got: KIND_POST 822 } 823 )); 824 825 let calendar_parsed = calendar_parsed_from_event( 826 "calendar_id".to_string(), 827 "author".to_string(), 828 9, 829 calendar_parts.kind, 830 calendar_parts.content, 831 calendar_parts.tags, 832 "sig".to_string(), 833 ) 834 .unwrap(); 835 assert_eq!(calendar_parsed.event.sig, "sig"); 836 837 let rsvp = sample_rsvp(); 838 let rsvp_parts = rsvp_to_wire_parts(&rsvp).unwrap(); 839 let rsvp_data = rsvp_data_from_event( 840 "rsvp_id".to_string(), 841 "author".to_string(), 842 10, 843 rsvp_parts.kind, 844 rsvp_parts.content.clone(), 845 rsvp_parts.tags.clone(), 846 ) 847 .unwrap(); 848 assert_eq!(rsvp_data.kind, KIND_CALENDAR_EVENT_RSVP); 849 assert_eq!(rsvp_data.data.event_id.as_deref(), Some(EVENT_ID)); 850 851 let err = rsvp_parsed_from_event( 852 "rsvp_id".to_string(), 853 "author".to_string(), 854 10, 855 KIND_POST, 856 rsvp_parts.content.clone(), 857 rsvp_parts.tags.clone(), 858 "sig".to_string(), 859 ) 860 .unwrap_err(); 861 assert!(matches!( 862 err, 863 EventParseError::InvalidKind { 864 expected: "31925", 865 got: KIND_POST 866 } 867 )); 868 869 let rsvp_parsed = rsvp_parsed_from_event( 870 "rsvp_id".to_string(), 871 "author".to_string(), 872 10, 873 rsvp_parts.kind, 874 rsvp_parts.content, 875 rsvp_parts.tags, 876 "sig".to_string(), 877 ) 878 .unwrap(); 879 assert_eq!(rsvp_parsed.event.created_at, 10); 880 }