lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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 }