report.rs
| 1 | //! REPORT parsing, query filters, calendar-data rendering and free-busy. |
| 2 | |
| 3 | use chrono::{DateTime, Utc}; |
| 4 | use pimdav::calcard::icalendar::ICalendar; |
| 5 | use pimdav::calcard::vcard::VCard; |
| 6 | use pimdav::filter::{matches_calendar, matches_card}; |
| 7 | use pimdav::freebusy::{Busy, busy, merge}; |
| 8 | use pimdav::render::{AddressData, address_data, calendar_data}; |
| 9 | use pimdav::report::{Refused, Report, parse}; |
| 10 | use pimdav::xml::{CALDAV, Name}; |
| 11 | use pimdav::zone::{Zone, named}; |
| 12 | |
| 13 | fn utc(s: &str) -> DateTime<Utc> { |
| 14 | format!("{s}Z").parse().unwrap() |
| 15 | } |
| 16 | |
| 17 | fn cal(body: &str) -> ICalendar { |
| 18 | ICalendar::parse(format!( |
| 19 | "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{body}END:VCALENDAR\r\n" |
| 20 | )) |
| 21 | .unwrap() |
| 22 | } |
| 23 | |
| 24 | fn query(filter: &str) -> String { |
| 25 | format!( |
| 26 | r#"<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"> |
| 27 | <d:prop><d:getetag/></d:prop> |
| 28 | <c:filter><c:comp-filter name="VCALENDAR">{filter}</c:comp-filter></c:filter> |
| 29 | </c:calendar-query>"# |
| 30 | ) |
| 31 | } |
| 32 | |
| 33 | /// Whether `body` matches `filter` inside the VCALENDAR comp-filter. |
| 34 | fn hit(body: &str, filter: &str) -> bool { |
| 35 | let Ok(Report::CalendarQuery { filter, .. }) = parse(query(filter).as_bytes()) else { |
| 36 | panic!("bad query {filter}"); |
| 37 | }; |
| 38 | matches_calendar(&cal(body), &filter, &Zone::Utc) |
| 39 | } |
| 40 | |
| 41 | fn range(comp: &str, start: &str, end: &str) -> String { |
| 42 | format!( |
| 43 | r#"<c:comp-filter name="{comp}"><c:time-range start="{start}" end="{end}"/></c:comp-filter>"# |
| 44 | ) |
| 45 | } |
| 46 | |
| 47 | #[test] |
| 48 | fn todo_time_ranges() { |
| 49 | let day = |comp| range(comp, "20260110T000000Z", "20260111T000000Z"); |
| 50 | let todo = |props: &str| format!("BEGIN:VTODO\r\nUID:t\r\n{props}END:VTODO\r\n"); |
| 51 | assert!(hit(&todo("DUE:20260110T170000Z\r\n"), &day("VTODO"))); |
| 52 | // RFC 4791 counts a DUE on the range end as inside. |
| 53 | assert!(hit(&todo("DUE:20260111T000000Z\r\n"), &day("VTODO"))); |
| 54 | assert!(!hit(&todo("DUE:20260111T000001Z\r\n"), &day("VTODO"))); |
| 55 | assert!(hit(&todo("COMPLETED:20260110T100000Z\r\n"), &day("VTODO"))); |
| 56 | assert!(hit(&todo("CREATED:20260101T000000Z\r\n"), &day("VTODO"))); |
| 57 | assert!(!hit(&todo("CREATED:20260201T000000Z\r\n"), &day("VTODO"))); |
| 58 | assert!(hit(&todo(""), &day("VTODO"))); |
| 59 | // DTSTART with DUE spanning the range. |
| 60 | assert!(hit( |
| 61 | &todo("DTSTART:20260101T000000Z\r\nDUE:20260201T000000Z\r\n"), |
| 62 | &day("VTODO") |
| 63 | )); |
| 64 | assert!(!hit(&todo("DTSTART:20260111T000000Z\r\n"), &day("VTODO"))); |
| 65 | } |
| 66 | |
| 67 | #[test] |
| 68 | fn alarms_fire_per_instance() { |
| 69 | let body = "BEGIN:VEVENT\r\nUID:a\r\nDTSTART:20260105T090000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=DAILY\r\n\ |
| 70 | BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\nEND:VEVENT\r\n"; |
| 71 | let alarm = |start, end| { |
| 72 | format!( |
| 73 | r#"<c:comp-filter name="VEVENT">{}</c:comp-filter>"#, |
| 74 | range("VALARM", start, end) |
| 75 | ) |
| 76 | }; |
| 77 | assert!(hit(body, &alarm("20260110T084000Z", "20260110T085000Z"))); |
| 78 | assert!(!hit(body, &alarm("20260110T085000Z", "20260110T090000Z"))); |
| 79 | let from_end = body.replace("TRIGGER:-PT15M", "TRIGGER;RELATED=END:PT5M"); |
| 80 | assert!(hit( |
| 81 | &from_end, |
| 82 | &alarm("20260110T100000Z", "20260110T101000Z") |
| 83 | )); |
| 84 | } |
| 85 | |
| 86 | #[test] |
| 87 | fn freebusy_components_and_properties() { |
| 88 | let fb = "BEGIN:VFREEBUSY\r\nUID:f\r\nDTSTART:20260101T000000Z\r\nDTEND:20260102T000000Z\r\n\ |
| 89 | FREEBUSY:20260101T100000Z/PT1H\r\nEND:VFREEBUSY\r\n"; |
| 90 | assert!(hit( |
| 91 | fb, |
| 92 | &range("VFREEBUSY", "20260101T103000Z", "20260101T120000Z") |
| 93 | )); |
| 94 | assert!(!hit( |
| 95 | fb, |
| 96 | &range("VFREEBUSY", "20260101T120000Z", "20260101T130000Z") |
| 97 | )); |
| 98 | |
| 99 | let event = "BEGIN:VEVENT\r\nUID:e\r\nDTSTART;TZID=Europe/Berlin:20260101T100000\r\n\ |
| 100 | ATTENDEE;PARTSTAT=ACCEPTED;CN=\"Bob, B\":mailto:bob@example.com\r\nEND:VEVENT\r\n"; |
| 101 | let prop = |inner: &str| format!(r#"<c:comp-filter name="VEVENT">{inner}</c:comp-filter>"#); |
| 102 | let partstat = r#"<c:prop-filter name="ATTENDEE"><c:param-filter name="PARTSTAT"><c:text-match collation="i;octet">ACCEPTED</c:text-match></c:param-filter></c:prop-filter>"#; |
| 103 | assert!(hit(event, &prop(partstat))); |
| 104 | let cn = r#"<c:prop-filter name="ATTENDEE"><c:param-filter name="CN"><c:text-match>bob, b</c:text-match></c:param-filter></c:prop-filter>"#; |
| 105 | assert!(hit(event, &prop(cn))); |
| 106 | let missing = r#"<c:prop-filter name="LOCATION"><c:is-not-defined/></c:prop-filter>"#; |
| 107 | assert!(hit(event, &prop(missing))); |
| 108 | let dtstart = r#"<c:prop-filter name="DTSTART"><c:time-range start="20260101T090000Z" end="20260101T090001Z"/></c:prop-filter>"#; |
| 109 | assert!(hit(event, &prop(dtstart))); |
| 110 | let no_todo = r#"<c:comp-filter name="VTODO"><c:is-not-defined/></c:comp-filter>"#; |
| 111 | assert!(hit(event, no_todo)); |
| 112 | } |
| 113 | |
| 114 | #[test] |
| 115 | fn floating_times_use_the_query_zone() { |
| 116 | let body = |
| 117 | "BEGIN:VEVENT\r\nUID:f\r\nDTSTART:20260101T100000\r\nDURATION:PT1H\r\nEND:VEVENT\r\n"; |
| 118 | let filter = range("VEVENT", "20260101T090000Z", "20260101T093000Z"); |
| 119 | let Ok(Report::CalendarQuery { filter, .. }) = parse(query(&filter).as_bytes()) else { |
| 120 | panic!(); |
| 121 | }; |
| 122 | assert!(!matches_calendar(&cal(body), &filter, &Zone::Utc)); |
| 123 | let berlin = named("Europe/Berlin").unwrap(); |
| 124 | assert!(matches_calendar(&cal(body), &filter, &berlin)); |
| 125 | } |
| 126 | |
| 127 | #[test] |
| 128 | fn report_bodies() { |
| 129 | let body = br#"<d:sync-collection xmlns:d="DAV:"><d:sync-token/><d:sync-level>1</d:sync-level> |
| 130 | <d:limit><d:nresults>5</d:nresults></d:limit><d:prop><d:getetag/></d:prop></d:sync-collection>"#; |
| 131 | let Ok(Report::SyncCollection { token, limit, .. }) = parse(body) else { |
| 132 | panic!(); |
| 133 | }; |
| 134 | assert_eq!((token.as_str(), limit), ("", Some(5))); |
| 135 | |
| 136 | let body = br#"<d:sync-collection xmlns:d="DAV:"><d:sync-token/><d:sync-level>1</d:sync-level> |
| 137 | <d:limit><d:nresults>0</d:nresults></d:limit><d:prop><d:getetag/></d:prop></d:sync-collection>"#; |
| 138 | let Ok(Report::SyncCollection { limit, .. }) = parse(body) else { |
| 139 | panic!(); |
| 140 | }; |
| 141 | assert_eq!(limit, None); |
| 142 | |
| 143 | let body = br#"<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav"><c:filter><c:comp-filter name="VEVENT"/></c:filter></c:calendar-query>"#; |
| 144 | assert_eq!( |
| 145 | parse(body).unwrap_err(), |
| 146 | Refused::Condition(Name::new(CALDAV, "valid-filter")) |
| 147 | ); |
| 148 | let body = br#"<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav"><c:timezone-id>Nowhere/Else</c:timezone-id><c:filter><c:comp-filter name="VCALENDAR"/></c:filter></c:calendar-query>"#; |
| 149 | assert_eq!( |
| 150 | parse(body).unwrap_err(), |
| 151 | Refused::Condition(Name::new(CALDAV, "valid-timezone")) |
| 152 | ); |
| 153 | let body = br#"<c:free-busy-query xmlns:c="urn:ietf:params:xml:ns:caldav"><c:time-range start="20260101T000000Z"/></c:free-busy-query>"#; |
| 154 | assert_eq!(parse(body).unwrap_err(), Refused::Invalid); |
| 155 | assert!(matches!( |
| 156 | parse(b"<x:nope xmlns:x=\"urn:x\"/>"), |
| 157 | Err(Refused::Condition(_)) |
| 158 | )); |
| 159 | } |
| 160 | |
| 161 | #[test] |
| 162 | fn expand_keeps_dates_and_limit_keeps_used_overrides() { |
| 163 | let body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\ |
| 164 | BEGIN:VEVENT\r\nUID:d\r\nDTSTART;VALUE=DATE:20260101\r\nRRULE:FREQ=DAILY;COUNT=5\r\nEND:VEVENT\r\n\ |
| 165 | BEGIN:VEVENT\r\nUID:d\r\nRECURRENCE-ID;VALUE=DATE:20260102\r\nDTSTART;VALUE=DATE:20260102\r\nSUMMARY:moved\r\nEND:VEVENT\r\n\ |
| 166 | BEGIN:VEVENT\r\nUID:d\r\nRECURRENCE-ID;VALUE=DATE:20260104\r\nDTSTART;VALUE=DATE:20260104\r\nSUMMARY:later\r\nEND:VEVENT\r\n\ |
| 167 | END:VCALENDAR\r\n"; |
| 168 | let req = |inner: &str| { |
| 169 | let xml = format!( |
| 170 | r#"<c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><c:calendar-data>{inner}</c:calendar-data></d:prop></c:calendar-multiget>"# |
| 171 | ); |
| 172 | let Ok(Report::CalendarMultiget { props, .. }) = parse(xml.as_bytes()) else { |
| 173 | panic!(); |
| 174 | }; |
| 175 | props.calendar.unwrap() |
| 176 | }; |
| 177 | let out = calendar_data( |
| 178 | body, |
| 179 | &req(r#"<c:expand start="20260102T000000Z" end="20260104T000000Z"/>"#), |
| 180 | &Zone::Utc, |
| 181 | ) |
| 182 | .unwrap() |
| 183 | .0; |
| 184 | assert!(out.contains("DTSTART;VALUE=DATE:20260102"), "{out}"); |
| 185 | assert!(out.contains("RECURRENCE-ID;VALUE=DATE:20260103"), "{out}"); |
| 186 | assert!(!out.contains("RRULE") && !out.contains("20260104"), "{out}"); |
| 187 | |
| 188 | let out = calendar_data( |
| 189 | body, |
| 190 | &req(r#"<c:limit-recurrence-set start="20260102T000000Z" end="20260103T000000Z"/>"#), |
| 191 | &Zone::Utc, |
| 192 | ) |
| 193 | .unwrap() |
| 194 | .0; |
| 195 | assert!( |
| 196 | out.contains("RRULE") && out.contains("moved") && !out.contains("later"), |
| 197 | "{out}" |
| 198 | ); |
| 199 | } |
| 200 | |
| 201 | #[test] |
| 202 | fn address_data_converts_versions() { |
| 203 | let card = |
| 204 | "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c\r\nFN:Bob\r\nTEL;TYPE=CELL:123\r\nEND:VCARD\r\n"; |
| 205 | let same = AddressData::default(); |
| 206 | assert_eq!(address_data(card, &same), card); |
| 207 | let v4 = AddressData { |
| 208 | props: None, |
| 209 | version: Some(pimdav::calcard::vcard::VCardVersion::V4_0), |
| 210 | }; |
| 211 | assert!(address_data(card, &v4).contains("VERSION:4.0")); |
| 212 | } |
| 213 | |
| 214 | #[test] |
| 215 | fn groups_and_companies_in_apple_forms() { |
| 216 | use pimdav::calcard::vcard::VCardVersion::{V3_0, V4_0}; |
| 217 | let to = |v| AddressData { |
| 218 | props: None, |
| 219 | version: Some(v), |
| 220 | }; |
| 221 | let group = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:g\r\nFN:Team\r\nKIND:group\r\n\ |
| 222 | MEMBER:urn:uuid:m1\r\nEMAIL;PREF=1;TYPE=work:t@example.com\r\nEND:VCARD\r\n"; |
| 223 | let v3 = address_data(group, &to(V3_0)); |
| 224 | assert!(v3.contains("VERSION:3.0"), "{v3}"); |
| 225 | assert!(v3.contains("X-ADDRESSBOOKSERVER-KIND:group\r\n"), "{v3}"); |
| 226 | assert!( |
| 227 | v3.contains("X-ADDRESSBOOKSERVER-MEMBER:urn:uuid:m1\r\n"), |
| 228 | "{v3}" |
| 229 | ); |
| 230 | assert!( |
| 231 | !v3.contains("\r\nKIND") && !v3.contains("\r\nMEMBER"), |
| 232 | "{v3}" |
| 233 | ); |
| 234 | assert!(v3.contains("TYPE=pref") && !v3.contains("PREF=1"), "{v3}"); |
| 235 | |
| 236 | // Back to 4.0, as a client asking for it gets an Apple-made card. |
| 237 | let back = address_data(&v3, &to(V4_0)); |
| 238 | assert!( |
| 239 | back.to_ascii_lowercase().contains("\r\nkind:group\r\n"), |
| 240 | "{back}" |
| 241 | ); |
| 242 | assert!(back.contains("MEMBER:urn:uuid:m1\r\n"), "{back}"); |
| 243 | assert!( |
| 244 | back.contains("PREF=1") && !back.contains("X-ADDRESSBOOKSERVER"), |
| 245 | "{back}" |
| 246 | ); |
| 247 | |
| 248 | let ranked = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:r\r\nFN:R\r\n\ |
| 249 | EMAIL;PREF=3:c@example.com\r\nEMAIL;PREF=2:b@example.com\r\nEND:VCARD\r\n"; |
| 250 | let v3 = address_data(ranked, &to(V3_0)); |
| 251 | assert!(v3.contains("EMAIL;TYPE=pref:b@example.com"), "{v3}"); |
| 252 | assert!(v3.contains("EMAIL:c@example.com"), "{v3}"); |
| 253 | |
| 254 | let org = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:o\r\nFN:ACME\r\nKIND:org\r\nEND:VCARD\r\n"; |
| 255 | assert!(address_data(org, &to(V3_0)).contains("X-ABSHOWAS:COMPANY\r\n")); |
| 256 | let apple = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:o\r\nFN:ACME\r\nX-ABSHOWAS:COMPANY\r\n\ |
| 257 | TEL;TYPE=\"CELL,pref\":1\r\nEND:VCARD\r\n"; |
| 258 | let v4 = address_data(apple, &to(V4_0)); |
| 259 | assert!(v4.to_ascii_lowercase().contains("\r\nkind:org\r\n"), "{v4}"); |
| 260 | assert!(v4.contains("TYPE=CELL") && v4.contains("PREF=1"), "{v4}"); |
| 261 | } |
| 262 | |
| 263 | #[test] |
| 264 | fn vcard_three_is_the_default() { |
| 265 | use pimdav::calcard::vcard::VCardVersion::{V3_0, V4_0}; |
| 266 | use pimdav::render::{accepted_version, address_request}; |
| 267 | use pimdav::xml::{CARDDAV, el}; |
| 268 | assert_eq!( |
| 269 | address_request(&el(CARDDAV, "address-data")) |
| 270 | .unwrap() |
| 271 | .version, |
| 272 | Some(V3_0) |
| 273 | ); |
| 274 | assert_eq!(accepted_version(None), V3_0); |
| 275 | assert_eq!(accepted_version(Some("text/vcard")), V3_0); |
| 276 | assert_eq!(accepted_version(Some("text/vcard; version=4.0")), V4_0); |
| 277 | } |
| 278 | |
| 279 | #[test] |
| 280 | fn card_filters() { |
| 281 | let card = VCard::parse( |
| 282 | "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Zoë\r\nEMAIL:z@example.com\r\nEND:VCARD\r\n", |
| 283 | ) |
| 284 | .unwrap(); |
| 285 | let parse_filter = |f: &str| { |
| 286 | let xml = format!( |
| 287 | r#"<card:addressbook-query xmlns:card="urn:ietf:params:xml:ns:carddav">{f}</card:addressbook-query>"# |
| 288 | ); |
| 289 | let Ok(Report::AddressbookQuery { filter, .. }) = parse(xml.as_bytes()) else { |
| 290 | panic!("{f}"); |
| 291 | }; |
| 292 | filter |
| 293 | }; |
| 294 | let f = parse_filter( |
| 295 | r#"<card:filter><card:prop-filter name="FN"><card:text-match match-type="starts-with">ZO</card:text-match></card:prop-filter></card:filter>"#, |
| 296 | ); |
| 297 | assert!(matches_card(&card, &f)); |
| 298 | let f = parse_filter( |
| 299 | r#"<card:filter><card:prop-filter name="FN"><card:text-match collation="i;octet">zoë</card:text-match></card:prop-filter></card:filter>"#, |
| 300 | ); |
| 301 | assert!(!matches_card(&card, &f)); |
| 302 | let f = parse_filter( |
| 303 | r#"<card:filter test="allof"><card:prop-filter name="FN"/><card:prop-filter name="TEL"/></card:filter>"#, |
| 304 | ); |
| 305 | assert!(!matches_card(&card, &f)); |
| 306 | let f = parse_filter( |
| 307 | r#"<card:filter><card:prop-filter name="FN"/><card:prop-filter name="TEL"/></card:filter>"#, |
| 308 | ); |
| 309 | assert!(matches_card(&card, &f)); |
| 310 | } |
| 311 | |
| 312 | #[test] |
| 313 | fn busy_time() { |
| 314 | let body = "BEGIN:VEVENT\r\nUID:a\r\nDTSTART:20260101T100000Z\r\nDTEND:20260101T110000Z\r\nEND:VEVENT\r\n\ |
| 315 | BEGIN:VEVENT\r\nUID:b\r\nDTSTART:20260101T103000Z\r\nDTEND:20260101T120000Z\r\nEND:VEVENT\r\n\ |
| 316 | BEGIN:VEVENT\r\nUID:c\r\nDTSTART:20260101T130000Z\r\nDTEND:20260101T140000Z\r\nSTATUS:TENTATIVE\r\nEND:VEVENT\r\n\ |
| 317 | BEGIN:VEVENT\r\nUID:d\r\nDTSTART:20260101T150000Z\r\nDTEND:20260101T160000Z\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\n\ |
| 318 | BEGIN:VEVENT\r\nUID:e\r\nDTSTART:20260101T170000Z\r\nDTEND:20260101T180000Z\r\nSTATUS:CANCELLED\r\nEND:VEVENT\r\n"; |
| 319 | let range = utc("2026-01-01T00:00:00")..utc("2026-01-01T13:30:00"); |
| 320 | let got = merge(busy(&cal(body), &range, &Zone::Utc, None)); |
| 321 | let got: Vec<_> = got.iter().map(|p| (p.kind, p.start, p.end)).collect(); |
| 322 | assert_eq!( |
| 323 | got, |
| 324 | [ |
| 325 | ( |
| 326 | Busy::Busy, |
| 327 | utc("2026-01-01T10:00:00"), |
| 328 | utc("2026-01-01T12:00:00") |
| 329 | ), |
| 330 | ( |
| 331 | Busy::Tentative, |
| 332 | utc("2026-01-01T13:00:00"), |
| 333 | utc("2026-01-01T13:30:00") |
| 334 | ), |
| 335 | ] |
| 336 | ); |
| 337 | } |
| 338 | |
| 339 | #[test] |
| 340 | fn unicode_casemap_folds_after_nfkd() { |
| 341 | use pimdav::filter::{Collation, MatchType, TextMatch}; |
| 342 | let m = |text: &str, collation| TextMatch { |
| 343 | text: text.to_string(), |
| 344 | collation, |
| 345 | match_type: MatchType::Equals, |
| 346 | negate: false, |
| 347 | }; |
| 348 | let unicode = Collation::UnicodeCasemap; |
| 349 | assert!(m("Müller", unicode).matches("Mu\u{0308}ller")); |
| 350 | assert!(m("file", unicode).matches("FILE")); |
| 351 | assert!(m("ix", unicode).matches("\u{2168}")); |
| 352 | assert!(m("é", Collation::AsciiCasemap).matches("é")); |
| 353 | assert!(!m("é", Collation::AsciiCasemap).matches("É")); |
| 354 | } |
| 355 | |
| 356 | #[test] |
| 357 | fn bad_filters_are_valid_filter_conditions() { |
| 358 | let bad_range = query( |
| 359 | r#"<c:comp-filter name="VEVENT"><c:time-range start="20260102T000000Z" end="20260101T000000Z"/></c:comp-filter>"#, |
| 360 | ); |
| 361 | assert!(matches!( |
| 362 | parse(bad_range.as_bytes()), |
| 363 | Err(Refused::Condition(n)) if n == Name::new(CALDAV, "valid-filter") |
| 364 | )); |
| 365 | let bad_test = r#"<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"> |
| 366 | <card:filter test="sometimes"/></card:addressbook-query>"#; |
| 367 | assert!(matches!( |
| 368 | parse(bad_test.as_bytes()), |
| 369 | Err(Refused::Condition(n)) if n == Name::new(pimdav::xml::CARDDAV, "valid-filter") |
| 370 | )); |
| 371 | } |
| 372 | |
| 373 | #[test] |
| 374 | fn busy_time_follows_own_answer() { |
| 375 | const ME: &str = "mailto:room@rooms.dovenest.invalid"; |
| 376 | let me = |a: &str| a.eq_ignore_ascii_case(ME); |
| 377 | let body = format!( |
| 378 | "BEGIN:VEVENT\r\nUID:a\r\nDTSTART:20260101T100000Z\r\nDTEND:20260101T110000Z\r\nATTENDEE;PARTSTAT=DECLINED:{ME}\r\nEND:VEVENT\r\n\ |
| 379 | BEGIN:VEVENT\r\nUID:b\r\nDTSTART:20260101T120000Z\r\nDTEND:20260101T130000Z\r\nATTENDEE:{ME}\r\nEND:VEVENT\r\n\ |
| 380 | BEGIN:VEVENT\r\nUID:c\r\nDTSTART:20260101T140000Z\r\nDTEND:20260101T150000Z\r\nATTENDEE;PARTSTAT=ACCEPTED:{ME}\r\nEND:VEVENT\r\n" |
| 381 | ); |
| 382 | let range = utc("2026-01-01T00:00:00")..utc("2026-01-02T00:00:00"); |
| 383 | let got: Vec<_> = merge(busy(&cal(&body), &range, &Zone::Utc, Some(&me))) |
| 384 | .iter() |
| 385 | .map(|p| (p.kind, p.start)) |
| 386 | .collect(); |
| 387 | assert_eq!( |
| 388 | got, |
| 389 | [ |
| 390 | (Busy::Busy, utc("2026-01-01T14:00:00")), |
| 391 | (Busy::Tentative, utc("2026-01-01T12:00:00")), |
| 392 | ] |
| 393 | ); |
| 394 | // Without a calendar user, the answers do not count (RFC 4791). |
| 395 | assert_eq!(merge(busy(&cal(&body), &range, &Zone::Utc, None)).len(), 3); |
| 396 | } |
| 397 | |
| 398 | #[test] |
| 399 | fn outbox_free_busy_request() { |
| 400 | use pimdav::freebusy::{reply, request, schedule_response}; |
| 401 | let body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nMETHOD:REQUEST\r\n\ |
| 402 | BEGIN:VFREEBUSY\r\nUID:fb-1\r\nDTSTAMP:20260101T000000Z\r\n\ |
| 403 | DTSTART:20260105T000000Z\r\nDTEND:20260106T000000Z\r\n\ |
| 404 | ORGANIZER:mailto:alice@dovenest.invalid\r\n\ |
| 405 | ATTENDEE:mailto:bob@dovenest.invalid\r\n\ |
| 406 | ATTENDEE:mailto:BOB@dovenest.invalid\r\n\ |
| 407 | ATTENDEE:mailto:dave@example.com\r\n\ |
| 408 | END:VFREEBUSY\r\nEND:VCALENDAR\r\n"; |
| 409 | let req = request(body.as_bytes()).unwrap(); |
| 410 | assert_eq!( |
| 411 | req.range, |
| 412 | utc("2026-01-05T00:00:00")..utc("2026-01-06T00:00:00") |
| 413 | ); |
| 414 | assert_eq!(req.organizer, "mailto:alice@dovenest.invalid"); |
| 415 | assert_eq!( |
| 416 | req.attendees, |
| 417 | ["mailto:bob@dovenest.invalid", "mailto:dave@example.com"] |
| 418 | ); |
| 419 | |
| 420 | let busy = [pimdav::freebusy::Period { |
| 421 | kind: Busy::Busy, |
| 422 | start: utc("2026-01-05T10:00:00"), |
| 423 | end: utc("2026-01-05T11:00:00"), |
| 424 | }]; |
| 425 | let text = reply(&busy, &req, &req.attendees[0], utc("2026-01-01T00:00:00")); |
| 426 | let got = ICalendar::parse(&text).unwrap().to_string(); |
| 427 | for line in [ |
| 428 | "METHOD:REPLY", |
| 429 | "UID:fb-1", |
| 430 | "ATTENDEE:mailto:bob@dovenest.invalid", |
| 431 | "FREEBUSY;FBTYPE=BUSY:20260105T100000Z/20260105T110000Z", |
| 432 | ] { |
| 433 | assert!(got.contains(line), "{line} in {got}"); |
| 434 | } |
| 435 | |
| 436 | let xml = schedule_response(&[( |
| 437 | req.attendees[1].clone(), |
| 438 | "5.2;Invalid calendar service", |
| 439 | None, |
| 440 | )]); |
| 441 | assert!(xml.contains("<c:schedule-response"), "{xml}"); |
| 442 | assert!(xml.contains("<c:request-status>5.2;Invalid calendar service</c:request-status>")); |
| 443 | |
| 444 | // Not a request, or a request without the parts it needs. |
| 445 | for bad in [ |
| 446 | body.replace("METHOD:REQUEST", "METHOD:PUBLISH"), |
| 447 | body.replace("DTEND:20260106T000000Z\r\n", ""), |
| 448 | body.replace("ORGANIZER:mailto:alice@dovenest.invalid\r\n", ""), |
| 449 | ] { |
| 450 | let err = request(bad.as_bytes()).unwrap_err(); |
| 451 | assert!(err.is(CALDAV, "valid-scheduling-message"), "{bad}"); |
| 452 | } |
| 453 | } |
| 454 | |
| 455 | #[test] |
| 456 | fn text_match_ignores_pretty_printing() { |
| 457 | let body = |
| 458 | "BEGIN:VEVENT\r\nUID:a\r\nDTSTART:20240101T100000Z\r\nSUMMARY:John Smith\r\nEND:VEVENT\r\n"; |
| 459 | let filter = r#"<c:comp-filter name="VEVENT"><c:prop-filter name="SUMMARY"> |
| 460 | <c:text-match match-type="equals"> |
| 461 | John Smith |
| 462 | </c:text-match></c:prop-filter></c:comp-filter>"#; |
| 463 | assert!(hit(body, filter)); |
| 464 | } |
| 465 | |
| 466 | #[test] |
| 467 | fn many_overrides_expand_once_per_query() { |
| 468 | let mut body = String::from( |
| 469 | "BEGIN:VEVENT\r\nUID:s\r\nDTSTART:20240101T100000Z\r\nRRULE:FREQ=DAILY\r\nBEGIN:VALARM\r\nTRIGGER:-PT15M\r\nACTION:DISPLAY\r\nEND:VALARM\r\nEND:VEVENT\r\n", |
| 470 | ); |
| 471 | for d in 0..2000 { |
| 472 | let day = chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap() + chrono::Days::new(d); |
| 473 | let t = day.format("%Y%m%dT100000Z"); |
| 474 | body.push_str(&format!( |
| 475 | "BEGIN:VEVENT\r\nUID:s\r\nRECURRENCE-ID:{t}\r\nDTSTART:{t}\r\nSUMMARY:{d}\r\nBEGIN:VALARM\r\nTRIGGER:-PT{}M\r\nACTION:DISPLAY\r\nEND:VALARM\r\nEND:VEVENT\r\n", |
| 476 | d % 50 + 1 |
| 477 | )); |
| 478 | } |
| 479 | let started = std::time::Instant::now(); |
| 480 | assert!(!hit( |
| 481 | &body, |
| 482 | &range("VEVENT", "19990101T000000Z", "19990102T000000Z") |
| 483 | )); |
| 484 | let alarm = r#"<c:comp-filter name="VEVENT"><c:comp-filter name="VALARM"><c:time-range start="19990101T000000Z" end="19990102T000000Z"/></c:comp-filter></c:comp-filter>"#; |
| 485 | assert!(!hit(&body, alarm)); |
| 486 | assert!( |
| 487 | started.elapsed() < std::time::Duration::from_secs(2), |
| 488 | "{:?}", |
| 489 | started.elapsed() |
| 490 | ); |
| 491 | } |
| 492 | |
| 493 | #[test] |
| 494 | fn an_open_range_on_an_endless_rule_needs_no_expansion() { |
| 495 | let body = "BEGIN:VEVENT\r\nUID:e\r\nDTSTART:20000101T090000Z\r\nDURATION:PT1H\r\nRRULE:FREQ=MINUTELY\r\n\ |
| 496 | BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\nEND:VEVENT\r\n"; |
| 497 | let open = r#"<c:time-range start="20300101T000000Z"/>"#; |
| 498 | let event = format!(r#"<c:comp-filter name="VEVENT">{open}</c:comp-filter>"#); |
| 499 | let alarm = format!( |
| 500 | r#"<c:comp-filter name="VEVENT"><c:comp-filter name="VALARM">{open}</c:comp-filter></c:comp-filter>"# |
| 501 | ); |
| 502 | let t = std::time::Instant::now(); |
| 503 | assert!(hit(body, &event)); |
| 504 | assert!(hit(body, &alarm)); |
| 505 | assert!( |
| 506 | t.elapsed() < std::time::Duration::from_millis(500), |
| 507 | "{:?}", |
| 508 | t.elapsed() |
| 509 | ); |
| 510 | } |
| 511 | |
| 512 | #[test] |
| 513 | fn a_photo_data_uri_survives_the_conversion_to_4_0() { |
| 514 | let card = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:p\r\nFN:Pic\r\n\ |
| 515 | PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRg==\r\nEND:VCARD\r\n"; |
| 516 | let v4 = AddressData { |
| 517 | props: None, |
| 518 | version: Some(pimdav::calcard::vcard::VCardVersion::V4_0), |
| 519 | }; |
| 520 | let out = address_data(card, &v4).replace("\r\n ", ""); |
| 521 | assert!(out.contains("data:image/jpeg;base64,/9j/"), "{out}"); |
| 522 | assert!(!out.contains("\\,"), "{out}"); |
| 523 | } |
| 524 | |
| 525 | #[test] |
| 526 | fn a_photo_without_type_gets_its_media_type_in_4_0() { |
| 527 | let v4 = AddressData { |
| 528 | props: None, |
| 529 | version: Some(pimdav::calcard::vcard::VCardVersion::V4_0), |
| 530 | }; |
| 531 | for (data, mime) in [ |
| 532 | ( |
| 533 | "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABpfZFQAAAAABJRU5ErkJggg==", |
| 534 | "image/png", |
| 535 | ), |
| 536 | ("/9j/4AAQSkZJRgABAQ==", "image/jpeg"), |
| 537 | ( |
| 538 | "R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==", |
| 539 | "image/gif", |
| 540 | ), |
| 541 | ( |
| 542 | "UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==", |
| 543 | "image/webp", |
| 544 | ), |
| 545 | ] { |
| 546 | let card = format!( |
| 547 | "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:p\r\nFN:Pic\r\nPHOTO;ENCODING=b:{data}\r\nEND:VCARD\r\n" |
| 548 | ); |
| 549 | let out = address_data(&card, &v4).replace("\r\n ", ""); |
| 550 | assert!(out.contains(&format!("data:{mime};base64,{data}")), "{out}"); |
| 551 | } |
| 552 | // A stored 4.0 card in upper case is sniffed too. |
| 553 | let card = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:p\r\nFN:Pic\r\nPHOTO:DATA:;BASE64,/9j/4AAQSkZJRgABAQ==\r\nEND:VCARD\r\n"; |
| 554 | let photo = AddressData { |
| 555 | props: Some(vec![pimdav::render::PropSelect { |
| 556 | name: "PHOTO".into(), |
| 557 | novalue: false, |
| 558 | }]), |
| 559 | ..v4 |
| 560 | }; |
| 561 | let out = address_data(card, &photo).replace("\r\n ", ""); |
| 562 | assert!(out.contains("data:image/jpeg;base64,/9j/"), "{out}"); |
| 563 | } |
| 564 | |
| 565 | #[test] |
| 566 | fn expand_keeps_a_task_without_start() { |
| 567 | let raw = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:t\r\nDUE:20260105T100000Z\r\n\ |
| 568 | SUMMARY:Pay\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"; |
| 569 | let req = pimdav::render::CalendarData { |
| 570 | expand: Some(utc("2026-01-01T00:00:00")..utc("2026-02-01T00:00:00")), |
| 571 | ..Default::default() |
| 572 | }; |
| 573 | let out = calendar_data(raw, &req, &Zone::Utc).unwrap().0; |
| 574 | assert!( |
| 575 | out.contains("BEGIN:VTODO") && out.contains("SUMMARY:Pay"), |
| 576 | "{out}" |
| 577 | ); |
| 578 | } |
| 579 | |
| 580 | #[test] |
| 581 | fn expand_puts_a_kept_task_in_utc() { |
| 582 | let raw = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\ |
| 583 | BEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:STANDARD\r\n\ |
| 584 | DTSTART:19701025T030000\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\n\ |
| 585 | END:STANDARD\r\nEND:VTIMEZONE\r\n\ |
| 586 | BEGIN:VTODO\r\nUID:t\r\nDUE;TZID=Europe/Berlin:20260105T100000\r\n\ |
| 587 | SUMMARY:Pay\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"; |
| 588 | let req = pimdav::render::CalendarData { |
| 589 | expand: Some(utc("2026-01-01T00:00:00")..utc("2026-02-01T00:00:00")), |
| 590 | ..Default::default() |
| 591 | }; |
| 592 | let out = calendar_data(raw, &req, &Zone::Utc).unwrap().0; |
| 593 | assert!(out.contains("DUE:20260105T090000Z"), "{out}"); |
| 594 | assert!(!out.contains("TZID"), "{out}"); |
| 595 | } |
| 596 | |
| 597 | #[test] |
| 598 | fn limit_recurrence_keeps_an_override_moved_out_of_the_range() { |
| 599 | let raw = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\ |
| 600 | BEGIN:VEVENT\r\nUID:d\r\nDTSTART:20260101T100000Z\r\nRRULE:FREQ=DAILY\r\nEND:VEVENT\r\n\ |
| 601 | BEGIN:VEVENT\r\nUID:d\r\nRECURRENCE-ID:20260102T100000Z\r\nDTSTART:20260301T100000Z\r\nEND:VEVENT\r\n\ |
| 602 | END:VCALENDAR\r\n"; |
| 603 | let req = pimdav::render::CalendarData { |
| 604 | limit_recurrence: Some(utc("2026-01-01T00:00:00")..utc("2026-01-05T00:00:00")), |
| 605 | ..Default::default() |
| 606 | }; |
| 607 | let out = calendar_data(raw, &req, &Zone::Utc).unwrap().0; |
| 608 | assert!(out.contains("RECURRENCE-ID:20260102T100000Z"), "{out}"); |
| 609 | } |
| 610 | |
| 611 | #[test] |
| 612 | fn limit_recurrence_keeps_an_override_whose_original_overlaps_the_range() { |
| 613 | let raw = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\ |
| 614 | BEGIN:VEVENT\r\nUID:d\r\nDTSTART:20260101T093000Z\r\nDTEND:20260101T103000Z\r\n\ |
| 615 | RRULE:FREQ=DAILY\r\nEND:VEVENT\r\n\ |
| 616 | BEGIN:VEVENT\r\nUID:d\r\nRECURRENCE-ID:20260102T093000Z\r\n\ |
| 617 | DTSTART:20260301T093000Z\r\nDTEND:20260301T103000Z\r\nEND:VEVENT\r\n\ |
| 618 | END:VCALENDAR\r\n"; |
| 619 | let req = pimdav::render::CalendarData { |
| 620 | limit_recurrence: Some(utc("2026-01-02T10:00:00")..utc("2026-01-02T11:00:00")), |
| 621 | ..Default::default() |
| 622 | }; |
| 623 | let out = calendar_data(raw, &req, &Zone::Utc).unwrap().0; |
| 624 | assert!(out.contains("RECURRENCE-ID:20260102T093000Z"), "{out}"); |
| 625 | } |
| 626 | |
| 627 | #[test] |
| 628 | fn expand_puts_every_zoned_time_of_an_instance_in_utc() { |
| 629 | let raw = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\ |
| 630 | BEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:STANDARD\r\n\ |
| 631 | DTSTART:19701025T030000\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\n\ |
| 632 | END:STANDARD\r\nEND:VTIMEZONE\r\n\ |
| 633 | BEGIN:VEVENT\r\nUID:e\r\nDTSTART;TZID=Europe/Berlin:20260105T100000\r\n\ |
| 634 | CREATED;TZID=Europe/Berlin:20251201T100000\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; |
| 635 | let req = pimdav::render::CalendarData { |
| 636 | expand: Some(utc("2026-01-01T00:00:00")..utc("2026-02-01T00:00:00")), |
| 637 | ..Default::default() |
| 638 | }; |
| 639 | let out = calendar_data(raw, &req, &Zone::Utc).unwrap().0; |
| 640 | assert!(out.contains("CREATED:20251201T090000Z"), "{out}"); |
| 641 | assert!(!out.contains("TZID"), "{out}"); |
| 642 | } |
| 643 | |
| 644 | #[test] |
| 645 | fn vcard_three_gets_plain_numbers_and_apple_birthdays() { |
| 646 | let v4 = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:x\r\nFN:A\r\n\ |
| 647 | TEL;VALUE=uri;TYPE=cell:tel:+49-1\r\nBDAY:--0315\r\nEND:VCARD\r\n"; |
| 648 | let to = |v| AddressData { |
| 649 | props: None, |
| 650 | version: Some(v), |
| 651 | }; |
| 652 | use pimdav::calcard::vcard::VCardVersion::{V3_0, V4_0}; |
| 653 | let v3 = address_data(v4, &to(V3_0)); |
| 654 | assert!(v3.contains("TEL;TYPE=CELL:+49-1\r\n"), "{v3}"); |
| 655 | assert!( |
| 656 | v3.contains("BDAY;X-APPLE-OMIT-YEAR=1604:1604-03-15\r\n"), |
| 657 | "{v3}" |
| 658 | ); |
| 659 | let back = address_data(&v3, &to(V4_0)); |
| 660 | assert!(back.contains("BDAY:--0315\r\n"), "{back}"); |
| 661 | } |
| 662 | |
| 663 | #[test] |
| 664 | fn an_instance_moved_by_a_range_takes_the_overrides_component() { |
| 665 | let cal = cal(concat!( |
| 666 | "BEGIN:VEVENT\r\nUID:a\r\nDTSTAMP:20240101T000000Z\r\nDTSTART:20240101T100000Z\r\n", |
| 667 | "DURATION:PT1H\r\nRRULE:FREQ=DAILY;COUNT=5\r\nSUMMARY:Old\r\nEND:VEVENT\r\n", |
| 668 | "BEGIN:VEVENT\r\nUID:a\r\nDTSTAMP:20240101T000000Z\r\n", |
| 669 | "RECURRENCE-ID;RANGE=THISANDFUTURE:20240103T100000Z\r\nDTSTART:20240103T120000Z\r\n", |
| 670 | "DURATION:PT1H\r\nSUMMARY:New\r\nEND:VEVENT\r\n", |
| 671 | )); |
| 672 | let rid = Some(utc("2024-01-04T10:00:00")); |
| 673 | let index = pimdav::view::component_for(&cal, rid, &Zone::Utc).unwrap(); |
| 674 | // The instance belongs to the series, not to an override of its own. |
| 675 | assert!( |
| 676 | !cal.components[index] |
| 677 | .has_property(&pimdav::calcard::icalendar::ICalendarProperty::RecurrenceId) |
| 678 | ); |
| 679 | let i = pimdav::view::instance_for(&cal, index, rid, &Zone::Utc).unwrap(); |
| 680 | let summary = cal.components[i.component] |
| 681 | .property(&pimdav::calcard::icalendar::ICalendarProperty::Summary) |
| 682 | .and_then(|e| e.values.first()?.as_text().map(str::to_string)); |
| 683 | assert_eq!(summary.as_deref(), Some("New")); |
| 684 | } |
| 685 | |
| 686 | #[test] |
| 687 | fn expand_refuses_an_answer_too_big_to_build() { |
| 688 | let attendees: String = (0..2000) |
| 689 | .map(|i| format!("ATTENDEE:mailto:user{i}@example.com\r\n")) |
| 690 | .collect(); |
| 691 | let raw = format!( |
| 692 | "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:big\r\n\ |
| 693 | DTSTART:20260101T100000Z\r\nRRULE:FREQ=DAILY\r\n{attendees}END:VEVENT\r\nEND:VCALENDAR\r\n" |
| 694 | ); |
| 695 | let req = |days: i64| pimdav::render::CalendarData { |
| 696 | expand: Some( |
| 697 | utc("2026-01-01T00:00:00")..utc("2026-01-01T00:00:00") + chrono::TimeDelta::days(days), |
| 698 | ), |
| 699 | ..Default::default() |
| 700 | }; |
| 701 | assert!(calendar_data(&raw, &req(10), &Zone::Utc).is_ok()); |
| 702 | assert!(calendar_data(&raw, &req(300), &Zone::Utc).is_err()); |
| 703 | // Without the attendees the same expansion is small. |
| 704 | let body = r#"<c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"> |
| 705 | <d:prop><c:calendar-data><c:comp name="VCALENDAR"> |
| 706 | <c:comp name="VEVENT"><c:prop name="UID"/><c:prop name="DTSTART"/></c:comp> |
| 707 | </c:comp></c:calendar-data></d:prop> |
| 708 | <d:href>/x.ics</d:href></c:calendar-multiget>"#; |
| 709 | let Ok(Report::CalendarMultiget { props, .. }) = parse(body.as_bytes()) else { |
| 710 | panic!("bad multiget"); |
| 711 | }; |
| 712 | let picked = pimdav::render::CalendarData { |
| 713 | expand: req(300).expand, |
| 714 | ..props.calendar.unwrap() |
| 715 | }; |
| 716 | assert!(calendar_data(&raw, &picked, &Zone::Utc).is_ok()); |
| 717 | } |
| 718 | |
| 719 | #[test] |
| 720 | fn a_task_alarm_without_start_fires_from_due() { |
| 721 | let task = "BEGIN:VTODO\r\nUID:t\r\nDUE:20260110T120000Z\r\n\ |
| 722 | BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER;RELATED=END:-PT15M\r\nEND:VALARM\r\nEND:VTODO\r\n"; |
| 723 | let alarm = |start, end| { |
| 724 | format!( |
| 725 | r#"<c:comp-filter name="VTODO">{}</c:comp-filter>"#, |
| 726 | range("VALARM", start, end) |
| 727 | ) |
| 728 | }; |
| 729 | assert!(hit(task, &alarm("20260110T114000Z", "20260110T115000Z"))); |
| 730 | assert!(!hit(task, &alarm("20260110T115000Z", "20260110T120000Z"))); |
| 731 | } |
| 732 | |
| 733 | #[test] |
| 734 | fn an_empty_comp_selects_the_whole_component() { |
| 735 | let body = r#"<c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"> |
| 736 | <d:prop><c:calendar-data><c:comp name="VCALENDAR"><c:allprop/> |
| 737 | <c:comp name="VEVENT"><c:prop name="UID"/><c:prop name="DTSTART"/></c:comp> |
| 738 | <c:comp name="VTIMEZONE"/> |
| 739 | </c:comp></c:calendar-data></d:prop> |
| 740 | <d:href>/x.ics</d:href></c:calendar-multiget>"#; |
| 741 | let Ok(Report::CalendarMultiget { props, .. }) = parse(body.as_bytes()) else { |
| 742 | panic!("bad multiget"); |
| 743 | }; |
| 744 | let raw = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\ |
| 745 | BEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:STANDARD\r\n\ |
| 746 | DTSTART:19701025T030000\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\n\ |
| 747 | END:STANDARD\r\nEND:VTIMEZONE\r\n\ |
| 748 | BEGIN:VEVENT\r\nUID:e\r\nDTSTART;TZID=Europe/Berlin:20260105T100000\r\n\ |
| 749 | SUMMARY:Hidden\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; |
| 750 | let out = calendar_data(raw, &props.calendar.unwrap(), &Zone::Utc) |
| 751 | .unwrap() |
| 752 | .0; |
| 753 | assert!(out.contains("TZID:Europe/Berlin"), "{out}"); |
| 754 | assert!(out.contains("TZOFFSETTO:+0100"), "{out}"); |
| 755 | assert!(!out.contains("SUMMARY"), "{out}"); |
| 756 | } |
| 757 | |
| 758 | #[test] |
| 759 | fn uri_values_keep_their_commas_and_semicolons() { |
| 760 | let body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:x\r\n\ |
| 761 | BEGIN:VEVENT\r\nUID:u\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\n\ |
| 762 | LOCATION:A\\, B\r\n\ |
| 763 | X-APPLE-STRUCTURED-LOCATION;VALUE=URI;X-TITLE=Home:geo:52.5,13.4\r\n\ |
| 764 | URL:http://x/a,b;c\r\n\ |
| 765 | ATTACH:http://x/f,1;2\r\n\ |
| 766 | CONFERENCE;VALUE=URI:https://meet/x,y;z\r\n\ |
| 767 | X-GOOGLE-CONFERENCE:https://meet.google.com/a,b\r\n\ |
| 768 | END:VEVENT\r\nEND:VCALENDAR\r\n"; |
| 769 | let req = |inner: &str| { |
| 770 | let xml = format!( |
| 771 | r#"<c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><c:calendar-data>{inner}</c:calendar-data></d:prop></c:calendar-multiget>"# |
| 772 | ); |
| 773 | let Ok(Report::CalendarMultiget { props, .. }) = parse(xml.as_bytes()) else { |
| 774 | panic!(); |
| 775 | }; |
| 776 | props.calendar.unwrap() |
| 777 | }; |
| 778 | for inner in [ |
| 779 | r#"<c:comp name="VCALENDAR"><c:allprop/><c:comp name="VEVENT"><c:allprop/></c:comp></c:comp>"#, |
| 780 | r#"<c:expand start="20260101T000000Z" end="20260102T000000Z"/>"#, |
| 781 | r#"<c:limit-recurrence-set start="20260101T000000Z" end="20260102T000000Z"/>"#, |
| 782 | ] { |
| 783 | let out = calendar_data(body, &req(inner), &Zone::Utc).unwrap().0; |
| 784 | let out = out.replace("\r\n ", ""); |
| 785 | assert!(out.contains(":geo:52.5,13.4"), "{out}"); |
| 786 | assert!(out.contains("URL:http://x/a,b;c"), "{out}"); |
| 787 | assert!(out.contains(":http://x/f,1;2"), "{out}"); |
| 788 | assert!(out.contains(":https://meet/x,y;z"), "{out}"); |
| 789 | assert!(out.contains(":https://meet.google.com/a,b"), "{out}"); |
| 790 | assert!(out.contains("LOCATION:A\\, B"), "{out}"); |
| 791 | } |
| 792 | } |
| 793 | |
| 794 | #[test] |
| 795 | fn card_uris_and_geo_survive_conversion() { |
| 796 | use pimdav::calcard::vcard::VCardVersion::{V3_0, V4_0}; |
| 797 | let to = |v| AddressData { |
| 798 | props: None, |
| 799 | version: Some(v), |
| 800 | }; |
| 801 | let v3 = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:g\r\nFN:A\r\n\ |
| 802 | X-SOCIALPROFILE;type=x:http://t/a,b;c\r\nGEO:52.5;13.4\r\nEND:VCARD\r\n"; |
| 803 | let v4 = address_data(v3, &to(V4_0)); |
| 804 | let flat = v4.replace("\r\n ", ""); |
| 805 | assert!(flat.contains(":http://t/a,b;c"), "{v4}"); |
| 806 | assert!(flat.contains("GEO:geo:52.5,13.4"), "{v4}"); |
| 807 | let back = address_data(&v4, &to(V3_0)); |
| 808 | let flat = back.replace("\r\n ", ""); |
| 809 | assert!(flat.contains(":http://t/a,b;c"), "{back}"); |
| 810 | assert!(flat.contains("GEO:52.5;13.4"), "{back}"); |
| 811 | // vCard 3 GEO has no altitude. |
| 812 | let high = v4.replace("geo:52.5,13.4", "geo:52.5,13.4,34"); |
| 813 | let flat = address_data(&high, &to(V3_0)).replace("\r\n ", ""); |
| 814 | assert!(flat.contains("GEO:52.5;13.4\r\n"), "{flat}"); |
| 815 | } |
| 816 |