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)).changed);
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)).changed);
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!(
328 !itip::apply_reply(&mut org, &reply(rid), &is(BOB)).changed,
329 "{rid}"
330 );
331 }
332 assert!(itip::apply_reply(&mut org, &reply("20260112T100000Z"), &is(BOB)).changed);
333}
334
335#[test]
336fn attendees_may_not_move_the_meeting() {
337 let copy = meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n"));
338 let moved = text(&copy).replace("DTSTART:20260105T100000Z", "DTSTART:20260105T120000Z");
339 assert_eq!(
340 itip::attend(&copy, ICalendar::parse(&moved).unwrap(), &is(BOB), now()).err(),
341 Some(Refused::AttendeeChange)
342 );
343 // Alarms and transparency are the attendee's.
344 let own = text(&copy).replace(
345 "SUMMARY:Sync",
346 "SUMMARY:Sync\r\nTRANSP:TRANSPARENT\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT5M\r\nEND:VALARM",
347 );
348 let (_, reply) = itip::attend(&copy, ICalendar::parse(&own).unwrap(), &is(BOB), now()).unwrap();
349 assert!(reply.is_none());
350}
351
352#[test]
353fn attendee_delete_declines() {
354 let copy = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
355 let reply = itip::decline(&copy, &is(BOB), now()).unwrap();
356 assert!(text(&reply.cal).contains(&format!("PARTSTAT=DECLINED:{BOB}")));
357 let client = text(&copy).replace(
358 &format!("ORGANIZER:{ALICE}"),
359 &format!("ORGANIZER;SCHEDULE-AGENT=CLIENT:{ALICE}"),
360 );
361 assert!(itip::decline(&ICalendar::parse(&client).unwrap(), &is(BOB), now()).is_none());
362}
363
364#[test]
365fn updates_keep_what_the_attendee_owns() {
366 let org = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
367 let (_, msgs) = itip::organize(None, Some(org.clone()), &is(ALICE), now());
368 let copy = itip::receive(None, to(&msgs, BOB).unwrap()).unwrap();
369 let copy = ICalendar::parse(text(&copy).replace(
370 "SUMMARY:Sync",
371 "SUMMARY:Sync\r\nTRANSP:TRANSPARENT\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT5M\r\nEND:VALARM",
372 ))
373 .unwrap();
374 let retitled =
375 ICalendar::parse(text(&org).replace("SUMMARY:Sync", "SUMMARY:Planning")).unwrap();
376 let (_, msgs) = itip::organize(Some(&org), Some(retitled), &is(ALICE), now());
377 let updated = text(&itip::receive(Some(&copy), to(&msgs, BOB).unwrap()).unwrap());
378 assert!(
379 updated.contains("SUMMARY:Planning")
380 && updated.contains("TRANSP:TRANSPARENT")
381 && updated.contains("TRIGGER:-PT5M"),
382 "{updated}"
383 );
384
385 let (_, msgs) = itip::organize(Some(&org), None, &is(ALICE), now());
386 let cancelled = text(&itip::receive(Some(&copy), to(&msgs, BOB).unwrap()).unwrap());
387 assert!(cancelled.contains("STATUS:CANCELLED"), "{cancelled}");
388}
389
390#[test]
391fn sender_is_named_for_the_owner() {
392 let mut m = meeting(&format!("ATTENDEE:{BOB}\n"));
393 itip::stamp_sender(&mut m, &is(ALICE), Some(BOB));
394 let got = text(&m);
395 assert!(
396 got.contains(&format!("ORGANIZER;SENT-BY=\"{BOB}\":{ALICE}")),
397 "{got}"
398 );
399 assert!(!got.contains(&format!("SENT-BY=\"{BOB}\":{BOB}")), "{got}");
400 // An edit by the owner drops it again.
401 itip::stamp_sender(&mut m, &is(ALICE), None);
402 assert!(!text(&m).contains("SENT-BY"), "{}", text(&m));
403
404 // Who sent it is no reason to message the attendees.
405 let old = meeting(&format!("ATTENDEE:{BOB}\n"));
406 let mut new = old.clone();
407 itip::stamp_sender(&mut new, &is(ALICE), Some(BOB));
408 let msgs = itip::messages(Some(&old), Some(&new), &is(ALICE), &[], now());
409 assert!(msgs.is_empty(), "{msgs:?}");
410}
411
412#[test]
413fn rooms_answer_from_their_bookings() {
414 use pimdav::freebusy::{Busy, Period};
415 use pimdav::zone::Zone;
416 const ROOM: &str = "mailto:board@rooms.dovenest.invalid";
417 let at = |d: u32, h: u32| Utc.with_ymd_and_hms(2026, 1, d, h, 0, 0).unwrap();
418 // The weekly meeting runs on 5, 12, 19 and 26 January, 10:00 to 11:00.
419 let copy = meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{ROOM}\n"));
420 let window = at(1, 0)..at(31, 0);
421
422 let free = itip::auto_answer(&copy, &is(ROOM), &[], &window, &Zone::Utc);
423 assert!(
424 text(&free).contains(&format!("PARTSTAT=ACCEPTED:{ROOM}")),
425 "{}",
426 text(&free)
427 );
428
429 let taken = [Period {
430 kind: Busy::Busy,
431 start: at(12, 10),
432 end: at(12, 11),
433 }];
434 let answer = itip::auto_answer(&copy, &is(ROOM), &taken, &window, &Zone::Utc);
435 let got = text(&answer);
436 assert!(got.contains("RECURRENCE-ID:20260112T100000Z"), "{got}");
437 assert_eq!(
438 got.matches(&format!("PARTSTAT=DECLINED:{ROOM}")).count(),
439 1,
440 "{got}"
441 );
442 assert_eq!(
443 got.matches(&format!("PARTSTAT=ACCEPTED:{ROOM}")).count(),
444 1,
445 "{got}"
446 );
447
448 // The answer travels as a REPLY for the series and the one instance.
449 let (_, reply) = itip::attend(&copy, answer, &is(ROOM), now()).unwrap();
450 let reply = reply.unwrap();
451 assert_eq!(reply.method, Method::Reply);
452 assert!(text(&reply.cal).contains("RECURRENCE-ID:20260112T100000Z"));
453
454 // A single event on a taken slot is declined as a whole.
455 let single = cal(&format!(
456 "BEGIN:VEVENT\nUID:s1\nDTSTART:20260112T103000Z\nDURATION:PT1H\nORGANIZER:{ALICE}\n\
457 ATTENDEE:{ROOM}\nEND:VEVENT\n"
458 ));
459 let got = text(&itip::auto_answer(
460 &single,
461 &is(ROOM),
462 &taken,
463 &window,
464 &Zone::Utc,
465 ));
466 assert!(got.contains(&format!("PARTSTAT=DECLINED:{ROOM}")), "{got}");
467}
468
469#[test]
470fn forget_a_deleted_principal() {
471 let gone = is(BOB);
472 let tomb = "mailto:bob-7@deleted.dovenest.invalid";
473
474 // In alice's meeting bob becomes a tombstone that reaches no one.
475 let org = format!(
476 "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"
477 );
478 let out = itip::forget(&org, &gone, tomb).unwrap();
479 // The longer line is folded at 75 octets.
480 assert_eq!(
481 out.replace("\r\n ", ""),
482 org.replace(
483 &format!("ATTENDEE;CN=Bob;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=1.2:{BOB}"),
484 &format!("ATTENDEE;CN=Bob;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=3.7:{tomb}")
485 )
486 );
487
488 // A copy of bob's meeting is cancelled, the override too; LF stays LF.
489 let copy = format!(
490 "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",
491 BOB.to_uppercase()
492 );
493 let out = itip::forget(&copy, &gone, tomb).unwrap();
494 assert_eq!(
495 out,
496 format!(
497 "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"
498 )
499 );
500
501 assert_eq!(
502 itip::forget(&org, &is(CAROL.trim_end_matches('m')), tomb),
503 None
504 );
505}
506
507const ROOM: &str = "mailto:board@rooms.dovenest.invalid";
508
509fn at(d: u32, h: u32) -> chrono::DateTime<Utc> {
510 Utc.with_ymd_and_hms(2026, 1, d, h, 0, 0).unwrap()
511}
512
513fn booked(start: chrono::DateTime<Utc>, end: chrono::DateTime<Utc>) -> pimdav::freebusy::Period {
514 pimdav::freebusy::Period {
515 kind: pimdav::freebusy::Busy::Busy,
516 start,
517 end,
518 }
519}
520
521#[test]
522fn a_room_declines_a_series_with_many_conflicts_as_a_whole() {
523 use pimdav::zone::Zone;
524 let copy = cal(&format!(
525 "BEGIN:VEVENT\nUID:h1\nDTSTART:20260105T000000Z\nDURATION:PT30M\nRRULE:FREQ=HOURLY;COUNT=200\n\
526 ORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n"
527 ));
528 let taken = [booked(at(1, 0), at(31, 0))];
529 let got = text(&itip::auto_answer(
530 &copy,
531 &is(ROOM),
532 &taken,
533 &(at(1, 0)..at(31, 0)),
534 &Zone::Utc,
535 ));
536 assert!(!got.contains("RECURRENCE-ID"), "{got}");
537 assert!(got.contains(&format!("PARTSTAT=DECLINED:{ROOM}")), "{got}");
538}
539
540#[test]
541fn a_room_declines_one_instance_a_this_and_future_override_moves() {
542 use pimdav::zone::Zone;
543 // From the 12th on, the meeting runs two hours later.
544 let copy = cal(&format!(
545 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\nRRULE:FREQ=WEEKLY;COUNT=4\n\
546 ORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n\
547 BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\
548 DTSTART:20260112T120000Z\nDTEND:20260112T130000Z\nORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n"
549 ));
550 let taken = [booked(at(19, 12), at(19, 13))];
551 let answer = itip::auto_answer(&copy, &is(ROOM), &taken, &(at(1, 0)..at(31, 0)), &Zone::Utc);
552 let got = text(&answer);
553 assert!(
554 got.contains("RECURRENCE-ID:20260119T100000Z\r\nDTSTART:20260119T120000Z")
555 || got.contains("DTSTART:20260119T120000Z\r\nRECURRENCE-ID:20260119T100000Z"),
556 "{got}"
557 );
558 assert_eq!(
559 got.matches(&format!("PARTSTAT=DECLINED:{ROOM}")).count(),
560 1,
561 "{got}"
562 );
563 assert_eq!(
564 got.matches(&format!("PARTSTAT=ACCEPTED:{ROOM}")).count(),
565 2,
566 "{got}"
567 );
568
569 // The moved override passes as the room's own answer, and the
570 // organizer's copy takes it.
571 let (_, reply) = itip::attend(&copy, answer, &is(ROOM), now()).unwrap();
572 let mut org = copy.clone();
573 assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(ROOM)).changed);
574 let org = text(&org);
575 assert!(org.contains("DTSTART:20260119T120000Z"), "{org}");
576}
577
578#[test]
579fn a_client_scheduled_attendee_gets_no_cancel() {
580 let old = meeting(&format!("ATTENDEE:{BOB}\n"));
581 let new = meeting(&format!("ATTENDEE;SCHEDULE-AGENT=CLIENT:{BOB}\n"));
582 let (_, msgs) = itip::organize(Some(&old), Some(new), &is(ALICE), now());
583 assert!(to(&msgs, BOB).is_none(), "{msgs:?}");
584}
585
586#[test]
587fn the_highest_sequence_does_not_overflow() {
588 let old = meeting(&format!(
589 "SEQUENCE:9223372036854775807\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\n"
590 ));
591 let moved = text(&old)
592 .replace("DTSTART:20260105T100000Z", "DTSTART:20260105T090000Z")
593 .replace(&format!("ATTENDEE:{CAROL}\r\n"), "");
594 let (store, msgs) = itip::organize(
595 Some(&old),
596 Some(ICalendar::parse(&moved).unwrap()),
597 &is(ALICE),
598 now(),
599 );
600 assert!(text(&store.unwrap()).contains("SEQUENCE:9223372036854775807"));
601 assert!(text(&to(&msgs, CAROL).unwrap().cal).contains("SEQUENCE:9223372036854775807"));
602}
603
604#[test]
605fn answering_an_instance_the_series_lacks_changes_nothing() {
606 use pimdav::calcard::icalendar::ICalendarParticipationStatus::Declined;
607 use pimdav::zone::Zone;
608 let copy = meeting(&format!("ATTENDEE:{BOB}\n"));
609 // A Tuesday is no instance.
610 assert!(itip::respond(&copy, &is(BOB), Declined, Some(at(13, 10)), &Zone::Utc).is_none());
611 let got =
612 text(&itip::respond(&copy, &is(BOB), Declined, Some(at(12, 10)), &Zone::Utc).unwrap());
613 assert!(got.contains("RECURRENCE-ID:20260112T100000Z"), "{got}");
614 assert_eq!(
615 got.matches(&format!("PARTSTAT=DECLINED:{BOB}")).count(),
616 1,
617 "{got}"
618 );
619}
620
621#[test]
622fn a_date_until_on_the_same_day_extends_the_series() {
623 let old = text(&meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n")))
624 .replace("COUNT=4", "UNTIL=20260119T000000Z");
625 let old = ICalendar::parse(&old).unwrap();
626 // The DATE includes the instance at 10:00 on the 19th.
627 let longer = text(&old).replace("UNTIL=20260119T000000Z", "UNTIL=20260119");
628 let (store, _) = itip::organize(
629 Some(&old),
630 Some(ICalendar::parse(&longer).unwrap()),
631 &is(ALICE),
632 now(),
633 );
634 let store = text(&store.unwrap());
635 assert!(
636 store.contains(&format!("PARTSTAT=NEEDS-ACTION:{BOB}")),
637 "{store}"
638 );
639}
640
641#[test]
642fn a_room_declines_only_the_first_instance_of_a_this_and_future_override() {
643 use pimdav::calcard::icalendar::ICalendarParticipationStatus::Declined;
644 use pimdav::zone::Zone;
645 // From the 12th on, the meeting runs two hours later.
646 let copy = cal(&format!(
647 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\nRRULE:FREQ=WEEKLY;COUNT=4\n\
648 ORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n\
649 BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\
650 DTSTART:20260112T120000Z\nDTEND:20260112T130000Z\nORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n"
651 ));
652 let taken = [booked(at(12, 12), at(12, 13))];
653 let answer = itip::auto_answer(&copy, &is(ROOM), &taken, &(at(1, 0)..at(31, 0)), &Zone::Utc);
654 let got = text(&answer);
655 assert!(got.contains("RECURRENCE-ID:20260112T100000Z"), "{got}");
656 assert!(
657 got.contains("RECURRENCE-ID;RANGE=THISANDFUTURE:20260119T100000Z"),
658 "{got}"
659 );
660 assert!(got.contains("DTSTART:20260119T120000Z"), "{got}");
661 let declined = format!("PARTSTAT=DECLINED:{ROOM}");
662 let accepted = format!("PARTSTAT=ACCEPTED:{ROOM}");
663 assert_eq!(got.matches(&declined).count(), 1, "{got}");
664 assert_eq!(got.matches(&accepted).count(), 2, "{got}");
665
666 // The organizer's copy splits its range the same way.
667 let (_, reply) = itip::attend(&copy, answer, &is(ROOM), now()).unwrap();
668 let mut org = copy.clone();
669 assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(ROOM)).changed);
670 let org = text(&org);
671 assert!(
672 org.contains("RECURRENCE-ID;RANGE=THISANDFUTURE:20260119T100000Z"),
673 "{org}"
674 );
675 assert_eq!(org.matches("PARTSTAT=DECLINED").count(), 1, "{org}");
676 assert_eq!(org.matches("PARTSTAT=ACCEPTED").count(), 2, "{org}");
677
678 // So does an answer for that one instance from the web.
679 let got =
680 text(&itip::respond(&copy, &is(ROOM), Declined, Some(at(12, 10)), &Zone::Utc).unwrap());
681 assert!(
682 got.contains("RECURRENCE-ID;RANGE=THISANDFUTURE:20260119T100000Z"),
683 "{got}"
684 );
685 assert_eq!(got.matches(&declined).count(), 1, "{got}");
686}
687
688#[test]
689fn a_new_override_takes_the_others_state_from_its_range() {
690 let old = cal(&format!(
691 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\nRRULE:FREQ=WEEKLY;COUNT=4\n\
692 ORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE;PARTSTAT=NEEDS-ACTION:{CAROL}\nEND:VEVENT\n\
693 BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\
694 DTSTART:20260112T120000Z\nDTEND:20260112T130000Z\nORGANIZER:{ALICE}\n\
695 ATTENDEE:{BOB}\nATTENDEE;PARTSTAT=ACCEPTED:{CAROL}\nEND:VEVENT\n"
696 ));
697 let new = cal(&format!(
698 "{}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260119T100000Z\nDTSTART:20260119T120000Z\n\
699 DTEND:20260119T130000Z\nORGANIZER:{ALICE}\nATTENDEE;PARTSTAT=DECLINED:{BOB}\n\
700 ATTENDEE;PARTSTAT=NEEDS-ACTION:{CAROL}\nEND:VEVENT\n",
701 text(&old)
702 .trim_start_matches("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n")
703 .trim_end_matches("END:VCALENDAR\r\n")
704 .replace("\r\n", "\n")
705 ));
706 let (store, reply) = itip::attend(&old, new, &is(BOB), now()).unwrap();
707 let store = text(&store);
708 assert_eq!(
709 store.matches(&format!("PARTSTAT=ACCEPTED:{CAROL}")).count(),
710 2,
711 "{store}"
712 );
713 assert!(reply.is_some());
714}
715
716#[test]
717fn an_attendee_dropped_from_the_master_gets_a_cancel_despite_a_client_override() {
718 let over = |agent: &str| {
719 format!(
720 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T120000Z\n\
721 DTEND:20260112T130000Z\nORGANIZER:{ALICE}\nATTENDEE{agent}:{BOB}\nEND:VEVENT\n"
722 )
723 };
724 let series = |extra: &str| {
725 format!(
726 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
727 RRULE:FREQ=WEEKLY;COUNT=4\nORGANIZER:{ALICE}\nATTENDEE:{CAROL}\n{extra}END:VEVENT\n"
728 )
729 };
730 let old = cal(&format!(
731 "{}{}",
732 series(&format!("ATTENDEE:{BOB}\n")),
733 over("")
734 ));
735 let new = cal(&format!("{}{}", series(""), over(";SCHEDULE-AGENT=CLIENT")));
736 let (_, msgs) = itip::organize(Some(&old), Some(new), &is(ALICE), now());
737 let cancel = to(&msgs, BOB).expect("a CANCEL for the series");
738 assert_eq!(cancel.method, Method::Cancel);
739 assert!(
740 !text(&cancel.cal).contains("RECURRENCE-ID"),
741 "{}",
742 text(&cancel.cal)
743 );
744}
745
746/// alice's weekly meeting, two hours later from the 12th on, with bob in it.
747fn shifted_series() -> String {
748 format!(
749 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\nRRULE:FREQ=WEEKLY;COUNT=4\n\
750 ORGANIZER:{ALICE}\nATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n\
751 BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\
752 DTSTART:20260112T120000Z\nDTEND:20260112T130000Z\nORGANIZER:{ALICE}\n\
753 ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n"
754 )
755}
756
757#[test]
758fn a_new_override_at_the_shifted_time_is_no_move() {
759 let old = cal(&shifted_series());
760 let new = cal(&format!(
761 "{}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260119T100000Z\nDTSTART:20260119T120000Z\n\
762 DTEND:20260119T130000Z\nSUMMARY:renamed\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n",
763 shifted_series()
764 ));
765 let (store, _) = itip::organize(Some(&old), Some(new), &is(ALICE), now());
766 let store = text(&store.unwrap());
767 assert_eq!(
768 store.matches(&format!("PARTSTAT=ACCEPTED:{BOB}")).count(),
769 3,
770 "{store}"
771 );
772 assert!(!store.contains("SEQUENCE:1"), "{store}");
773}
774
775#[test]
776fn a_copy_without_its_master_keeps_the_shift() {
777 let range = shifted_series()
778 .split_inclusive("END:VEVENT\n")
779 .nth(1)
780 .unwrap()
781 .to_string();
782 let old = cal(&range);
783 let new = cal(&format!(
784 "{range}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260119T100000Z\nDTSTART:20260119T120000Z\n\
785 DTEND:20260119T130000Z\nSUMMARY:renamed\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n"
786 ));
787 let (store, _) = itip::organize(Some(&old), Some(new), &is(ALICE), now());
788 let store = text(&store.unwrap());
789 assert_eq!(
790 store.matches(&format!("PARTSTAT=ACCEPTED:{BOB}")).count(),
791 2,
792 "{store}"
793 );
794}
795
796#[test]
797fn a_new_override_that_moves_bumps_the_sequence() {
798 let old = cal(&shifted_series());
799 let new = cal(&format!(
800 "{}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260119T100000Z\nDTSTART:20260119T150000Z\n\
801 DTEND:20260119T160000Z\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n",
802 shifted_series()
803 ));
804 let (store, _) = itip::organize(Some(&old), Some(new), &is(ALICE), now());
805 let store = text(&store.unwrap());
806 assert!(store.contains("SEQUENCE:1"), "{store}");
807 assert!(
808 store.contains(&format!("PARTSTAT=NEEDS-ACTION:{BOB}")),
809 "{store}"
810 );
811}
812
813#[test]
814fn a_changed_exrule_is_a_reschedule() {
815 let old = meeting(&format!(
816 "ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEXRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=1\n"
817 ));
818 let removed: String = text(&old)
819 .split_inclusive("\r\n")
820 .filter(|l| !l.starts_with("EXRULE"))
821 .collect();
822 assert_ne!(removed, text(&old));
823 let (store, _) = itip::organize(
824 Some(&old),
825 Some(ICalendar::parse(&removed).unwrap()),
826 &is(ALICE),
827 now(),
828 );
829 let store = text(&store.unwrap());
830 assert!(
831 store.contains(&format!("PARTSTAT=NEEDS-ACTION:{BOB}")),
832 "{store}"
833 );
834}
835
836#[test]
837fn a_rule_in_another_form_is_the_same_rule() {
838 let old = text(&meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n"))).replace(
839 "RRULE:FREQ=WEEKLY;COUNT=4",
840 "RRULE:FREQ=WEEKLY;BYDAY=MO,TU;COUNT=4",
841 );
842 let old = ICalendar::parse(&old).unwrap();
843 // bob's client writes the rule back in its own form.
844 let new = text(&old)
845 .replace(
846 "RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO,TU",
847 "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU,MO;WKST=MO;COUNT=4",
848 )
849 .replace(
850 &format!("PARTSTAT=NEEDS-ACTION:{BOB}"),
851 &format!("PARTSTAT=ACCEPTED:{BOB}"),
852 );
853 assert_ne!(new, text(&old));
854 let (_, reply) = itip::attend(&old, ICalendar::parse(&new).unwrap(), &is(BOB), now()).unwrap();
855 assert!(reply.is_some());
856 // The same rewrite by the organizer keeps bob's answer.
857 let accepted = ICalendar::parse(text(&old).replace(
858 &format!("PARTSTAT=NEEDS-ACTION:{BOB}"),
859 &format!("PARTSTAT=ACCEPTED:{BOB}"),
860 ))
861 .unwrap();
862 let rewritten = text(&accepted).replace(
863 "RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO,TU",
864 "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU,MO;WKST=MO;COUNT=4",
865 );
866 let (store, _) = itip::organize(
867 Some(&accepted),
868 Some(ICalendar::parse(&rewritten).unwrap()),
869 &is(ALICE),
870 now(),
871 );
872 assert!(
873 text(&store.unwrap()).contains(&format!("PARTSTAT=ACCEPTED:{BOB}")),
874 "rule rewrite reset bob"
875 );
876}
877
878#[test]
879fn a_cancelled_range_cancels_the_later_instances() {
880 use pimdav::expand::expand;
881 use pimdav::zone::Zone;
882 let copy = meeting(&format!("ATTENDEE:{BOB}\n"));
883 let msg = Message {
884 to: BOB.to_string(),
885 method: Method::Cancel,
886 quiet: false,
887 cal: std::sync::Arc::new(cal(&format!(
888 "METHOD:CANCEL\nBEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\
889 DTSTART:20260112T100000Z\nSEQUENCE:1\nSTATUS:CANCELLED\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n"
890 ))),
891 };
892 use pimdav::calcard::icalendar::{ICalendarProperty, ICalendarStatus, ICalendarValue};
893 let got = itip::receive(Some(&copy), &msg).unwrap();
894 let mut out = expand(&got, at(1, 0)..at(31, 0), Zone::Utc).instances;
895 out.sort_by_key(|i| i.start);
896 let cancelled: Vec<bool> = out
897 .iter()
898 .map(|i| {
899 matches!(
900 got.components[i.component]
901 .property(&ICalendarProperty::Status)
902 .and_then(|e| e.values.first()),
903 Some(ICalendarValue::Status(ICalendarStatus::Cancelled))
904 )
905 })
906 .collect();
907 assert_eq!(cancelled, [false, true, true, true], "{}", text(&got));
908}
909
910#[test]
911fn a_range_an_attendee_is_left_out_of_ends_their_series() {
912 use pimdav::expand::expand;
913 use pimdav::zone::Zone;
914 let org = cal(&format!(
915 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
916 RRULE:FREQ=WEEKLY;COUNT=8\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\nEND:VEVENT\n\
917 BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260119T100000Z\n\
918 DTSTART:20260119T100000Z\nDTEND:20260119T110000Z\nORGANIZER:{ALICE}\n\
919 ATTENDEE:{CAROL}\nEND:VEVENT\n"
920 ));
921 let (_, msgs) = itip::organize(None, Some(org), &is(ALICE), now());
922 let bob = &to(&msgs, BOB).unwrap().cal;
923 let starts: Vec<_> = expand(
924 bob,
925 at(1, 0)..at(1, 0) + chrono::TimeDelta::days(90),
926 Zone::Utc,
927 )
928 .instances
929 .iter()
930 .map(|i| i.start)
931 .collect();
932 assert_eq!(starts, [at(5, 10), at(12, 10)], "{}", text(bob));
933 let carol = &to(&msgs, CAROL).unwrap().cal;
934 let window = at(1, 0)..at(1, 0) + chrono::TimeDelta::days(90);
935 assert_eq!(expand(carol, window, Zone::Utc).instances.len(), 8);
936}
937
938#[test]
939fn a_reply_status_keeps_only_its_code() {
940 let mut org = meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n"));
941 let reply = cal(&format!(
942 "METHOD:REPLY\nBEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nORGANIZER:{ALICE}\n\
943 ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nREQUEST-STATUS:2.0\",x:EVIL;Success\nEND:VEVENT\n"
944 ));
945 assert!(itip::apply_reply(&mut org, &reply, &is(BOB)).changed);
946 let org = text(&org);
947 assert!(
948 org.contains(&format!("SCHEDULE-STATUS=2.0:{BOB}")) && !org.contains("EVIL"),
949 "{org}"
950 );
951}
952
953#[test]
954fn attendees_may_not_change_an_instance_through_its_override() {
955 let base = "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
956 RRULE:FREQ=WEEKLY;COUNT=4\n";
957 let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
958 let over = |start: &str, end: &str| {
959 format!(
960 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:2026011{start}00Z\n\
961 DTEND:2026011{end}00Z\n{people}END:VEVENT\n"
962 )
963 };
964 let copy = cal(&format!("{base}{people}END:VEVENT\n"));
965 // A new override at the right start but longer.
966 let longer = cal(&format!(
967 "{base}{people}END:VEVENT\n{}",
968 over("2T1000", "2T1200")
969 ));
970 assert_eq!(
971 itip::attend(&copy, longer, &is(BOB), now()).err(),
972 Some(Refused::AttendeeChange)
973 );
974 // Dropping the organizer's move of an instance.
975 let moved = cal(&format!(
976 "{base}{people}END:VEVENT\n{}",
977 over("2T1400", "2T1500")
978 ));
979 assert_eq!(
980 itip::attend(&moved, copy.clone(), &is(BOB), now()).err(),
981 Some(Refused::AttendeeChange)
982 );
983 // Dropping an override that only held a status is fine.
984 let own = cal(&format!(
985 "{base}{people}END:VEVENT\n{}",
986 over("2T1000", "2T1100")
987 ));
988 assert!(itip::attend(&own, copy, &is(BOB), now()).is_ok());
989}
990
991#[test]
992fn attendees_may_delete_an_instance_or_drop_one_of_their_instances() {
993 let base = "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
994 RRULE:FREQ=WEEKLY;COUNT=4\n";
995 let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
996 let moved = format!(
997 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T140000Z\n\
998 DTEND:20260112T150000Z\n{people}END:VEVENT\n"
999 );
1000 // Deleting the instance the organizer moved: EXDATE and no override.
1001 let copy = cal(&format!("{base}{people}END:VEVENT\n{moved}"));
1002 let deleted = cal(&format!(
1003 "{base}EXDATE:20260112T100000Z\n{people}END:VEVENT\n"
1004 ));
1005 assert!(itip::attend(&copy, deleted, &is(BOB), now()).is_ok());
1006 // A copy of single instances: dropping one of them.
1007 let other = moved.replace("20260112T1", "20260119T1").replace(
1008 "RECURRENCE-ID:20260119T140000Z",
1009 "RECURRENCE-ID:20260119T100000Z",
1010 );
1011 let single = cal(&format!("{moved}{other}"));
1012 assert!(itip::attend(&single, cal(&other), &is(BOB), now()).is_ok());
1013}
1014
1015#[test]
1016fn an_override_without_end_has_the_default_length() {
1017 let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
1018 for (master, rid, start, end) in [
1019 (
1020 "DTSTART;VALUE=DATE:20260105",
1021 "RECURRENCE-ID;VALUE=DATE:20260112",
1022 "DTSTART;VALUE=DATE:20260112",
1023 "DTEND;VALUE=DATE:20260113",
1024 ),
1025 (
1026 "DTSTART:20260105T100000Z",
1027 "RECURRENCE-ID:20260112T100000Z",
1028 "DTSTART:20260112T100000Z",
1029 "DTEND:20260112T100000Z",
1030 ),
1031 ] {
1032 let series = format!(
1033 "BEGIN:VEVENT\nUID:m1\n{master}\nRRULE:FREQ=WEEKLY;COUNT=4\n{people}END:VEVENT\n"
1034 );
1035 let copy = cal(&series);
1036 let own = cal(&format!(
1037 "{series}BEGIN:VEVENT\nUID:m1\n{rid}\n{start}\n{end}\n{people}END:VEVENT\n"
1038 ));
1039 assert!(
1040 itip::attend(&copy, own, &is(BOB), now()).is_ok(),
1041 "{master}"
1042 );
1043 }
1044}
1045
1046#[test]
1047fn attendees_may_not_drop_a_cancelled_instance() {
1048 let series = format!(
1049 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1050 RRULE:FREQ=WEEKLY;COUNT=4\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n\
1051 END:VEVENT\n"
1052 );
1053 let cancelled = cal(&format!(
1054 "{series}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\n\
1055 DTSTART:20260112T100000Z\nDTEND:20260112T110000Z\nSTATUS:CANCELLED\n\
1056 ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n"
1057 ));
1058 assert_eq!(
1059 itip::attend(&cancelled, cal(&series), &is(BOB), now()).err(),
1060 Some(Refused::AttendeeChange)
1061 );
1062}
1063
1064#[test]
1065fn a_range_from_the_first_instance_cancels_the_whole_series() {
1066 let org = cal(&format!(
1067 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1068 RRULE:FREQ=WEEKLY;COUNT=8\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\nEND:VEVENT\n\
1069 BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260105T100000Z\n\
1070 DTSTART:20260105T120000Z\nDTEND:20260105T130000Z\nORGANIZER:{ALICE}\n\
1071 ATTENDEE:{CAROL}\nEND:VEVENT\n"
1072 ));
1073 let old = cal(&format!(
1074 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1075 RRULE:FREQ=WEEKLY;COUNT=8\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\nEND:VEVENT\n"
1076 ));
1077 let (_, msgs) = itip::organize(Some(&old), Some(org), &is(ALICE), now());
1078 let bob = to(&msgs, BOB).unwrap();
1079 assert_eq!(bob.method, Method::Cancel, "{}", text(&bob.cal));
1080}
1081
1082#[test]
1083fn an_attendee_may_write_the_same_end_in_another_form() {
1084 let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
1085 for (stored, written) in [
1086 (
1087 "DTSTART:20260105T100000Z\nDURATION:PT1H",
1088 "DTSTART:20260105T100000Z\nDTEND:20260105T110000Z",
1089 ),
1090 (
1091 "DTSTART;VALUE=DATE:20260105",
1092 "DTSTART;VALUE=DATE:20260105\nDTEND;VALUE=DATE:20260106",
1093 ),
1094 (
1095 "DTSTART:20260105T100000Z",
1096 "DTSTART:20260105T100000Z\nDTEND:20260105T100000Z",
1097 ),
1098 // A nominal day across the October change lasts 25 hours.
1099 (
1100 "DTSTART;TZID=Europe/Berlin:20261024T120000\nDURATION:P1D",
1101 "DTSTART;TZID=Europe/Berlin:20261024T120000\nDTEND;TZID=Europe/Berlin:20261025T120000",
1102 ),
1103 ] {
1104 let event = |times: &str| {
1105 cal(&format!(
1106 "BEGIN:VEVENT\nUID:m1\n{times}\nRRULE:FREQ=WEEKLY;COUNT=4\n{people}END:VEVENT\n"
1107 ))
1108 };
1109 let accepted = text(&event(written)).replace(
1110 &format!("ATTENDEE:{BOB}"),
1111 &format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}"),
1112 );
1113 let (_, reply) = itip::attend(
1114 &event(stored),
1115 ICalendar::parse(&accepted).unwrap(),
1116 &is(BOB),
1117 now(),
1118 )
1119 .unwrap_or_else(|e| panic!("{stored}: {e:?}"));
1120 assert!(reply.is_some(), "{stored}");
1121 }
1122}
1123
1124#[test]
1125fn a_new_override_that_changes_the_length_is_a_reschedule() {
1126 let old = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
1127 let new = cal(&format!(
1128 "{}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\n\
1129 DTEND:20260112T130000Z\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n",
1130 text(&old)
1131 .trim_start_matches("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n")
1132 .trim_end_matches("END:VCALENDAR\r\n")
1133 .replace("\r\n", "\n")
1134 ));
1135 let (store, _) = itip::organize(Some(&old), Some(new), &is(ALICE), now());
1136 let store = text(&store.unwrap());
1137 let longer = store.split("RECURRENCE-ID").nth(1).unwrap();
1138 assert!(longer.contains("SEQUENCE:1"), "{store}");
1139 assert!(
1140 longer.contains(&format!("PARTSTAT=NEEDS-ACTION:{BOB}")),
1141 "{store}"
1142 );
1143}
1144
1145#[test]
1146fn an_attendee_may_complete_one_instance() {
1147 let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
1148 let series = format!(
1149 "BEGIN:VTODO\nUID:t1\nDTSTART:20260105T100000Z\nDUE:20260105T110000Z\n\
1150 RRULE:FREQ=WEEKLY;COUNT=4\n{people}END:VTODO\n"
1151 );
1152 let done = cal(&format!(
1153 "{series}BEGIN:VTODO\nUID:t1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\n\
1154 DUE:20260112T110000Z\nSTATUS:COMPLETED\n{people}END:VTODO\n"
1155 ));
1156 assert!(itip::attend(&cal(&series), done, &is(BOB), now()).is_ok());
1157}
1158
1159#[test]
1160fn attendees_keep_the_organizers_status_of_an_instance() {
1161 let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
1162 let master = format!(
1163 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1164 RRULE:FREQ=WEEKLY;COUNT=4\nSTATUS:CONFIRMED\n{people}END:VEVENT\n"
1165 );
1166 let instance = |status: &str| {
1167 format!(
1168 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\n\
1169 DTEND:20260112T110000Z\nSTATUS:{status}\n{people}END:VEVENT\n"
1170 )
1171 };
1172 let tentative = cal(&format!("{master}{}", instance("TENTATIVE")));
1173 assert_eq!(
1174 itip::attend(&tentative, cal(&master), &is(BOB), now()).err(),
1175 Some(Refused::AttendeeChange)
1176 );
1177 let added = cal(&format!("{master}{}", instance("TENTATIVE")));
1178 assert_eq!(
1179 itip::attend(&cal(&master), added, &is(BOB), now()).err(),
1180 Some(Refused::AttendeeChange)
1181 );
1182 let same = cal(&format!("{master}{}", instance("CONFIRMED")));
1183 assert!(itip::attend(&cal(&master), same, &is(BOB), now()).is_ok());
1184}
1185
1186#[test]
1187fn dropping_a_declined_instance_replies_with_the_series_answer() {
1188 let master = format!(
1189 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1190 RRULE:FREQ=WEEKLY;COUNT=4\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n\
1191 ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n"
1192 );
1193 let copy = cal(&format!(
1194 "{master}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\n\
1195 DTEND:20260112T110000Z\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n\
1196 ATTENDEE;PARTSTAT=DECLINED:{BOB}\nEND:VEVENT\n"
1197 ));
1198 let (_, reply) = itip::attend(&copy, cal(&master), &is(BOB), now()).unwrap();
1199 let reply = text(&reply.unwrap().cal);
1200 assert!(reply.contains("RECURRENCE-ID:20260112T100000Z"), "{reply}");
1201 assert!(
1202 reply.contains(&format!("PARTSTAT=ACCEPTED:{BOB}")),
1203 "{reply}"
1204 );
1205}
1206
1207#[test]
1208fn a_reordered_save_sends_nothing() {
1209 let old = meeting(&format!(
1210 "ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nLOCATION:Room 1\n"
1211 ));
1212 let (store, _) = itip::organize(None, Some(old), &is(ALICE), now());
1213 let old = store.unwrap();
1214 let reordered = text(&old)
1215 .replace("SUMMARY:Sync\r\n", "")
1216 .replace("LOCATION:Room 1\r\n", "LOCATION:Room 1\r\nSUMMARY:Sync\r\n")
1217 .replace("DTSTAMP:20260101T000000Z", "DTSTAMP:20260102T000000Z");
1218 let (_, msgs) = itip::organize(
1219 Some(&old),
1220 Some(ICalendar::parse(&reordered).unwrap()),
1221 &is(ALICE),
1222 now(),
1223 );
1224 assert!(to(&msgs, BOB).is_none(), "{msgs:?}");
1225}
1226
1227#[test]
1228fn an_override_of_no_instance_is_refused_and_many_overrides_stay_fast() {
1229 let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
1230 let series = format!(
1231 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1232 RRULE:FREQ=DAILY;COUNT=5000\n{people}END:VEVENT\n"
1233 );
1234 let over = |rid: &str| {
1235 format!(
1236 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:{rid}\nDTSTART:{rid}\n\
1237 DTEND:{}\n{people}END:VEVENT\n",
1238 rid.replace("T10", "T11")
1239 )
1240 };
1241 let stray = cal(&format!("{series}{}", over("20260112T103000Z")));
1242 assert_eq!(
1243 itip::attend(&cal(&series), stray, &is(BOB), now()).err(),
1244 Some(Refused::AttendeeChange)
1245 );
1246 let day = |i: i64| {
1247 (chrono::NaiveDate::from_ymd_opt(2026, 1, 5).unwrap() + chrono::TimeDelta::days(i))
1248 .format("%Y%m%dT100000Z")
1249 .to_string()
1250 };
1251 // At most this many changes in one PUT.
1252 let many: String = (0..1000)
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)).changed);
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)).changed);
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
1317#[test]
1318fn a_huge_duration_does_not_panic() {
1319 let huge = cal(&format!(
1320 "BEGIN:VEVENT\nUID:m1\nDTSTART;TZID=Europe/Berlin:20260105T100000\nDURATION:P99999999W\n\
1321 ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\nEND:VEVENT\n"
1322 ));
1323 itip::organize(Some(&huge), Some(huge.clone()), &is(ALICE), now());
1324 let accepted = ICalendar::parse(text(&huge).replace("NEEDS-ACTION", "ACCEPTED")).unwrap();
1325 assert!(itip::attend(&huge, accepted, &is(BOB), now()).is_ok());
1326}
1327
1328#[test]
1329fn many_attendees_and_ranges_stay_fast() {
1330 let people: String = (0..50)
1331 .map(|i| format!("ATTENDEE:mailto:u{i}@example.com\n"))
1332 .collect();
1333 let second = |n: i64| {
1334 (Utc.with_ymd_and_hms(2026, 1, 5, 10, 0, 0).unwrap() + chrono::TimeDelta::seconds(n))
1335 .format("%Y%m%dT%H%M%SZ")
1336 .to_string()
1337 };
1338 let ranges: String = (1..=20)
1339 .map(|i| {
1340 let at = second(i * 400);
1341 format!(
1342 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:{at}\nDTSTART:{at}\n\
1343 DURATION:PT1M\nORGANIZER:{ALICE}\nATTENDEE:mailto:u0@example.com\nEND:VEVENT\n"
1344 )
1345 })
1346 .collect();
1347 let org = cal(&format!(
1348 "BEGIN:VEVENT\nUID:m1\nDTSTART:{}\nDURATION:PT1M\nRRULE:FREQ=SECONDLY;COUNT=10000\n\
1349 ORGANIZER:{ALICE}\n{people}END:VEVENT\n{ranges}",
1350 second(0)
1351 ));
1352 let t = std::time::Instant::now();
1353 let (_, msgs) = itip::organize(None, Some(org), &is(ALICE), now());
1354 assert!(t.elapsed().as_secs() < 5, "{:?}", t.elapsed());
1355 let u1 = text(&to(&msgs, "mailto:u1@example.com").unwrap().cal);
1356 assert!(u1.contains("UNTIL=20260105T100639Z"), "{u1}");
1357}
1358
1359#[test]
1360fn a_copy_with_many_overrides_is_answered_fast() {
1361 let people =
1362 format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n");
1363 let day = |i: i64| {
1364 (chrono::NaiveDate::from_ymd_opt(2026, 1, 5).unwrap() + chrono::TimeDelta::days(i))
1365 .format("%Y%m%dT100000")
1366 .to_string()
1367 };
1368 let overrides: String = (0..8000)
1369 .map(|i| {
1370 format!(
1371 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;TZID=Europe/Berlin:{d}\n\
1372 DTSTART;TZID=Europe/Berlin:{d}\nDURATION:PT1H\nSUMMARY:{i}\n{people}END:VEVENT\n",
1373 d = day(i)
1374 )
1375 })
1376 .collect();
1377 let copy = cal(&format!(
1378 "BEGIN:VEVENT\nUID:m1\nDTSTART;TZID=Europe/Berlin:{}\nDURATION:PT1H\n\
1379 RRULE:FREQ=DAILY;COUNT=9000\n{people}END:VEVENT\n{overrides}",
1380 day(0)
1381 ));
1382 let answered = ICalendar::parse(text(&copy).replacen(
1383 &format!("PARTSTAT=NEEDS-ACTION:{BOB}"),
1384 &format!("PARTSTAT=ACCEPTED:{BOB}"),
1385 1,
1386 ))
1387 .unwrap();
1388 let t = std::time::Instant::now();
1389 let (_, reply) = itip::attend(&copy, answered, &is(BOB), now()).unwrap();
1390 assert!(reply.is_some());
1391 assert!(t.elapsed().as_secs() < 10, "{:?}", t.elapsed());
1392}
1393
1394#[test]
1395fn clients_may_name_the_week_start_or_confirm_their_instance() {
1396 let people =
1397 format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n");
1398 let series = format!(
1399 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260107T100000Z\nDTEND:20260107T110000Z\n\
1400 RRULE:FREQ=WEEKLY;BYDAY=WE;COUNT=4\n{people}END:VEVENT\n"
1401 );
1402 let accepted = series.replace("NEEDS-ACTION", "ACCEPTED");
1403 let with_wkst = accepted.replace("COUNT=4", "COUNT=4;WKST=SU");
1404 assert!(itip::attend(&cal(&series), cal(&with_wkst), &is(BOB), now()).is_ok());
1405 // Every other week depends on where weeks start.
1406 let biweekly = series.replace("FREQ=WEEKLY", "FREQ=WEEKLY;INTERVAL=2");
1407 let moved = biweekly
1408 .replace("NEEDS-ACTION", "ACCEPTED")
1409 .replace("COUNT=4", "COUNT=4;WKST=SU");
1410 assert_eq!(
1411 itip::attend(&cal(&biweekly), cal(&moved), &is(BOB), now()).err(),
1412 Some(Refused::AttendeeChange)
1413 );
1414 let confirmed = cal(&format!(
1415 "{series}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260114T100000Z\nDTSTART:20260114T100000Z\n\
1416 DTEND:20260114T110000Z\nSTATUS:CONFIRMED\n{}END:VEVENT\n",
1417 people.replace("NEEDS-ACTION", "ACCEPTED")
1418 ));
1419 assert!(itip::attend(&cal(&series), confirmed.clone(), &is(BOB), now()).is_ok());
1420 // The organizer's CONFIRMED instance is not the attendee's to drop.
1421 assert_eq!(
1422 itip::attend(&confirmed, cal(&accepted), &is(BOB), now()).err(),
1423 Some(Refused::AttendeeChange)
1424 );
1425}
1426
1427#[test]
1428fn a_sequence_bump_reaches_the_copies_quietly() {
1429 let old = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
1430 let (store, _) = itip::organize(None, Some(old), &is(ALICE), now());
1431 let old = store.unwrap();
1432 let bumped =
1433 ICalendar::parse(text(&old).replace("SUMMARY:Sync", "SUMMARY:Sync\r\nSEQUENCE:3")).unwrap();
1434 let (store, msgs) = itip::organize(Some(&old), Some(bumped), &is(ALICE), now());
1435 let bob = to(&msgs, BOB).expect("no update for bob");
1436 assert!(bob.quiet);
1437 assert!(text(&bob.cal).contains("SEQUENCE:3"));
1438 // bob's later answer carries that SEQUENCE and lands.
1439 let copy = itip::receive(
1440 None,
1441 &Message {
1442 quiet: false,
1443 ..bob.clone()
1444 },
1445 )
1446 .unwrap();
1447 let declined = ICalendar::parse(text(&copy).replace(
1448 &format!("PARTSTAT=NEEDS-ACTION:{BOB}"),
1449 &format!("PARTSTAT=DECLINED:{BOB}"),
1450 ))
1451 .unwrap();
1452 let (_, reply) = itip::attend(&copy, declined, &is(BOB), now()).unwrap();
1453 assert!(reply.is_some(), "{}", text(&copy));
1454 let mut org = store.unwrap();
1455 assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB)).changed);
1456}
1457
1458#[test]
1459fn many_attendees_share_one_message_body() {
1460 let people: String = (0..2000)
1461 .map(|i| format!("ATTENDEE:mailto:u{i}@example.com\n"))
1462 .collect();
1463 let new = cal(&format!(
1464 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1465 SUMMARY:All hands\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n{people}END:VEVENT\n"
1466 ));
1467 let t = std::time::Instant::now();
1468 let (store, msgs) = itip::organize(None, Some(new), &is(ALICE), now());
1469 assert_eq!(msgs.len(), 2000);
1470 assert!(
1471 msgs.iter()
1472 .all(|m| std::sync::Arc::ptr_eq(&m.cal, &msgs[0].cal))
1473 );
1474 let store = store.unwrap();
1475 let (_, again) = itip::organize(Some(&store), Some(store.clone()), &is(ALICE), now());
1476 assert!(again.is_empty());
1477 assert!(t.elapsed().as_secs() < 2, "{:?}", t.elapsed());
1478}
1479
1480/// An endless daily series alice organizes, bob's copy of it, and bob's
1481/// copy with `n` instances deleted.
1482fn endless_with_exdates(n: i64) -> (ICalendar, ICalendar, ICalendar) {
1483 let series = |bob: &str, extra: &str| {
1484 cal(&format!(
1485 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1486 RRULE:FREQ=DAILY\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n\
1487 ATTENDEE;PARTSTAT={bob}:{BOB}\n{extra}END:VEVENT\n"
1488 ))
1489 };
1490 let days: Vec<String> = (1..=n)
1491 .map(|i| {
1492 (chrono::NaiveDate::from_ymd_opt(2026, 1, 5).unwrap() + chrono::TimeDelta::days(i))
1493 .format("%Y%m%dT100000Z")
1494 .to_string()
1495 })
1496 .collect();
1497 let exdate = format!("EXDATE:{}\n", days.join(","));
1498 (
1499 series("ACCEPTED", ""),
1500 series("ACCEPTED", ""),
1501 series("ACCEPTED", &exdate),
1502 )
1503}
1504
1505#[test]
1506fn an_attendee_may_delete_many_instances_but_not_without_end() {
1507 let (_, copy, many) = endless_with_exdates(8000);
1508 assert_eq!(
1509 itip::attend(&copy, many, &is(BOB), now()).err(),
1510 Some(Refused::AttendeeChange)
1511 );
1512 let (mut org, copy, some) = endless_with_exdates(1000);
1513 let t = std::time::Instant::now();
1514 let (_, reply) = itip::attend(&copy, some, &is(BOB), now()).unwrap();
1515 assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB)).changed);
1516 assert_eq!(org.components.len(), 1002);
1517 assert!(t.elapsed().as_secs() < 10, "{:?}", t.elapsed());
1518}
1519
1520#[test]
1521fn a_reply_never_grows_the_object_past_the_limit() {
1522 let (mut org, copy, some) = endless_with_exdates(1000);
1523 // 3101 components before the reply: the VCALENDAR, the master and 3099
1524 // overrides of later days.
1525 let overrides: String = (2000..5099)
1526 .map(|i| {
1527 let d = (chrono::NaiveDate::from_ymd_opt(2026, 1, 5).unwrap()
1528 + chrono::TimeDelta::days(i))
1529 .format("%Y%m%dT100000Z");
1530 format!(
1531 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:{d}\nDTSTART:{d}\nDTEND:{d}\n\
1532 ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n"
1533 )
1534 })
1535 .collect();
1536 org = ICalendar::parse(text(&org).replace(
1537 "END:VCALENDAR\r\n",
1538 &format!("{}END:VCALENDAR\r\n", overrides.replace('\n', "\r\n")),
1539 ))
1540 .unwrap();
1541 assert_eq!(org.components.len(), 3101);
1542 let (_, reply) = itip::attend(&copy, some, &is(BOB), now()).unwrap();
1543 let applied = itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB));
1544 assert!(applied.changed);
1545 assert!(applied.dropped > 0);
1546 assert_eq!(org.components.len(), 4000);
1547}
1548
1549#[test]
1550fn narrowing_a_range_never_grows_the_object_past_the_limit() {
1551 let day = |i: i64| {
1552 (chrono::NaiveDate::from_ymd_opt(2026, 2, 1).unwrap() + chrono::TimeDelta::days(i))
1553 .format("%Y%m%dT100000Z")
1554 .to_string()
1555 };
1556 let part = |rid: &str, extra: &str, bob: &str| {
1557 format!(
1558 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID{extra}:{rid}\nDTSTART:{rid}\nDTEND:{rid}\n\
1559 ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT={bob}:{BOB}\nEND:VEVENT\n"
1560 )
1561 };
1562 // 3993 components: the VCALENDAR, the master, a range from February 1
1563 // and 3990 plain overrides well after it.
1564 let padding: String = (2000..5990)
1565 .map(|i| part(&day(i), "", "ACCEPTED"))
1566 .collect();
1567 let mut org = cal(&format!(
1568 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1569 RRULE:FREQ=DAILY\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n\
1570 ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n{}{padding}",
1571 part(&day(0), ";RANGE=THISANDFUTURE", "ACCEPTED")
1572 ));
1573 assert_eq!(org.components.len(), 3993);
1574 // Each part for one instance under the range splits it again.
1575 let parts: String = (0..20).map(|i| part(&day(i), "", "DECLINED")).collect();
1576 let reply = ICalendar::parse(
1577 format!(
1578 "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//t//t//EN\nMETHOD:REPLY\n{parts}END:VCALENDAR\n"
1579 )
1580 .replace('\n', "\r\n"),
1581 )
1582 .unwrap();
1583 let applied = itip::apply_reply(&mut org, &reply, &is(BOB));
1584 assert!(applied.changed);
1585 assert!(applied.dropped > 0);
1586 assert!(org.components.len() <= 4000, "{}", org.components.len());
1587}
1588
1589/// What alice's save of `new` over `old` does to bob's ACCEPTED.
1590fn keeps_bobs_answer(old_rule: &str, new_rule: &str) -> bool {
1591 let series = |rule: &str| {
1592 let m = text(&meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n")));
1593 ICalendar::parse(m.replace("COUNT=4", rule)).unwrap()
1594 };
1595 let (store, _) = itip::organize(
1596 Some(&series(old_rule)),
1597 Some(series(new_rule)),
1598 &is(ALICE),
1599 now(),
1600 );
1601 text(&store.unwrap()).contains(&format!("PARTSTAT=ACCEPTED:{BOB}"))
1602}
1603
1604#[test]
1605fn count_and_until_compare_by_the_last_instance() {
1606 // The meeting is on Mondays from January 5.
1607 assert!(keeps_bobs_answer("COUNT=4", "UNTIL=20260119T100000Z"));
1608 assert!(keeps_bobs_answer("UNTIL=20260126T100000Z", "COUNT=3"));
1609 assert!(keeps_bobs_answer("COUNT=4", "COUNT=3"));
1610 assert!(!keeps_bobs_answer("COUNT=3", "UNTIL=20260202T100000Z"));
1611}
1612
1613#[test]
1614fn an_attendee_may_write_the_same_rule_in_another_form() {
1615 let answer = |old_rule: &str, new_rule: &str| {
1616 let copy = text(&meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n")))
1617 .replace("COUNT=4", old_rule);
1618 let answered = copy
1619 .replace("PARTSTAT=NEEDS-ACTION", "PARTSTAT=ACCEPTED")
1620 .replace(old_rule, new_rule);
1621 itip::attend(
1622 &ICalendar::parse(&copy).unwrap(),
1623 ICalendar::parse(&answered).unwrap(),
1624 &is(BOB),
1625 now(),
1626 )
1627 .map(|_| ())
1628 };
1629 assert_eq!(answer("UNTIL=20260126", "UNTIL=20260126T100000Z"), Ok(()));
1630 assert_eq!(answer("COUNT=4", "COUNT=4;BYDAY=MO"), Ok(()));
1631 assert_eq!(answer("COUNT=4", "UNTIL=20260126T100000Z"), Ok(()));
1632 assert_eq!(
1633 answer("COUNT=4", "UNTIL=20260202T100000Z"),
1634 Err(Refused::AttendeeChange)
1635 );
1636 assert_eq!(
1637 answer("COUNT=4", "COUNT=4;BYDAY=MO,TU"),
1638 Err(Refused::AttendeeChange)
1639 );
1640}
1641
1642#[test]
1643fn moving_a_far_until_costs_only_the_gap() {
1644 let series = |until: &str, bob: &str| {
1645 cal(&format!(
1646 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T100100Z\n\
1647 RRULE:FREQ=MINUTELY;UNTIL={until}\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n\
1648 ATTENDEE;PARTSTAT={bob}:{BOB}\nEND:VEVENT\n"
1649 ))
1650 };
1651 let t = std::time::Instant::now();
1652 let (store, _) = itip::organize(
1653 Some(&series("99991231T000000Z", "ACCEPTED")),
1654 Some(series("99991230T000000Z", "ACCEPTED")),
1655 &is(ALICE),
1656 now(),
1657 );
1658 assert!(text(&store.unwrap()).contains(&format!("PARTSTAT=ACCEPTED:{BOB}")));
1659 // A DATE end that keeps the same instances: none falls between the two.
1660 let copy = series("99991230T235930Z", "NEEDS-ACTION");
1661 let answered = series("99991230", "ACCEPTED");
1662 let attended = itip::attend(&copy, answered, &is(BOB), now());
1663 assert!(attended.is_ok(), "{:?}", attended.err());
1664 assert!(t.elapsed().as_millis() < 1000, "{:?}", t.elapsed());
1665}
1666