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#"<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav"><c:filter><c:comp-filter name="VEVENT"/></c:filter></c:calendar-query>"#; |
| 137 | assert_eq!( |
| 138 | parse(body).unwrap_err(), |
| 139 | Refused::Condition(Name::new(CALDAV, "valid-filter")) |
| 140 | ); |
| 141 | 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>"#; |
| 142 | assert_eq!( |
| 143 | parse(body).unwrap_err(), |
| 144 | Refused::Condition(Name::new(CALDAV, "valid-timezone")) |
| 145 | ); |
| 146 | let body = br#"<c:free-busy-query xmlns:c="urn:ietf:params:xml:ns:caldav"><c:time-range start="20260101T000000Z"/></c:free-busy-query>"#; |
| 147 | assert_eq!(parse(body).unwrap_err(), Refused::Invalid); |
| 148 | assert!(matches!( |
| 149 | parse(b"<x:nope xmlns:x=\"urn:x\"/>"), |
| 150 | Err(Refused::Condition(_)) |
| 151 | )); |
| 152 | } |
| 153 | |
| 154 | #[test] |
| 155 | fn expand_keeps_dates_and_limit_keeps_used_overrides() { |
| 156 | let body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n\ |
| 157 | BEGIN:VEVENT\r\nUID:d\r\nDTSTART;VALUE=DATE:20260101\r\nRRULE:FREQ=DAILY;COUNT=5\r\nEND:VEVENT\r\n\ |
| 158 | BEGIN:VEVENT\r\nUID:d\r\nRECURRENCE-ID;VALUE=DATE:20260102\r\nDTSTART;VALUE=DATE:20260102\r\nSUMMARY:moved\r\nEND:VEVENT\r\n\ |
| 159 | BEGIN:VEVENT\r\nUID:d\r\nRECURRENCE-ID;VALUE=DATE:20260104\r\nDTSTART;VALUE=DATE:20260104\r\nSUMMARY:later\r\nEND:VEVENT\r\n\ |
| 160 | END:VCALENDAR\r\n"; |
| 161 | let req = |inner: &str| { |
| 162 | let xml = format!( |
| 163 | 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>"# |
| 164 | ); |
| 165 | let Ok(Report::CalendarMultiget { props, .. }) = parse(xml.as_bytes()) else { |
| 166 | panic!(); |
| 167 | }; |
| 168 | props.calendar.unwrap() |
| 169 | }; |
| 170 | let out = calendar_data( |
| 171 | body, |
| 172 | &req(r#"<c:expand start="20260102T000000Z" end="20260104T000000Z"/>"#), |
| 173 | &Zone::Utc, |
| 174 | ) |
| 175 | .unwrap() |
| 176 | .0; |
| 177 | assert!(out.contains("DTSTART;VALUE=DATE:20260102"), "{out}"); |
| 178 | assert!(out.contains("RECURRENCE-ID;VALUE=DATE:20260103"), "{out}"); |
| 179 | assert!(!out.contains("RRULE") && !out.contains("20260104"), "{out}"); |
| 180 | |
| 181 | let out = calendar_data( |
| 182 | body, |
| 183 | &req(r#"<c:limit-recurrence-set start="20260102T000000Z" end="20260103T000000Z"/>"#), |
| 184 | &Zone::Utc, |
| 185 | ) |
| 186 | .unwrap() |
| 187 | .0; |
| 188 | assert!( |
| 189 | out.contains("RRULE") && out.contains("moved") && !out.contains("later"), |
| 190 | "{out}" |
| 191 | ); |
| 192 | } |
| 193 | |
| 194 | #[test] |
| 195 | fn address_data_converts_versions() { |
| 196 | let card = |
| 197 | "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c\r\nFN:Bob\r\nTEL;TYPE=CELL:123\r\nEND:VCARD\r\n"; |
| 198 | let same = AddressData::default(); |
| 199 | assert_eq!(address_data(card, &same), card); |
| 200 | let v4 = AddressData { |
| 201 | props: None, |
| 202 | version: Some(pimdav::calcard::vcard::VCardVersion::V4_0), |
| 203 | }; |
| 204 | assert!(address_data(card, &v4).contains("VERSION:4.0")); |
| 205 | } |
| 206 | |
| 207 | #[test] |
| 208 | fn groups_and_companies_in_apple_forms() { |
| 209 | use pimdav::calcard::vcard::VCardVersion::{V3_0, V4_0}; |
| 210 | let to = |v| AddressData { |
| 211 | props: None, |
| 212 | version: Some(v), |
| 213 | }; |
| 214 | let group = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:g\r\nFN:Team\r\nKIND:group\r\n\ |
| 215 | MEMBER:urn:uuid:m1\r\nEMAIL;PREF=1;TYPE=work:t@example.com\r\nEND:VCARD\r\n"; |
| 216 | let v3 = address_data(group, &to(V3_0)); |
| 217 | assert!(v3.contains("VERSION:3.0"), "{v3}"); |
| 218 | assert!(v3.contains("X-ADDRESSBOOKSERVER-KIND:group\r\n"), "{v3}"); |
| 219 | assert!( |
| 220 | v3.contains("X-ADDRESSBOOKSERVER-MEMBER:urn:uuid:m1\r\n"), |
| 221 | "{v3}" |
| 222 | ); |
| 223 | assert!( |
| 224 | !v3.contains("\r\nKIND") && !v3.contains("\r\nMEMBER"), |
| 225 | "{v3}" |
| 226 | ); |
| 227 | assert!(v3.contains("TYPE=pref") && !v3.contains("PREF=1"), "{v3}"); |
| 228 | |
| 229 | // Back to 4.0, as a client asking for it gets an Apple-made card. |
| 230 | let back = address_data(&v3, &to(V4_0)); |
| 231 | assert!( |
| 232 | back.to_ascii_lowercase().contains("\r\nkind:group\r\n"), |
| 233 | "{back}" |
| 234 | ); |
| 235 | assert!(back.contains("MEMBER:urn:uuid:m1\r\n"), "{back}"); |
| 236 | assert!( |
| 237 | back.contains("PREF=1") && !back.contains("X-ADDRESSBOOKSERVER"), |
| 238 | "{back}" |
| 239 | ); |
| 240 | |
| 241 | let org = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:o\r\nFN:ACME\r\nKIND:org\r\nEND:VCARD\r\n"; |
| 242 | assert!(address_data(org, &to(V3_0)).contains("X-ABSHOWAS:COMPANY\r\n")); |
| 243 | let apple = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:o\r\nFN:ACME\r\nX-ABSHOWAS:COMPANY\r\n\ |
| 244 | TEL;TYPE=\"CELL,pref\":1\r\nEND:VCARD\r\n"; |
| 245 | let v4 = address_data(apple, &to(V4_0)); |
| 246 | assert!(v4.to_ascii_lowercase().contains("\r\nkind:org\r\n"), "{v4}"); |
| 247 | assert!(v4.contains("TYPE=CELL") && v4.contains("PREF=1"), "{v4}"); |
| 248 | } |
| 249 | |
| 250 | #[test] |
| 251 | fn vcard_three_is_the_default() { |
| 252 | use pimdav::calcard::vcard::VCardVersion::{V3_0, V4_0}; |
| 253 | use pimdav::render::{accepted_version, address_request}; |
| 254 | use pimdav::xml::{CARDDAV, el}; |
| 255 | assert_eq!( |
| 256 | address_request(&el(CARDDAV, "address-data")) |
| 257 | .unwrap() |
| 258 | .version, |
| 259 | Some(V3_0) |
| 260 | ); |
| 261 | assert_eq!(accepted_version(None), V3_0); |
| 262 | assert_eq!(accepted_version(Some("text/vcard")), V3_0); |
| 263 | assert_eq!(accepted_version(Some("text/vcard; version=4.0")), V4_0); |
| 264 | } |
| 265 | |
| 266 | #[test] |
| 267 | fn card_filters() { |
| 268 | let card = VCard::parse( |
| 269 | "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Zoë\r\nEMAIL:z@example.com\r\nEND:VCARD\r\n", |
| 270 | ) |
| 271 | .unwrap(); |
| 272 | let parse_filter = |f: &str| { |
| 273 | let xml = format!( |
| 274 | r#"<card:addressbook-query xmlns:card="urn:ietf:params:xml:ns:carddav">{f}</card:addressbook-query>"# |
| 275 | ); |
| 276 | let Ok(Report::AddressbookQuery { filter, .. }) = parse(xml.as_bytes()) else { |
| 277 | panic!("{f}"); |
| 278 | }; |
| 279 | filter |
| 280 | }; |
| 281 | let f = parse_filter( |
| 282 | r#"<card:filter><card:prop-filter name="FN"><card:text-match match-type="starts-with">ZO</card:text-match></card:prop-filter></card:filter>"#, |
| 283 | ); |
| 284 | assert!(matches_card(&card, &f)); |
| 285 | let f = parse_filter( |
| 286 | r#"<card:filter><card:prop-filter name="FN"><card:text-match collation="i;octet">zoë</card:text-match></card:prop-filter></card:filter>"#, |
| 287 | ); |
| 288 | assert!(!matches_card(&card, &f)); |
| 289 | let f = parse_filter( |
| 290 | r#"<card:filter test="allof"><card:prop-filter name="FN"/><card:prop-filter name="TEL"/></card:filter>"#, |
| 291 | ); |
| 292 | assert!(!matches_card(&card, &f)); |
| 293 | let f = parse_filter( |
| 294 | r#"<card:filter><card:prop-filter name="FN"/><card:prop-filter name="TEL"/></card:filter>"#, |
| 295 | ); |
| 296 | assert!(matches_card(&card, &f)); |
| 297 | } |
| 298 | |
| 299 | #[test] |
| 300 | fn busy_time() { |
| 301 | let body = "BEGIN:VEVENT\r\nUID:a\r\nDTSTART:20260101T100000Z\r\nDTEND:20260101T110000Z\r\nEND:VEVENT\r\n\ |
| 302 | BEGIN:VEVENT\r\nUID:b\r\nDTSTART:20260101T103000Z\r\nDTEND:20260101T120000Z\r\nEND:VEVENT\r\n\ |
| 303 | BEGIN:VEVENT\r\nUID:c\r\nDTSTART:20260101T130000Z\r\nDTEND:20260101T140000Z\r\nSTATUS:TENTATIVE\r\nEND:VEVENT\r\n\ |
| 304 | BEGIN:VEVENT\r\nUID:d\r\nDTSTART:20260101T150000Z\r\nDTEND:20260101T160000Z\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\n\ |
| 305 | BEGIN:VEVENT\r\nUID:e\r\nDTSTART:20260101T170000Z\r\nDTEND:20260101T180000Z\r\nSTATUS:CANCELLED\r\nEND:VEVENT\r\n"; |
| 306 | let range = utc("2026-01-01T00:00:00")..utc("2026-01-01T13:30:00"); |
| 307 | let got = merge(busy(&cal(body), &range, &Zone::Utc, None)); |
| 308 | let got: Vec<_> = got.iter().map(|p| (p.kind, p.start, p.end)).collect(); |
| 309 | assert_eq!( |
| 310 | got, |
| 311 | [ |
| 312 | ( |
| 313 | Busy::Busy, |
| 314 | utc("2026-01-01T10:00:00"), |
| 315 | utc("2026-01-01T12:00:00") |
| 316 | ), |
| 317 | ( |
| 318 | Busy::Tentative, |
| 319 | utc("2026-01-01T13:00:00"), |
| 320 | utc("2026-01-01T13:30:00") |
| 321 | ), |
| 322 | ] |
| 323 | ); |
| 324 | } |
| 325 | |
| 326 | #[test] |
| 327 | fn unicode_casemap_folds_after_nfkd() { |
| 328 | use pimdav::filter::{Collation, MatchType, TextMatch}; |
| 329 | let m = |text: &str, collation| TextMatch { |
| 330 | text: text.to_string(), |
| 331 | collation, |
| 332 | match_type: MatchType::Equals, |
| 333 | negate: false, |
| 334 | }; |
| 335 | let unicode = Collation::UnicodeCasemap; |
| 336 | assert!(m("Müller", unicode).matches("Mu\u{0308}ller")); |
| 337 | assert!(m("file", unicode).matches("FILE")); |
| 338 | assert!(m("ix", unicode).matches("\u{2168}")); |
| 339 | assert!(m("é", Collation::AsciiCasemap).matches("é")); |
| 340 | assert!(!m("é", Collation::AsciiCasemap).matches("É")); |
| 341 | } |
| 342 | |
| 343 | #[test] |
| 344 | fn bad_filters_are_valid_filter_conditions() { |
| 345 | let bad_range = query( |
| 346 | r#"<c:comp-filter name="VEVENT"><c:time-range start="20260102T000000Z" end="20260101T000000Z"/></c:comp-filter>"#, |
| 347 | ); |
| 348 | assert!(matches!( |
| 349 | parse(bad_range.as_bytes()), |
| 350 | Err(Refused::Condition(n)) if n == Name::new(CALDAV, "valid-filter") |
| 351 | )); |
| 352 | let bad_test = r#"<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"> |
| 353 | <card:filter test="sometimes"/></card:addressbook-query>"#; |
| 354 | assert!(matches!( |
| 355 | parse(bad_test.as_bytes()), |
| 356 | Err(Refused::Condition(n)) if n == Name::new(pimdav::xml::CARDDAV, "valid-filter") |
| 357 | )); |
| 358 | } |
| 359 | |
| 360 | #[test] |
| 361 | fn busy_time_follows_own_answer() { |
| 362 | const ME: &str = "mailto:room@rooms.dovenest.invalid"; |
| 363 | let me = |a: &str| a.eq_ignore_ascii_case(ME); |
| 364 | let body = format!( |
| 365 | "BEGIN:VEVENT\r\nUID:a\r\nDTSTART:20260101T100000Z\r\nDTEND:20260101T110000Z\r\nATTENDEE;PARTSTAT=DECLINED:{ME}\r\nEND:VEVENT\r\n\ |
| 366 | BEGIN:VEVENT\r\nUID:b\r\nDTSTART:20260101T120000Z\r\nDTEND:20260101T130000Z\r\nATTENDEE:{ME}\r\nEND:VEVENT\r\n\ |
| 367 | BEGIN:VEVENT\r\nUID:c\r\nDTSTART:20260101T140000Z\r\nDTEND:20260101T150000Z\r\nATTENDEE;PARTSTAT=ACCEPTED:{ME}\r\nEND:VEVENT\r\n" |
| 368 | ); |
| 369 | let range = utc("2026-01-01T00:00:00")..utc("2026-01-02T00:00:00"); |
| 370 | let got: Vec<_> = merge(busy(&cal(&body), &range, &Zone::Utc, Some(&me))) |
| 371 | .iter() |
| 372 | .map(|p| (p.kind, p.start)) |
| 373 | .collect(); |
| 374 | assert_eq!( |
| 375 | got, |
| 376 | [ |
| 377 | (Busy::Busy, utc("2026-01-01T14:00:00")), |
| 378 | (Busy::Tentative, utc("2026-01-01T12:00:00")), |
| 379 | ] |
| 380 | ); |
| 381 | // Without a calendar user, the answers do not count (RFC 4791). |
| 382 | assert_eq!(merge(busy(&cal(&body), &range, &Zone::Utc, None)).len(), 3); |
| 383 | } |
| 384 | |
| 385 | #[test] |
| 386 | fn outbox_free_busy_request() { |
| 387 | use pimdav::freebusy::{reply, request, schedule_response}; |
| 388 | let body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\nMETHOD:REQUEST\r\n\ |
| 389 | BEGIN:VFREEBUSY\r\nUID:fb-1\r\nDTSTAMP:20260101T000000Z\r\n\ |
| 390 | DTSTART:20260105T000000Z\r\nDTEND:20260106T000000Z\r\n\ |
| 391 | ORGANIZER:mailto:alice@dovenest.invalid\r\n\ |
| 392 | ATTENDEE:mailto:bob@dovenest.invalid\r\n\ |
| 393 | ATTENDEE:mailto:BOB@dovenest.invalid\r\n\ |
| 394 | ATTENDEE:mailto:dave@example.com\r\n\ |
| 395 | END:VFREEBUSY\r\nEND:VCALENDAR\r\n"; |
| 396 | let req = request(body.as_bytes()).unwrap(); |
| 397 | assert_eq!( |
| 398 | req.range, |
| 399 | utc("2026-01-05T00:00:00")..utc("2026-01-06T00:00:00") |
| 400 | ); |
| 401 | assert_eq!(req.organizer, "mailto:alice@dovenest.invalid"); |
| 402 | assert_eq!( |
| 403 | req.attendees, |
| 404 | ["mailto:bob@dovenest.invalid", "mailto:dave@example.com"] |
| 405 | ); |
| 406 | |
| 407 | let busy = [pimdav::freebusy::Period { |
| 408 | kind: Busy::Busy, |
| 409 | start: utc("2026-01-05T10:00:00"), |
| 410 | end: utc("2026-01-05T11:00:00"), |
| 411 | }]; |
| 412 | let text = reply(&busy, &req, &req.attendees[0], utc("2026-01-01T00:00:00")); |
| 413 | let got = ICalendar::parse(&text).unwrap().to_string(); |
| 414 | for line in [ |
| 415 | "METHOD:REPLY", |
| 416 | "UID:fb-1", |
| 417 | "ATTENDEE:mailto:bob@dovenest.invalid", |
| 418 | "FREEBUSY;FBTYPE=BUSY:20260105T100000Z/20260105T110000Z", |
| 419 | ] { |
| 420 | assert!(got.contains(line), "{line} in {got}"); |
| 421 | } |
| 422 | |
| 423 | let xml = schedule_response(&[( |
| 424 | req.attendees[1].clone(), |
| 425 | "5.2;Invalid calendar service", |
| 426 | None, |
| 427 | )]); |
| 428 | assert!(xml.contains("<c:schedule-response"), "{xml}"); |
| 429 | assert!(xml.contains("<c:request-status>5.2;Invalid calendar service</c:request-status>")); |
| 430 | |
| 431 | // Not a request, or a request without the parts it needs. |
| 432 | for bad in [ |
| 433 | body.replace("METHOD:REQUEST", "METHOD:PUBLISH"), |
| 434 | body.replace("DTEND:20260106T000000Z\r\n", ""), |
| 435 | body.replace("ORGANIZER:mailto:alice@dovenest.invalid\r\n", ""), |
| 436 | ] { |
| 437 | let err = request(bad.as_bytes()).unwrap_err(); |
| 438 | assert!(err.is(CALDAV, "valid-scheduling-message"), "{bad}"); |
| 439 | } |
| 440 | } |
| 441 |