itip.rs
⎇
Raw
1//! Implicit scheduling: what organizer and attendee changes send, and how
2//! the copies change.
3
4use chrono::{TimeZone, Utc};
5use pimdav::calcard::icalendar::ICalendar;
6use pimdav::itip::{self, Message, Method, Refused, Role};
7
8const ALICE: &str = "mailto:alice@dovenest.invalid";
9const BOB: &str = "mailto:bob@dovenest.invalid";
10const CAROL: &str = "mailto:carol@example.com";
11
12fn is(who: &'static str) -> impl Fn(&str) -> bool {
13 move |a: &str| a.eq_ignore_ascii_case(who)
14}
15
16fn cal(body: &str) -> ICalendar {
17 let text = format!(
18 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n{}END:VCALENDAR\r\n",
19 body.replace('\n', "\r\n")
20 );
21 ICalendar::parse(&text).unwrap()
22}
23
24/// A weekly event organized by alice, with the given extra lines.
25fn meeting(extra: &str) -> ICalendar {
26 cal(&format!(
27 "BEGIN:VEVENT
28UID:m1
29DTSTAMP:20260101T000000Z
30DTSTART:20260105T100000Z
31DTEND:20260105T110000Z
32RRULE:FREQ=WEEKLY;COUNT=4
33SUMMARY:Sync
34ORGANIZER:{ALICE}
35ATTENDEE;PARTSTAT=ACCEPTED:{ALICE}
36{extra}END:VEVENT
37"
38 ))
39}
40
41fn now() -> chrono::DateTime<Utc> {
42 Utc.with_ymd_and_hms(2026, 1, 2, 12, 0, 0).unwrap()
43}
44
45fn text(c: &ICalendar) -> String {
46 c.to_string().replace("\r\n ", "")
47}
48
49fn to<'a>(msgs: &'a [Message], who: &str) -> Option<&'a Message> {
50 msgs.iter().find(|m| m.to.eq_ignore_ascii_case(who))
51}
52
53#[test]
54fn roles() {
55 let m = meeting(&format!("ATTENDEE:{BOB}\n"));
56 assert_eq!(itip::role(&m, &is(ALICE)), Ok(Role::Organizer));
57 assert_eq!(itip::role(&m, &is(BOB)), Ok(Role::Attendee));
58 assert_eq!(itip::role(&m, &is(CAROL)), Ok(Role::None));
59 let split = cal(&format!(
60 "BEGIN:VEVENT\nUID:x\nDTSTART:20260105T100000Z\nORGANIZER:{ALICE}\nEND:VEVENT\n\
61 BEGIN:VEVENT\nUID:x\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\nORGANIZER:{BOB}\nEND:VEVENT\n"
62 ));
63 assert_eq!(itip::role(&split, &is(ALICE)), Err(Refused::SameOrganizer));
64}
65
66#[test]
67fn invite() {
68 // The client claims bob accepted; only bob can say that.
69 let new = cal(&format!(
70 "BEGIN:VEVENT
71UID:m1
72DTSTAMP:20260101T000000Z
73DTSTART:20260105T100000Z
74SUMMARY:Sync
75ORGANIZER:{ALICE}
76ATTENDEE;PARTSTAT=ACCEPTED:{ALICE}
77ATTENDEE;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=1.2:{BOB}
78ATTENDEE;SCHEDULE-AGENT=CLIENT;PARTSTAT=ACCEPTED:{CAROL}
79BEGIN:VALARM
80ACTION:DISPLAY
81TRIGGER:-PT5M
82END:VALARM
83END:VEVENT
84"
85 ));
86 let (store, msgs) = itip::organize(None, Some(new), &is(ALICE), now());
87 let store = text(&store.unwrap());
88 assert!(
89 store.contains(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}")),
90 "{store}"
91 );
92 assert!(
93 store.contains("SCHEDULE-AGENT=CLIENT;PARTSTAT=ACCEPTED"),
94 "{store}"
95 );
96 // Nothing to alice herself or to the attendee the client schedules.
97 assert_eq!(msgs.len(), 1);
98 let m = to(&msgs, BOB).unwrap();
99 assert_eq!(m.method, Method::Request);
100 let body = text(&m.cal);
101 assert!(body.contains("METHOD:REQUEST"));
102 assert!(body.contains("DTSTAMP:20260102T120000Z"));
103 assert!(!body.contains("VALARM"));
104 assert!(!body.contains("SCHEDULE-"), "{body}");
105}
106
107#[test]
108fn reschedule_resets_participation() {
109 let old = meeting(&format!(
110 "ATTENDEE;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:{BOB}\n"
111 ));
112 // A new title keeps bob's answer, even from a client with a stale copy.
113 let retitled = text(&meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n")))
114 .replace("SUMMARY:Sync", "SUMMARY:Weekly sync");
115 let (store, msgs) = itip::organize(
116 Some(&old),
117 Some(ICalendar::parse(&retitled).unwrap()),
118 &is(ALICE),
119 now(),
120 );
121 let store = text(&store.unwrap());
122 assert!(
123 store.contains(&format!("PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:{BOB}")),
124 "{store}"
125 );
126 assert!(!to(&msgs, BOB).unwrap().quiet);
127
128 let moved = text(&old).replace("DTSTART:20260105T100000Z", "DTSTART:20260105T090000Z");
129 let (store, msgs) = itip::organize(
130 Some(&old),
131 Some(ICalendar::parse(&moved).unwrap()),
132 &is(ALICE),
133 now(),
134 );
135 let store = text(&store.unwrap());
136 assert!(store.contains("PARTSTAT=NEEDS-ACTION"), "{store}");
137 assert!(store.contains("SEQUENCE:1"), "{store}");
138 assert!(text(&to(&msgs, BOB).unwrap().cal).contains("DTSTART:20260105T090000Z"));
139
140 // Shortening the series is no reschedule.
141 let shorter = text(&old).replace("COUNT=4", "COUNT=2");
142 let (store, _) = itip::organize(
143 Some(&old),
144 Some(ICalendar::parse(&shorter).unwrap()),
145 &is(ALICE),
146 now(),
147 );
148 assert!(
149 text(&store.unwrap()).contains(&format!("PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:{BOB}"))
150 );
151}
152
153#[test]
154fn unchanged_or_forced() {
155 let old = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
156 let resaved = text(&old)
157 .replace("DTSTAMP:20260101T000000Z", "DTSTAMP:20260101T080000Z")
158 .replace("SUMMARY:Sync", "SUMMARY:Sync\r\nX-MOZ-GENERATION:3");
159 let (_, msgs) = itip::organize(
160 Some(&old),
161 Some(ICalendar::parse(&resaved).unwrap()),
162 &is(ALICE),
163 now(),
164 );
165 assert!(msgs.is_empty(), "{msgs:?}");
166 let forced = text(&old).replace(
167 "ATTENDEE;PARTSTAT=ACCEPTED:mailto:bob",
168 "ATTENDEE;SCHEDULE-FORCE-SEND=REQUEST;PARTSTAT=ACCEPTED:mailto:bob",
169 );
170 let (store, msgs) = itip::organize(
171 Some(&old),
172 Some(ICalendar::parse(&forced).unwrap()),
173 &is(ALICE),
174 now(),
175 );
176 assert!(!text(&store.unwrap()).contains("FORCE-SEND"));
177 assert_eq!(to(&msgs, BOB).unwrap().method, Method::Request);
178}
179
180#[test]
181fn removed_attendee_and_delete_cancel() {
182 let old = meeting(&format!("ATTENDEE:{BOB}\nATTENDEE:{CAROL}\n"));
183 let (_, msgs) = itip::organize(
184 Some(&old),
185 Some(meeting(&format!("ATTENDEE:{BOB}\n"))),
186 &is(ALICE),
187 now(),
188 );
189 let cancel = to(&msgs, CAROL).unwrap();
190 assert_eq!(cancel.method, Method::Cancel);
191 assert!(text(&cancel.cal).contains("STATUS:CANCELLED"));
192 // The attendee list changed for bob too.
193 assert_eq!(to(&msgs, BOB).unwrap().method, Method::Request);
194
195 let (store, msgs) = itip::organize(Some(&old), None, &is(ALICE), now());
196 assert!(store.is_none());
197 assert!(msgs.iter().all(|m| m.method == Method::Cancel));
198 assert_eq!(msgs.len(), 2);
199}
200
201#[test]
202fn instances_only_reach_their_attendees() {
203 let old = cal(&format!(
204 "BEGIN:VEVENT
205UID:m1
206DTSTART:20260105T100000Z
207RRULE:FREQ=WEEKLY;COUNT=4
208ORGANIZER:{ALICE}
209ATTENDEE:{BOB}
210ATTENDEE:{CAROL}
211END:VEVENT
212BEGIN:VEVENT
213UID:m1
214RECURRENCE-ID:20260112T100000Z
215DTSTART:20260112T140000Z
216ORGANIZER:{ALICE}
217ATTENDEE:{BOB}
218END:VEVENT
219"
220 ));
221 let (_, msgs) = itip::organize(None, Some(old), &is(ALICE), now());
222 let carol = text(&to(&msgs, CAROL).unwrap().cal);
223 assert!(carol.contains("EXDATE:20260112T100000Z"), "{carol}");
224 assert!(!carol.contains("RECURRENCE-ID"), "{carol}");
225 let bob = text(&to(&msgs, BOB).unwrap().cal);
226 assert!(
227 bob.contains("RECURRENCE-ID:20260112T100000Z") && !bob.contains("EXDATE"),
228 "{bob}"
229 );
230}
231
232#[test]
233fn accept_and_apply_reply() {
234 let org = meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n"));
235 let (_, msgs) = itip::organize(None, Some(org.clone()), &is(ALICE), now());
236 let copy = itip::receive(None, to(&msgs, BOB).unwrap()).unwrap();
237 assert!(!text(&copy).contains("METHOD"));
238
239 let accepted = text(&copy).replace(
240 &format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}"),
241 &format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}"),
242 );
243 let (_, reply) =
244 itip::attend(&copy, ICalendar::parse(&accepted).unwrap(), &is(BOB), now()).unwrap();
245 let reply = reply.unwrap();
246 assert_eq!((reply.method, reply.to.as_str()), (Method::Reply, ALICE));
247 let body = text(&reply.cal);
248 assert!(body.contains("METHOD:REPLY"), "{body}");
249 assert!(
250 !body.contains(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{ALICE}")),
251 "{body}"
252 );
253
254 let mut org = org;
255 assert!(itip::apply_reply(&mut org, &reply.cal, &is(BOB)));
256 let org = text(&org);
257 assert!(
258 org.contains(&format!("PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:{BOB}")),
259 "{org}"
260 );
261}
262
263#[test]
264fn declining_one_instance() {
265 let org = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
266 let copy = org.clone();
267 // bob overrides the second week to decline it.
268 let with_override = format!(
269 "{}BEGIN:VEVENT\r\nUID:m1\r\nRECURRENCE-ID:20260112T100000Z\r\nDTSTART:20260112T100000Z\r\nDTEND:20260112T110000Z\r\nSUMMARY:Sync\r\nORGANIZER:{ALICE}\r\nATTENDEE;PARTSTAT=ACCEPTED:{ALICE}\r\nATTENDEE;PARTSTAT=DECLINED:{BOB}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
270 text(&copy).trim_end_matches("END:VCALENDAR\r\n")
271 );
272 let (_, reply) = itip::attend(
273 &copy,
274 ICalendar::parse(&with_override).unwrap(),
275 &is(BOB),
276 now(),
277 )
278 .unwrap();
279 let reply = reply.unwrap();
280 assert!(text(&reply.cal).contains("RECURRENCE-ID:20260112T100000Z"));
281 let mut org = org;
282 assert!(itip::apply_reply(&mut org, &reply.cal, &is(BOB)));
283 let org_text = text(&org);
284 assert!(
285 org_text.contains("RECURRENCE-ID:20260112T100000Z"),
286 "{org_text}"
287 );
288 assert!(org_text.contains("DURATION:PT1H"), "{org_text}");
289 assert!(
290 org_text.contains(&format!("PARTSTAT=DECLINED;SCHEDULE-STATUS=2.0:{BOB}")),
291 "{org_text}"
292 );
293
294 // An EXDATE declines too.
295 let excluded = text(&copy).replace(
296 "RRULE:FREQ=WEEKLY;COUNT=4",
297 "RRULE:FREQ=WEEKLY;COUNT=4\r\nEXDATE:20260119T100000Z",
298 );
299 let (_, reply) =
300 itip::attend(&copy, ICalendar::parse(&excluded).unwrap(), &is(BOB), now()).unwrap();
301 let body = text(&reply.unwrap().cal);
302 assert!(
303 body.contains("RECURRENCE-ID:20260119T100000Z") && body.contains("PARTSTAT=DECLINED"),
304 "{body}"
305 );
306}
307
308#[test]
309fn reply_for_a_missing_instance_adds_nothing() {
310 let reply = |rid: &str| {
311 cal(&format!(
312 "METHOD:REPLY
313BEGIN:VEVENT
314UID:m1
315DTSTAMP:20260101T000000Z
316RECURRENCE-ID:{rid}
317DTSTART:{rid}
318ORGANIZER:{ALICE}
319ATTENDEE;PARTSTAT=DECLINED:{BOB}
320END:VEVENT
321"
322 ))
323 };
324 let mut org = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
325 // A Tuesday, and a Monday after COUNT ends.
326 for rid in ["20260113T100000Z", "20260202T100000Z"] {
327 assert!(!itip::apply_reply(&mut org, &reply(rid), &is(BOB)), "{rid}");
328 }
329 assert!(itip::apply_reply(
330 &mut org,
331 &reply("20260112T100000Z"),
332 &is(BOB)
333 ));
334}
335
336#[test]
337fn attendees_may_not_move_the_meeting() {
338 let copy = meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n"));
339 let moved = text(&copy).replace("DTSTART:20260105T100000Z", "DTSTART:20260105T120000Z");
340 assert_eq!(
341 itip::attend(&copy, ICalendar::parse(&moved).unwrap(), &is(BOB), now()).err(),
342 Some(Refused::AttendeeChange)
343 );
344 // Alarms and transparency are the attendee's.
345 let own = text(&copy).replace(
346 "SUMMARY:Sync",
347 "SUMMARY:Sync\r\nTRANSP:TRANSPARENT\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT5M\r\nEND:VALARM",
348 );
349 let (_, reply) = itip::attend(&copy, ICalendar::parse(&own).unwrap(), &is(BOB), now()).unwrap();
350 assert!(reply.is_none());
351}
352
353#[test]
354fn attendee_delete_declines() {
355 let copy = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
356 let reply = itip::decline(&copy, &is(BOB), now()).unwrap();
357 assert!(text(&reply.cal).contains(&format!("PARTSTAT=DECLINED:{BOB}")));
358 let client = text(&copy).replace(
359 &format!("ORGANIZER:{ALICE}"),
360 &format!("ORGANIZER;SCHEDULE-AGENT=CLIENT:{ALICE}"),
361 );
362 assert!(itip::decline(&ICalendar::parse(&client).unwrap(), &is(BOB), now()).is_none());
363}
364
365#[test]
366fn updates_keep_what_the_attendee_owns() {
367 let org = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
368 let (_, msgs) = itip::organize(None, Some(org.clone()), &is(ALICE), now());
369 let copy = itip::receive(None, to(&msgs, BOB).unwrap()).unwrap();
370 let copy = ICalendar::parse(text(&copy).replace(
371 "SUMMARY:Sync",
372 "SUMMARY:Sync\r\nTRANSP:TRANSPARENT\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT5M\r\nEND:VALARM",
373 ))
374 .unwrap();
375 let retitled =
376 ICalendar::parse(text(&org).replace("SUMMARY:Sync", "SUMMARY:Planning")).unwrap();
377 let (_, msgs) = itip::organize(Some(&org), Some(retitled), &is(ALICE), now());
378 let updated = text(&itip::receive(Some(&copy), to(&msgs, BOB).unwrap()).unwrap());
379 assert!(
380 updated.contains("SUMMARY:Planning")
381 && updated.contains("TRANSP:TRANSPARENT")
382 && updated.contains("TRIGGER:-PT5M"),
383 "{updated}"
384 );
385
386 let (_, msgs) = itip::organize(Some(&org), None, &is(ALICE), now());
387 let cancelled = text(&itip::receive(Some(&copy), to(&msgs, BOB).unwrap()).unwrap());
388 assert!(cancelled.contains("STATUS:CANCELLED"), "{cancelled}");
389}
390
391#[test]
392fn sender_is_named_for_the_owner() {
393 let mut m = meeting(&format!("ATTENDEE:{BOB}\n"));
394 itip::stamp_sender(&mut m, &is(ALICE), Some(BOB));
395 let got = text(&m);
396 assert!(
397 got.contains(&format!("ORGANIZER;SENT-BY=\"{BOB}\":{ALICE}")),
398 "{got}"
399 );
400 assert!(!got.contains(&format!("SENT-BY=\"{BOB}\":{BOB}")), "{got}");
401 // An edit by the owner drops it again.
402 itip::stamp_sender(&mut m, &is(ALICE), None);
403 assert!(!text(&m).contains("SENT-BY"), "{}", text(&m));
404
405 // Who sent it is no reason to message the attendees.
406 let old = meeting(&format!("ATTENDEE:{BOB}\n"));
407 let mut new = old.clone();
408 itip::stamp_sender(&mut new, &is(ALICE), Some(BOB));
409 let msgs = itip::messages(Some(&old), Some(&new), &is(ALICE), &[], now());
410 assert!(msgs.is_empty(), "{msgs:?}");
411}
412
413#[test]
414fn rooms_answer_from_their_bookings() {
415 use pimdav::freebusy::{Busy, Period};
416 use pimdav::zone::Zone;
417 const ROOM: &str = "mailto:board@rooms.dovenest.invalid";
418 let at = |d: u32, h: u32| Utc.with_ymd_and_hms(2026, 1, d, h, 0, 0).unwrap();
419 // The weekly meeting runs on 5, 12, 19 and 26 January, 10:00 to 11:00.
420 let copy = meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{ROOM}\n"));
421 let window = at(1, 0)..at(31, 0);
422
423 let free = itip::auto_answer(&copy, &is(ROOM), &[], &window, &Zone::Utc);
424 assert!(
425 text(&free).contains(&format!("PARTSTAT=ACCEPTED:{ROOM}")),
426 "{}",
427 text(&free)
428 );
429
430 let taken = [Period {
431 kind: Busy::Busy,
432 start: at(12, 10),
433 end: at(12, 11),
434 }];
435 let answer = itip::auto_answer(&copy, &is(ROOM), &taken, &window, &Zone::Utc);
436 let got = text(&answer);
437 assert!(got.contains("RECURRENCE-ID:20260112T100000Z"), "{got}");
438 assert_eq!(
439 got.matches(&format!("PARTSTAT=DECLINED:{ROOM}")).count(),
440 1,
441 "{got}"
442 );
443 assert_eq!(
444 got.matches(&format!("PARTSTAT=ACCEPTED:{ROOM}")).count(),
445 1,
446 "{got}"
447 );
448
449 // The answer travels as a REPLY for the series and the one instance.
450 let (_, reply) = itip::attend(&copy, answer, &is(ROOM), now()).unwrap();
451 let reply = reply.unwrap();
452 assert_eq!(reply.method, Method::Reply);
453 assert!(text(&reply.cal).contains("RECURRENCE-ID:20260112T100000Z"));
454
455 // A single event on a taken slot is declined as a whole.
456 let single = cal(&format!(
457 "BEGIN:VEVENT\nUID:s1\nDTSTART:20260112T103000Z\nDURATION:PT1H\nORGANIZER:{ALICE}\n\
458 ATTENDEE:{ROOM}\nEND:VEVENT\n"
459 ));
460 let got = text(&itip::auto_answer(
461 &single,
462 &is(ROOM),
463 &taken,
464 &window,
465 &Zone::Utc,
466 ));
467 assert!(got.contains(&format!("PARTSTAT=DECLINED:{ROOM}")), "{got}");
468}
469
470#[test]
471fn forget_a_deleted_principal() {
472 let gone = is(BOB);
473 let tomb = "mailto:bob-7@deleted.dovenest.invalid";
474
475 // In alice's meeting bob becomes a tombstone that reaches no one.
476 let org = format!(
477 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:m1\r\nSUMMARY:Sync\r\nORGANIZER:{ALICE}\r\nATTENDEE;CN=Bob;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=1.2:{BOB}\r\nATTENDEE:{CAROL}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
478 );
479 let out = itip::forget(&org, &gone, tomb).unwrap();
480 // The longer line is folded at 75 octets.
481 assert_eq!(
482 out.replace("\r\n ", ""),
483 org.replace(
484 &format!("ATTENDEE;CN=Bob;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=1.2:{BOB}"),
485 &format!("ATTENDEE;CN=Bob;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=3.7:{tomb}")
486 )
487 );
488
489 // A copy of bob's meeting is cancelled, the override too; LF stays LF.
490 let copy = format!(
491 "BEGIN:VCALENDAR\nBEGIN:VEVENT\nUID:m2\nORGANIZER;CN=Bob:{}\nATTENDEE:{ALICE}\nEND:VEVENT\nBEGIN:VEVENT\nUID:m2\nRECURRENCE-ID:20260105T100000Z\nSTATUS:CONFIRMED\nORGANIZER:{BOB}\nEND:VEVENT\nEND:VCALENDAR\n",
492 BOB.to_uppercase()
493 );
494 let out = itip::forget(&copy, &gone, tomb).unwrap();
495 assert_eq!(
496 out,
497 format!(
498 "BEGIN:VCALENDAR\nBEGIN:VEVENT\nSTATUS:CANCELLED\nUID:m2\nORGANIZER;CN=Bob:{tomb}\nATTENDEE:{ALICE}\nEND:VEVENT\nBEGIN:VEVENT\nUID:m2\nRECURRENCE-ID:20260105T100000Z\nSTATUS:CANCELLED\nORGANIZER:{tomb}\nEND:VEVENT\nEND:VCALENDAR\n"
499 )
500 );
501
502 assert_eq!(
503 itip::forget(&org, &is(CAROL.trim_end_matches('m')), tomb),
504 None
505 );
506}
507
508const ROOM: &str = "mailto:board@rooms.dovenest.invalid";
509
510fn at(d: u32, h: u32) -> chrono::DateTime<Utc> {
511 Utc.with_ymd_and_hms(2026, 1, d, h, 0, 0).unwrap()
512}
513
514fn booked(start: chrono::DateTime<Utc>, end: chrono::DateTime<Utc>) -> pimdav::freebusy::Period {
515 pimdav::freebusy::Period {
516 kind: pimdav::freebusy::Busy::Busy,
517 start,
518 end,
519 }
520}
521
522#[test]
523fn a_room_declines_a_series_with_many_conflicts_as_a_whole() {
524 use pimdav::zone::Zone;
525 let copy = cal(&format!(
526 "BEGIN:VEVENT\nUID:h1\nDTSTART:20260105T000000Z\nDURATION:PT30M\nRRULE:FREQ=HOURLY;COUNT=200\n\
527 ORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n"
528 ));
529 let taken = [booked(at(1, 0), at(31, 0))];
530 let got = text(&itip::auto_answer(
531 &copy,
532 &is(ROOM),
533 &taken,
534 &(at(1, 0)..at(31, 0)),
535 &Zone::Utc,
536 ));
537 assert!(!got.contains("RECURRENCE-ID"), "{got}");
538 assert!(got.contains(&format!("PARTSTAT=DECLINED:{ROOM}")), "{got}");
539}
540
541#[test]
542fn a_room_declines_one_instance_a_this_and_future_override_moves() {
543 use pimdav::zone::Zone;
544 // From the 12th on, the meeting runs two hours later.
545 let copy = cal(&format!(
546 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\nRRULE:FREQ=WEEKLY;COUNT=4\n\
547 ORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n\
548 BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\
549 DTSTART:20260112T120000Z\nDTEND:20260112T130000Z\nORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n"
550 ));
551 let taken = [booked(at(19, 12), at(19, 13))];
552 let answer = itip::auto_answer(&copy, &is(ROOM), &taken, &(at(1, 0)..at(31, 0)), &Zone::Utc);
553 let got = text(&answer);
554 assert!(
555 got.contains("RECURRENCE-ID:20260119T100000Z\r\nDTSTART:20260119T120000Z")
556 || got.contains("DTSTART:20260119T120000Z\r\nRECURRENCE-ID:20260119T100000Z"),
557 "{got}"
558 );
559 assert_eq!(
560 got.matches(&format!("PARTSTAT=DECLINED:{ROOM}")).count(),
561 1,
562 "{got}"
563 );
564 assert_eq!(
565 got.matches(&format!("PARTSTAT=ACCEPTED:{ROOM}")).count(),
566 2,
567 "{got}"
568 );
569
570 // The moved override passes as the room's own answer, and the
571 // organizer's copy takes it.
572 let (_, reply) = itip::attend(&copy, answer, &is(ROOM), now()).unwrap();
573 let mut org = copy.clone();
574 assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(ROOM)));
575 let org = text(&org);
576 assert!(org.contains("DTSTART:20260119T120000Z"), "{org}");
577}
578
579#[test]
580fn a_client_scheduled_attendee_gets_no_cancel() {
581 let old = meeting(&format!("ATTENDEE:{BOB}\n"));
582 let new = meeting(&format!("ATTENDEE;SCHEDULE-AGENT=CLIENT:{BOB}\n"));
583 let (_, msgs) = itip::organize(Some(&old), Some(new), &is(ALICE), now());
584 assert!(to(&msgs, BOB).is_none(), "{msgs:?}");
585}
586
587#[test]
588fn the_highest_sequence_does_not_overflow() {
589 let old = meeting(&format!(
590 "SEQUENCE:9223372036854775807\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\n"
591 ));
592 let moved = text(&old)
593 .replace("DTSTART:20260105T100000Z", "DTSTART:20260105T090000Z")
594 .replace(&format!("ATTENDEE:{CAROL}\r\n"), "");
595 let (store, msgs) = itip::organize(
596 Some(&old),
597 Some(ICalendar::parse(&moved).unwrap()),
598 &is(ALICE),
599 now(),
600 );
601 assert!(text(&store.unwrap()).contains("SEQUENCE:9223372036854775807"));
602 assert!(text(&to(&msgs, CAROL).unwrap().cal).contains("SEQUENCE:9223372036854775807"));
603}
604
605#[test]
606fn answering_an_instance_the_series_lacks_changes_nothing() {
607 use pimdav::calcard::icalendar::ICalendarParticipationStatus::Declined;
608 use pimdav::zone::Zone;
609 let copy = meeting(&format!("ATTENDEE:{BOB}\n"));
610 // A Tuesday is no instance.
611 assert!(itip::respond(&copy, &is(BOB), Declined, Some(at(13, 10)), &Zone::Utc).is_none());
612 let got =
613 text(&itip::respond(&copy, &is(BOB), Declined, Some(at(12, 10)), &Zone::Utc).unwrap());
614 assert!(got.contains("RECURRENCE-ID:20260112T100000Z"), "{got}");
615 assert_eq!(
616 got.matches(&format!("PARTSTAT=DECLINED:{BOB}")).count(),
617 1,
618 "{got}"
619 );
620}
621
622#[test]
623fn a_date_until_on_the_same_day_extends_the_series() {
624 let old = text(&meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n")))
625 .replace("COUNT=4", "UNTIL=20260119T000000Z");
626 let old = ICalendar::parse(&old).unwrap();
627 // The DATE includes the instance at 10:00 on the 19th.
628 let longer = text(&old).replace("UNTIL=20260119T000000Z", "UNTIL=20260119");
629 let (store, _) = itip::organize(
630 Some(&old),
631 Some(ICalendar::parse(&longer).unwrap()),
632 &is(ALICE),
633 now(),
634 );
635 let store = text(&store.unwrap());
636 assert!(
637 store.contains(&format!("PARTSTAT=NEEDS-ACTION:{BOB}")),
638 "{store}"
639 );
640}
641
642#[test]
643fn a_room_declines_only_the_first_instance_of_a_this_and_future_override() {
644 use pimdav::calcard::icalendar::ICalendarParticipationStatus::Declined;
645 use pimdav::zone::Zone;
646 // From the 12th on, the meeting runs two hours later.
647 let copy = cal(&format!(
648 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\nRRULE:FREQ=WEEKLY;COUNT=4\n\
649 ORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n\
650 BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\
651 DTSTART:20260112T120000Z\nDTEND:20260112T130000Z\nORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n"
652 ));
653 let taken = [booked(at(12, 12), at(12, 13))];
654 let answer = itip::auto_answer(&copy, &is(ROOM), &taken, &(at(1, 0)..at(31, 0)), &Zone::Utc);
655 let got = text(&answer);
656 assert!(got.contains("RECURRENCE-ID:20260112T100000Z"), "{got}");
657 assert!(
658 got.contains("RECURRENCE-ID;RANGE=THISANDFUTURE:20260119T100000Z"),
659 "{got}"
660 );
661 assert!(got.contains("DTSTART:20260119T120000Z"), "{got}");
662 let declined = format!("PARTSTAT=DECLINED:{ROOM}");
663 let accepted = format!("PARTSTAT=ACCEPTED:{ROOM}");
664 assert_eq!(got.matches(&declined).count(), 1, "{got}");
665 assert_eq!(got.matches(&accepted).count(), 2, "{got}");
666
667 // The organizer's copy splits its range the same way.
668 let (_, reply) = itip::attend(&copy, answer, &is(ROOM), now()).unwrap();
669 let mut org = copy.clone();
670 assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(ROOM)));
671 let org = text(&org);
672 assert!(
673 org.contains("RECURRENCE-ID;RANGE=THISANDFUTURE:20260119T100000Z"),
674 "{org}"
675 );
676 assert_eq!(org.matches("PARTSTAT=DECLINED").count(), 1, "{org}");
677 assert_eq!(org.matches("PARTSTAT=ACCEPTED").count(), 2, "{org}");
678
679 // So does an answer for that one instance from the web.
680 let got =
681 text(&itip::respond(&copy, &is(ROOM), Declined, Some(at(12, 10)), &Zone::Utc).unwrap());
682 assert!(
683 got.contains("RECURRENCE-ID;RANGE=THISANDFUTURE:20260119T100000Z"),
684 "{got}"
685 );
686 assert_eq!(got.matches(&declined).count(), 1, "{got}");
687}
688
689#[test]
690fn a_new_override_takes_the_others_state_from_its_range() {
691 let old = cal(&format!(
692 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\nRRULE:FREQ=WEEKLY;COUNT=4\n\
693 ORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE;PARTSTAT=NEEDS-ACTION:{CAROL}\nEND:VEVENT\n\
694 BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\
695 DTSTART:20260112T120000Z\nDTEND:20260112T130000Z\nORGANIZER:{ALICE}\n\
696 ATTENDEE:{BOB}\nATTENDEE;PARTSTAT=ACCEPTED:{CAROL}\nEND:VEVENT\n"
697 ));
698 let new = cal(&format!(
699 "{}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260119T100000Z\nDTSTART:20260119T120000Z\n\
700 DTEND:20260119T130000Z\nORGANIZER:{ALICE}\nATTENDEE;PARTSTAT=DECLINED:{BOB}\n\
701 ATTENDEE;PARTSTAT=NEEDS-ACTION:{CAROL}\nEND:VEVENT\n",
702 text(&old)
703 .trim_start_matches("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n")
704 .trim_end_matches("END:VCALENDAR\r\n")
705 .replace("\r\n", "\n")
706 ));
707 let (store, reply) = itip::attend(&old, new, &is(BOB), now()).unwrap();
708 let store = text(&store);
709 assert_eq!(
710 store.matches(&format!("PARTSTAT=ACCEPTED:{CAROL}")).count(),
711 2,
712 "{store}"
713 );
714 assert!(reply.is_some());
715}
716
717#[test]
718fn an_attendee_dropped_from_the_master_gets_a_cancel_despite_a_client_override() {
719 let over = |agent: &str| {
720 format!(
721 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T120000Z\n\
722 DTEND:20260112T130000Z\nORGANIZER:{ALICE}\nATTENDEE{agent}:{BOB}\nEND:VEVENT\n"
723 )
724 };
725 let series = |extra: &str| {
726 format!(
727 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
728 RRULE:FREQ=WEEKLY;COUNT=4\nORGANIZER:{ALICE}\nATTENDEE:{CAROL}\n{extra}END:VEVENT\n"
729 )
730 };
731 let old = cal(&format!(
732 "{}{}",
733 series(&format!("ATTENDEE:{BOB}\n")),
734 over("")
735 ));
736 let new = cal(&format!("{}{}", series(""), over(";SCHEDULE-AGENT=CLIENT")));
737 let (_, msgs) = itip::organize(Some(&old), Some(new), &is(ALICE), now());
738 let cancel = to(&msgs, BOB).expect("a CANCEL for the series");
739 assert_eq!(cancel.method, Method::Cancel);
740 assert!(
741 !text(&cancel.cal).contains("RECURRENCE-ID"),
742 "{}",
743 text(&cancel.cal)
744 );
745}
746
747/// alice's weekly meeting, two hours later from the 12th on, with bob in it.
748fn shifted_series() -> String {
749 format!(
750 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\nRRULE:FREQ=WEEKLY;COUNT=4\n\
751 ORGANIZER:{ALICE}\nATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n\
752 BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\
753 DTSTART:20260112T120000Z\nDTEND:20260112T130000Z\nORGANIZER:{ALICE}\n\
754 ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n"
755 )
756}
757
758#[test]
759fn a_new_override_at_the_shifted_time_is_no_move() {
760 let old = cal(&shifted_series());
761 let new = cal(&format!(
762 "{}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260119T100000Z\nDTSTART:20260119T120000Z\n\
763 DTEND:20260119T130000Z\nSUMMARY:renamed\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n",
764 shifted_series()
765 ));
766 let (store, _) = itip::organize(Some(&old), Some(new), &is(ALICE), now());
767 let store = text(&store.unwrap());
768 assert_eq!(
769 store.matches(&format!("PARTSTAT=ACCEPTED:{BOB}")).count(),
770 3,
771 "{store}"
772 );
773 assert!(!store.contains("SEQUENCE:1"), "{store}");
774}
775
776#[test]
777fn a_copy_without_its_master_keeps_the_shift() {
778 let range = shifted_series()
779 .split_inclusive("END:VEVENT\n")
780 .nth(1)
781 .unwrap()
782 .to_string();
783 let old = cal(&range);
784 let new = cal(&format!(
785 "{range}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260119T100000Z\nDTSTART:20260119T120000Z\n\
786 DTEND:20260119T130000Z\nSUMMARY:renamed\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n"
787 ));
788 let (store, _) = itip::organize(Some(&old), Some(new), &is(ALICE), now());
789 let store = text(&store.unwrap());
790 assert_eq!(
791 store.matches(&format!("PARTSTAT=ACCEPTED:{BOB}")).count(),
792 2,
793 "{store}"
794 );
795}
796
797#[test]
798fn a_new_override_that_moves_bumps_the_sequence() {
799 let old = cal(&shifted_series());
800 let new = cal(&format!(
801 "{}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260119T100000Z\nDTSTART:20260119T150000Z\n\
802 DTEND:20260119T160000Z\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n",
803 shifted_series()
804 ));
805 let (store, _) = itip::organize(Some(&old), Some(new), &is(ALICE), now());
806 let store = text(&store.unwrap());
807 assert!(store.contains("SEQUENCE:1"), "{store}");
808 assert!(
809 store.contains(&format!("PARTSTAT=NEEDS-ACTION:{BOB}")),
810 "{store}"
811 );
812}
813
814#[test]
815fn a_changed_exrule_is_a_reschedule() {
816 let old = meeting(&format!(
817 "ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEXRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=1\n"
818 ));
819 let removed: String = text(&old)
820 .split_inclusive("\r\n")
821 .filter(|l| !l.starts_with("EXRULE"))
822 .collect();
823 assert_ne!(removed, text(&old));
824 let (store, _) = itip::organize(
825 Some(&old),
826 Some(ICalendar::parse(&removed).unwrap()),
827 &is(ALICE),
828 now(),
829 );
830 let store = text(&store.unwrap());
831 assert!(
832 store.contains(&format!("PARTSTAT=NEEDS-ACTION:{BOB}")),
833 "{store}"
834 );
835}
836
837#[test]
838fn a_rule_in_another_form_is_the_same_rule() {
839 let old = text(&meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n"))).replace(
840 "RRULE:FREQ=WEEKLY;COUNT=4",
841 "RRULE:FREQ=WEEKLY;BYDAY=MO,TU;COUNT=4",
842 );
843 let old = ICalendar::parse(&old).unwrap();
844 // bob's client writes the rule back in its own form.
845 let new = text(&old)
846 .replace(
847 "RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO,TU",
848 "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU,MO;WKST=MO;COUNT=4",
849 )
850 .replace(
851 &format!("PARTSTAT=NEEDS-ACTION:{BOB}"),
852 &format!("PARTSTAT=ACCEPTED:{BOB}"),
853 );
854 assert_ne!(new, text(&old));
855 let (_, reply) = itip::attend(&old, ICalendar::parse(&new).unwrap(), &is(BOB), now()).unwrap();
856 assert!(reply.is_some());
857 // The same rewrite by the organizer keeps bob's answer.
858 let accepted = ICalendar::parse(text(&old).replace(
859 &format!("PARTSTAT=NEEDS-ACTION:{BOB}"),
860 &format!("PARTSTAT=ACCEPTED:{BOB}"),
861 ))
862 .unwrap();
863 let rewritten = text(&accepted).replace(
864 "RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO,TU",
865 "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU,MO;WKST=MO;COUNT=4",
866 );
867 let (store, _) = itip::organize(
868 Some(&accepted),
869 Some(ICalendar::parse(&rewritten).unwrap()),
870 &is(ALICE),
871 now(),
872 );
873 assert!(
874 text(&store.unwrap()).contains(&format!("PARTSTAT=ACCEPTED:{BOB}")),
875 "rule rewrite reset bob"
876 );
877}
878
879#[test]
880fn a_cancelled_range_cancels_the_later_instances() {
881 use pimdav::expand::expand;
882 use pimdav::zone::Zone;
883 let copy = meeting(&format!("ATTENDEE:{BOB}\n"));
884 let msg = Message {
885 to: BOB.to_string(),
886 method: Method::Cancel,
887 quiet: false,
888 cal: cal(&format!(
889 "METHOD:CANCEL\nBEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\
890 DTSTART:20260112T100000Z\nSEQUENCE:1\nSTATUS:CANCELLED\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n"
891 )),
892 };
893 use pimdav::calcard::icalendar::{ICalendarProperty, ICalendarStatus, ICalendarValue};
894 let got = itip::receive(Some(&copy), &msg).unwrap();
895 let mut out = expand(&got, at(1, 0)..at(31, 0), Zone::Utc).instances;
896 out.sort_by_key(|i| i.start);
897 let cancelled: Vec<bool> = out
898 .iter()
899 .map(|i| {
900 matches!(
901 got.components[i.component]
902 .property(&ICalendarProperty::Status)
903 .and_then(|e| e.values.first()),
904 Some(ICalendarValue::Status(ICalendarStatus::Cancelled))
905 )
906 })
907 .collect();
908 assert_eq!(cancelled, [false, true, true, true], "{}", text(&got));
909}
910
911#[test]
912fn a_range_an_attendee_is_left_out_of_ends_their_series() {
913 use pimdav::expand::expand;
914 use pimdav::zone::Zone;
915 let org = cal(&format!(
916 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
917 RRULE:FREQ=WEEKLY;COUNT=8\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\nEND:VEVENT\n\
918 BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260119T100000Z\n\
919 DTSTART:20260119T100000Z\nDTEND:20260119T110000Z\nORGANIZER:{ALICE}\n\
920 ATTENDEE:{CAROL}\nEND:VEVENT\n"
921 ));
922 let (_, msgs) = itip::organize(None, Some(org), &is(ALICE), now());
923 let bob = &to(&msgs, BOB).unwrap().cal;
924 let starts: Vec<_> = expand(
925 bob,
926 at(1, 0)..at(1, 0) + chrono::TimeDelta::days(90),
927 Zone::Utc,
928 )
929 .instances
930 .iter()
931 .map(|i| i.start)
932 .collect();
933 assert_eq!(starts, [at(5, 10), at(12, 10)], "{}", text(bob));
934 let carol = &to(&msgs, CAROL).unwrap().cal;
935 let window = at(1, 0)..at(1, 0) + chrono::TimeDelta::days(90);
936 assert_eq!(expand(carol, window, Zone::Utc).instances.len(), 8);
937}
938
939#[test]
940fn a_reply_status_keeps_only_its_code() {
941 let mut org = meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n"));
942 let reply = cal(&format!(
943 "METHOD:REPLY\nBEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nORGANIZER:{ALICE}\n\
944 ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nREQUEST-STATUS:2.0\",x:EVIL;Success\nEND:VEVENT\n"
945 ));
946 assert!(itip::apply_reply(&mut org, &reply, &is(BOB)));
947 let org = text(&org);
948 assert!(
949 org.contains(&format!("SCHEDULE-STATUS=2.0:{BOB}")) && !org.contains("EVIL"),
950 "{org}"
951 );
952}
953
954#[test]
955fn attendees_may_not_change_an_instance_through_its_override() {
956 let base = "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
957 RRULE:FREQ=WEEKLY;COUNT=4\n";
958 let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
959 let over = |start: &str, end: &str| {
960 format!(
961 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:2026011{start}00Z\n\
962 DTEND:2026011{end}00Z\n{people}END:VEVENT\n"
963 )
964 };
965 let copy = cal(&format!("{base}{people}END:VEVENT\n"));
966 // A new override at the right start but longer.
967 let longer = cal(&format!(
968 "{base}{people}END:VEVENT\n{}",
969 over("2T1000", "2T1200")
970 ));
971 assert_eq!(
972 itip::attend(&copy, longer, &is(BOB), now()).err(),
973 Some(Refused::AttendeeChange)
974 );
975 // Dropping the organizer's move of an instance.
976 let moved = cal(&format!(
977 "{base}{people}END:VEVENT\n{}",
978 over("2T1400", "2T1500")
979 ));
980 assert_eq!(
981 itip::attend(&moved, copy.clone(), &is(BOB), now()).err(),
982 Some(Refused::AttendeeChange)
983 );
984 // Dropping an override that only held a status is fine.
985 let own = cal(&format!(
986 "{base}{people}END:VEVENT\n{}",
987 over("2T1000", "2T1100")
988 ));
989 assert!(itip::attend(&own, copy, &is(BOB), now()).is_ok());
990}
991
992#[test]
993fn attendees_may_delete_an_instance_or_drop_one_of_their_instances() {
994 let base = "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
995 RRULE:FREQ=WEEKLY;COUNT=4\n";
996 let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
997 let moved = format!(
998 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T140000Z\n\
999 DTEND:20260112T150000Z\n{people}END:VEVENT\n"
1000 );
1001 // Deleting the instance the organizer moved: EXDATE and no override.
1002 let copy = cal(&format!("{base}{people}END:VEVENT\n{moved}"));
1003 let deleted = cal(&format!(
1004 "{base}EXDATE:20260112T100000Z\n{people}END:VEVENT\n"
1005 ));
1006 assert!(itip::attend(&copy, deleted, &is(BOB), now()).is_ok());
1007 // A copy of single instances: dropping one of them.
1008 let other = moved.replace("20260112T1", "20260119T1").replace(
1009 "RECURRENCE-ID:20260119T140000Z",
1010 "RECURRENCE-ID:20260119T100000Z",
1011 );
1012 let single = cal(&format!("{moved}{other}"));
1013 assert!(itip::attend(&single, cal(&other), &is(BOB), now()).is_ok());
1014}
1015
1016#[test]
1017fn an_override_without_end_has_the_default_length() {
1018 let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
1019 for (master, rid, start, end) in [
1020 (
1021 "DTSTART;VALUE=DATE:20260105",
1022 "RECURRENCE-ID;VALUE=DATE:20260112",
1023 "DTSTART;VALUE=DATE:20260112",
1024 "DTEND;VALUE=DATE:20260113",
1025 ),
1026 (
1027 "DTSTART:20260105T100000Z",
1028 "RECURRENCE-ID:20260112T100000Z",
1029 "DTSTART:20260112T100000Z",
1030 "DTEND:20260112T100000Z",
1031 ),
1032 ] {
1033 let series = format!(
1034 "BEGIN:VEVENT\nUID:m1\n{master}\nRRULE:FREQ=WEEKLY;COUNT=4\n{people}END:VEVENT\n"
1035 );
1036 let copy = cal(&series);
1037 let own = cal(&format!(
1038 "{series}BEGIN:VEVENT\nUID:m1\n{rid}\n{start}\n{end}\n{people}END:VEVENT\n"
1039 ));
1040 assert!(
1041 itip::attend(&copy, own, &is(BOB), now()).is_ok(),
1042 "{master}"
1043 );
1044 }
1045}
1046
1047#[test]
1048fn attendees_may_not_drop_a_cancelled_instance() {
1049 let series = format!(
1050 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1051 RRULE:FREQ=WEEKLY;COUNT=4\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n\
1052 END:VEVENT\n"
1053 );
1054 let cancelled = cal(&format!(
1055 "{series}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\n\
1056 DTSTART:20260112T100000Z\nDTEND:20260112T110000Z\nSTATUS:CANCELLED\n\
1057 ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n"
1058 ));
1059 assert_eq!(
1060 itip::attend(&cancelled, cal(&series), &is(BOB), now()).err(),
1061 Some(Refused::AttendeeChange)
1062 );
1063}
1064
1065#[test]
1066fn a_range_from_the_first_instance_cancels_the_whole_series() {
1067 let org = cal(&format!(
1068 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1069 RRULE:FREQ=WEEKLY;COUNT=8\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\nEND:VEVENT\n\
1070 BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260105T100000Z\n\
1071 DTSTART:20260105T120000Z\nDTEND:20260105T130000Z\nORGANIZER:{ALICE}\n\
1072 ATTENDEE:{CAROL}\nEND:VEVENT\n"
1073 ));
1074 let old = cal(&format!(
1075 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1076 RRULE:FREQ=WEEKLY;COUNT=8\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\nEND:VEVENT\n"
1077 ));
1078 let (_, msgs) = itip::organize(Some(&old), Some(org), &is(ALICE), now());
1079 let bob = to(&msgs, BOB).unwrap();
1080 assert_eq!(bob.method, Method::Cancel, "{}", text(&bob.cal));
1081}
1082
1083#[test]
1084fn an_attendee_may_write_the_same_end_in_another_form() {
1085 let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
1086 for (stored, written) in [
1087 (
1088 "DTSTART:20260105T100000Z\nDURATION:PT1H",
1089 "DTSTART:20260105T100000Z\nDTEND:20260105T110000Z",
1090 ),
1091 (
1092 "DTSTART;VALUE=DATE:20260105",
1093 "DTSTART;VALUE=DATE:20260105\nDTEND;VALUE=DATE:20260106",
1094 ),
1095 (
1096 "DTSTART:20260105T100000Z",
1097 "DTSTART:20260105T100000Z\nDTEND:20260105T100000Z",
1098 ),
1099 // A nominal day across the October change lasts 25 hours.
1100 (
1101 "DTSTART;TZID=Europe/Berlin:20261024T120000\nDURATION:P1D",
1102 "DTSTART;TZID=Europe/Berlin:20261024T120000\nDTEND;TZID=Europe/Berlin:20261025T120000",
1103 ),
1104 ] {
1105 let event = |times: &str| {
1106 cal(&format!(
1107 "BEGIN:VEVENT\nUID:m1\n{times}\nRRULE:FREQ=WEEKLY;COUNT=4\n{people}END:VEVENT\n"
1108 ))
1109 };
1110 let accepted = text(&event(written)).replace(
1111 &format!("ATTENDEE:{BOB}"),
1112 &format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}"),
1113 );
1114 let (_, reply) = itip::attend(
1115 &event(stored),
1116 ICalendar::parse(&accepted).unwrap(),
1117 &is(BOB),
1118 now(),
1119 )
1120 .unwrap_or_else(|e| panic!("{stored}: {e:?}"));
1121 assert!(reply.is_some(), "{stored}");
1122 }
1123}
1124
1125#[test]
1126fn a_new_override_that_changes_the_length_is_a_reschedule() {
1127 let old = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
1128 let new = cal(&format!(
1129 "{}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\n\
1130 DTEND:20260112T130000Z\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n",
1131 text(&old)
1132 .trim_start_matches("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n")
1133 .trim_end_matches("END:VCALENDAR\r\n")
1134 .replace("\r\n", "\n")
1135 ));
1136 let (store, _) = itip::organize(Some(&old), Some(new), &is(ALICE), now());
1137 let store = text(&store.unwrap());
1138 let longer = store.split("RECURRENCE-ID").nth(1).unwrap();
1139 assert!(longer.contains("SEQUENCE:1"), "{store}");
1140 assert!(
1141 longer.contains(&format!("PARTSTAT=NEEDS-ACTION:{BOB}")),
1142 "{store}"
1143 );
1144}
1145
1146#[test]
1147fn an_attendee_may_complete_one_instance() {
1148 let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
1149 let series = format!(
1150 "BEGIN:VTODO\nUID:t1\nDTSTART:20260105T100000Z\nDUE:20260105T110000Z\n\
1151 RRULE:FREQ=WEEKLY;COUNT=4\n{people}END:VTODO\n"
1152 );
1153 let done = cal(&format!(
1154 "{series}BEGIN:VTODO\nUID:t1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\n\
1155 DUE:20260112T110000Z\nSTATUS:COMPLETED\n{people}END:VTODO\n"
1156 ));
1157 assert!(itip::attend(&cal(&series), done, &is(BOB), now()).is_ok());
1158}
1159
1160#[test]
1161fn attendees_keep_the_organizers_status_of_an_instance() {
1162 let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
1163 let master = format!(
1164 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1165 RRULE:FREQ=WEEKLY;COUNT=4\nSTATUS:CONFIRMED\n{people}END:VEVENT\n"
1166 );
1167 let instance = |status: &str| {
1168 format!(
1169 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\n\
1170 DTEND:20260112T110000Z\nSTATUS:{status}\n{people}END:VEVENT\n"
1171 )
1172 };
1173 let tentative = cal(&format!("{master}{}", instance("TENTATIVE")));
1174 assert_eq!(
1175 itip::attend(&tentative, cal(&master), &is(BOB), now()).err(),
1176 Some(Refused::AttendeeChange)
1177 );
1178 let added = cal(&format!("{master}{}", instance("TENTATIVE")));
1179 assert_eq!(
1180 itip::attend(&cal(&master), added, &is(BOB), now()).err(),
1181 Some(Refused::AttendeeChange)
1182 );
1183 let same = cal(&format!("{master}{}", instance("CONFIRMED")));
1184 assert!(itip::attend(&cal(&master), same, &is(BOB), now()).is_ok());
1185}
1186
1187#[test]
1188fn dropping_a_declined_instance_replies_with_the_series_answer() {
1189 let master = format!(
1190 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1191 RRULE:FREQ=WEEKLY;COUNT=4\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n\
1192 ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n"
1193 );
1194 let copy = cal(&format!(
1195 "{master}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\n\
1196 DTEND:20260112T110000Z\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n\
1197 ATTENDEE;PARTSTAT=DECLINED:{BOB}\nEND:VEVENT\n"
1198 ));
1199 let (_, reply) = itip::attend(&copy, cal(&master), &is(BOB), now()).unwrap();
1200 let reply = text(&reply.unwrap().cal);
1201 assert!(reply.contains("RECURRENCE-ID:20260112T100000Z"), "{reply}");
1202 assert!(
1203 reply.contains(&format!("PARTSTAT=ACCEPTED:{BOB}")),
1204 "{reply}"
1205 );
1206}
1207
1208#[test]
1209fn a_reordered_save_sends_nothing() {
1210 let old = meeting(&format!(
1211 "ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nLOCATION:Room 1\n"
1212 ));
1213 let (store, _) = itip::organize(None, Some(old), &is(ALICE), now());
1214 let old = store.unwrap();
1215 let reordered = text(&old)
1216 .replace("SUMMARY:Sync\r\n", "")
1217 .replace("LOCATION:Room 1\r\n", "LOCATION:Room 1\r\nSUMMARY:Sync\r\n")
1218 .replace("DTSTAMP:20260101T000000Z", "DTSTAMP:20260102T000000Z");
1219 let (_, msgs) = itip::organize(
1220 Some(&old),
1221 Some(ICalendar::parse(&reordered).unwrap()),
1222 &is(ALICE),
1223 now(),
1224 );
1225 assert!(to(&msgs, BOB).is_none(), "{msgs:?}");
1226}
1227
1228#[test]
1229fn an_override_of_no_instance_is_refused_and_many_overrides_stay_fast() {
1230 let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
1231 let series = format!(
1232 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1233 RRULE:FREQ=DAILY;COUNT=5000\n{people}END:VEVENT\n"
1234 );
1235 let over = |rid: &str| {
1236 format!(
1237 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:{rid}\nDTSTART:{rid}\n\
1238 DTEND:{}\n{people}END:VEVENT\n",
1239 rid.replace("T10", "T11")
1240 )
1241 };
1242 let stray = cal(&format!("{series}{}", over("20260112T103000Z")));
1243 assert_eq!(
1244 itip::attend(&cal(&series), stray, &is(BOB), now()).err(),
1245 Some(Refused::AttendeeChange)
1246 );
1247 let day = |i: i64| {
1248 (chrono::NaiveDate::from_ymd_opt(2026, 1, 5).unwrap() + chrono::TimeDelta::days(i))
1249 .format("%Y%m%dT100000Z")
1250 .to_string()
1251 };
1252 let many: String = (0..3000)
1253 .map(|i| {
1254 over(&day(i)).replace(
1255 &format!("ATTENDEE:{BOB}"),
1256 &format!("ATTENDEE;PARTSTAT=DECLINED:{BOB}"),
1257 )
1258 })
1259 .collect();
1260 let t = std::time::Instant::now();
1261 let (_, reply) = itip::attend(
1262 &cal(&series),
1263 cal(&format!("{series}{many}")),
1264 &is(BOB),
1265 now(),
1266 )
1267 .unwrap();
1268 let mut org = cal(&series);
1269 assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB)));
1270 assert!(t.elapsed().as_secs() < 20, "{:?}", t.elapsed());
1271}
1272
1273#[test]
1274fn far_apart_replies_on_a_dense_series_all_land() {
1275 let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
1276 let series = format!(
1277 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDURATION:PT1M\n\
1278 RRULE:FREQ=MINUTELY\n{people}END:VEVENT\n"
1279 );
1280 let declined = |rid: &str| {
1281 format!(
1282 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:{rid}\nDTSTART:{rid}\nDURATION:PT1M\n\
1283 ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT=DECLINED:{BOB}\nEND:VEVENT\n"
1284 )
1285 };
1286 let copy = format!(
1287 "{series}{}{}",
1288 declined("20260105T100000Z"),
1289 declined("20280105T100000Z")
1290 );
1291 let (_, reply) = itip::attend(&cal(&series), cal(&copy), &is(BOB), now()).unwrap();
1292 let mut org = cal(&series);
1293 assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB)));
1294 assert_eq!(
1295 text(&org).matches("PARTSTAT=DECLINED").count(),
1296 2,
1297 "{}",
1298 text(&org)
1299 );
1300}
1301
1302#[test]
1303fn a_stale_range_past_the_series_cuts_nothing() {
1304 let org = cal(&format!(
1305 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1306 RRULE:FREQ=DAILY;COUNT=3\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\nEND:VEVENT\n\
1307 BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260115T100000Z\n\
1308 DTSTART:20260115T120000Z\nDTEND:20260115T130000Z\nORGANIZER:{ALICE}\n\
1309 ATTENDEE:{CAROL}\nEND:VEVENT\n"
1310 ));
1311 let (_, msgs) = itip::organize(None, Some(org), &is(ALICE), now());
1312 let bob = text(&to(&msgs, BOB).unwrap().cal);
1313 assert!(bob.contains("COUNT=3"), "{bob}");
1314 assert!(!bob.contains("UNTIL"), "{bob}");
1315}
1316