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
49/// An organizer's PUT (`new`) or DELETE (`None`): what to store and what
50/// to deliver.
51fn organize(
52 old: Option<&ICalendar>,
53 new: Option<ICalendar>,
54 organizer: itip::Is,
55 now: chrono::DateTime<Utc>,
56) -> (Option<ICalendar>, Vec<Message>) {
57 let (new, force) = match new {
58 Some(n) => {
59 let (store, force) = itip::prepare(old, &n, organizer);
60 (Some(store), force)
61 }
62 None => (None, Vec::new()),
63 };
64 let msgs = itip::messages(old, new.as_ref(), organizer, &force, now);
65 (new, msgs)
66}
67
68fn to<'a>(msgs: &'a [Message], who: &str) -> Option<&'a Message> {
69 msgs.iter().find(|m| m.to.eq_ignore_ascii_case(who))
70}
71
72#[test]
73fn roles() {
74 let m = meeting(&format!("ATTENDEE:{BOB}\n"));
75 assert_eq!(itip::role(&m, &is(ALICE)), Ok(Role::Organizer));
76 assert_eq!(itip::role(&m, &is(BOB)), Ok(Role::Attendee));
77 assert_eq!(itip::role(&m, &is(CAROL)), Ok(Role::None));
78 let split = cal(&format!(
79 "BEGIN:VEVENT\nUID:x\nDTSTART:20260105T100000Z\nORGANIZER:{ALICE}\nEND:VEVENT\n\
80 BEGIN:VEVENT\nUID:x\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\nORGANIZER:{BOB}\nEND:VEVENT\n"
81 ));
82 assert_eq!(itip::role(&split, &is(ALICE)), Err(Refused::SameOrganizer));
83}
84
85#[test]
86fn invite() {
87 // The client claims bob accepted; only bob can say that.
88 let new = cal(&format!(
89 "BEGIN:VEVENT
90UID:m1
91DTSTAMP:20260101T000000Z
92DTSTART:20260105T100000Z
93SUMMARY:Sync
94ORGANIZER:{ALICE}
95ATTENDEE;PARTSTAT=ACCEPTED:{ALICE}
96ATTENDEE;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=1.2:{BOB}
97ATTENDEE;SCHEDULE-AGENT=CLIENT;PARTSTAT=ACCEPTED:{CAROL}
98BEGIN:VALARM
99ACTION:DISPLAY
100TRIGGER:-PT5M
101END:VALARM
102END:VEVENT
103"
104 ));
105 let (store, msgs) = organize(None, Some(new), &is(ALICE), now());
106 let store = text(&store.unwrap());
107 assert!(
108 store.contains(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}")),
109 "{store}"
110 );
111 assert!(
112 store.contains("SCHEDULE-AGENT=CLIENT;PARTSTAT=ACCEPTED"),
113 "{store}"
114 );
115 // Nothing to alice herself or to the attendee the client schedules.
116 assert_eq!(msgs.len(), 1);
117 let m = to(&msgs, BOB).unwrap();
118 assert_eq!(m.method, Method::Request);
119 let body = text(&m.cal);
120 assert!(body.contains("METHOD:REQUEST"));
121 assert!(body.contains("DTSTAMP:20260102T120000Z"));
122 assert!(!body.contains("VALARM"));
123 assert!(!body.contains("SCHEDULE-"), "{body}");
124}
125
126#[test]
127fn reschedule_resets_participation() {
128 let old = meeting(&format!(
129 "ATTENDEE;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:{BOB}\n"
130 ));
131 // A new title keeps bob's answer, even from a client with a stale copy.
132 let retitled = text(&meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n")))
133 .replace("SUMMARY:Sync", "SUMMARY:Weekly sync");
134 let (store, msgs) = organize(
135 Some(&old),
136 Some(ICalendar::parse(&retitled).unwrap()),
137 &is(ALICE),
138 now(),
139 );
140 let store = text(&store.unwrap());
141 assert!(
142 store.contains(&format!("PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:{BOB}")),
143 "{store}"
144 );
145 assert!(!to(&msgs, BOB).unwrap().quiet);
146
147 let moved = text(&old).replace("DTSTART:20260105T100000Z", "DTSTART:20260105T090000Z");
148 let (store, msgs) = organize(
149 Some(&old),
150 Some(ICalendar::parse(&moved).unwrap()),
151 &is(ALICE),
152 now(),
153 );
154 let store = text(&store.unwrap());
155 assert!(store.contains("PARTSTAT=NEEDS-ACTION"), "{store}");
156 assert!(store.contains("SEQUENCE:1"), "{store}");
157 assert!(text(&to(&msgs, BOB).unwrap().cal).contains("DTSTART:20260105T090000Z"));
158
159 // Shortening the series is no reschedule.
160 let shorter = text(&old).replace("COUNT=4", "COUNT=2");
161 let (store, _) = organize(
162 Some(&old),
163 Some(ICalendar::parse(&shorter).unwrap()),
164 &is(ALICE),
165 now(),
166 );
167 assert!(
168 text(&store.unwrap()).contains(&format!("PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:{BOB}"))
169 );
170}
171
172#[test]
173fn unchanged_or_forced() {
174 let old = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
175 let resaved = text(&old)
176 .replace("DTSTAMP:20260101T000000Z", "DTSTAMP:20260101T080000Z")
177 .replace("SUMMARY:Sync", "SUMMARY:Sync\r\nX-MOZ-GENERATION:3");
178 let (_, msgs) = organize(
179 Some(&old),
180 Some(ICalendar::parse(&resaved).unwrap()),
181 &is(ALICE),
182 now(),
183 );
184 assert!(msgs.is_empty(), "{msgs:?}");
185 let forced = text(&old).replace(
186 "ATTENDEE;PARTSTAT=ACCEPTED:mailto:bob",
187 "ATTENDEE;SCHEDULE-FORCE-SEND=REQUEST;PARTSTAT=ACCEPTED:mailto:bob",
188 );
189 let (store, msgs) = organize(
190 Some(&old),
191 Some(ICalendar::parse(&forced).unwrap()),
192 &is(ALICE),
193 now(),
194 );
195 assert!(!text(&store.unwrap()).contains("FORCE-SEND"));
196 assert_eq!(to(&msgs, BOB).unwrap().method, Method::Request);
197}
198
199#[test]
200fn removed_attendee_and_delete_cancel() {
201 let old = meeting(&format!("ATTENDEE:{BOB}\nATTENDEE:{CAROL}\n"));
202 let (_, msgs) = organize(
203 Some(&old),
204 Some(meeting(&format!("ATTENDEE:{BOB}\n"))),
205 &is(ALICE),
206 now(),
207 );
208 let cancel = to(&msgs, CAROL).unwrap();
209 assert_eq!(cancel.method, Method::Cancel);
210 assert!(text(&cancel.cal).contains("STATUS:CANCELLED"));
211 // The attendee list changed for bob too.
212 assert_eq!(to(&msgs, BOB).unwrap().method, Method::Request);
213
214 let (store, msgs) = organize(Some(&old), None, &is(ALICE), now());
215 assert!(store.is_none());
216 assert!(msgs.iter().all(|m| m.method == Method::Cancel));
217 assert_eq!(msgs.len(), 2);
218}
219
220#[test]
221fn instances_only_reach_their_attendees() {
222 let old = cal(&format!(
223 "BEGIN:VEVENT
224UID:m1
225DTSTART:20260105T100000Z
226RRULE:FREQ=WEEKLY;COUNT=4
227ORGANIZER:{ALICE}
228ATTENDEE:{BOB}
229ATTENDEE:{CAROL}
230END:VEVENT
231BEGIN:VEVENT
232UID:m1
233RECURRENCE-ID:20260112T100000Z
234DTSTART:20260112T140000Z
235ORGANIZER:{ALICE}
236ATTENDEE:{BOB}
237END:VEVENT
238"
239 ));
240 let (_, msgs) = organize(None, Some(old), &is(ALICE), now());
241 let carol = text(&to(&msgs, CAROL).unwrap().cal);
242 assert!(carol.contains("EXDATE:20260112T100000Z"), "{carol}");
243 assert!(!carol.contains("RECURRENCE-ID"), "{carol}");
244 let bob = text(&to(&msgs, BOB).unwrap().cal);
245 assert!(
246 bob.contains("RECURRENCE-ID:20260112T100000Z") && !bob.contains("EXDATE"),
247 "{bob}"
248 );
249}
250
251#[test]
252fn accept_and_apply_reply() {
253 let org = meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n"));
254 let (_, msgs) = organize(None, Some(org.clone()), &is(ALICE), now());
255 let copy = itip::receive(None, to(&msgs, BOB).unwrap()).unwrap();
256 assert!(!text(&copy).contains("METHOD"));
257
258 let accepted = text(&copy).replace(
259 &format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}"),
260 &format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}"),
261 );
262 let (_, reply) =
263 itip::attend(&copy, ICalendar::parse(&accepted).unwrap(), &is(BOB), now()).unwrap();
264 let reply = reply.unwrap();
265 assert_eq!((reply.method, reply.to.as_str()), (Method::Reply, ALICE));
266 let body = text(&reply.cal);
267 assert!(body.contains("METHOD:REPLY"), "{body}");
268 assert!(
269 !body.contains(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{ALICE}")),
270 "{body}"
271 );
272
273 let mut org = org;
274 assert!(itip::apply_reply(&mut org, &reply.cal, &is(BOB)).changed);
275 let org = text(&org);
276 assert!(
277 org.contains(&format!("PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:{BOB}")),
278 "{org}"
279 );
280}
281
282#[test]
283fn declining_one_instance() {
284 let org = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
285 let copy = org.clone();
286 // bob overrides the second week to decline it.
287 let with_override = format!(
288 "{}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",
289 text(&copy).trim_end_matches("END:VCALENDAR\r\n")
290 );
291 let (_, reply) = itip::attend(
292 &copy,
293 ICalendar::parse(&with_override).unwrap(),
294 &is(BOB),
295 now(),
296 )
297 .unwrap();
298 let reply = reply.unwrap();
299 assert!(text(&reply.cal).contains("RECURRENCE-ID:20260112T100000Z"));
300 let mut org = org;
301 assert!(itip::apply_reply(&mut org, &reply.cal, &is(BOB)).changed);
302 let org_text = text(&org);
303 assert!(
304 org_text.contains("RECURRENCE-ID:20260112T100000Z"),
305 "{org_text}"
306 );
307 assert!(org_text.contains("DURATION:PT1H"), "{org_text}");
308 assert!(
309 org_text.contains(&format!("PARTSTAT=DECLINED;SCHEDULE-STATUS=2.0:{BOB}")),
310 "{org_text}"
311 );
312
313 // An EXDATE declines too.
314 let excluded = text(&copy).replace(
315 "RRULE:FREQ=WEEKLY;COUNT=4",
316 "RRULE:FREQ=WEEKLY;COUNT=4\r\nEXDATE:20260119T100000Z",
317 );
318 let (_, reply) =
319 itip::attend(&copy, ICalendar::parse(&excluded).unwrap(), &is(BOB), now()).unwrap();
320 let body = text(&reply.unwrap().cal);
321 assert!(
322 body.contains("RECURRENCE-ID:20260119T100000Z") && body.contains("PARTSTAT=DECLINED"),
323 "{body}"
324 );
325}
326
327#[test]
328fn reply_for_a_missing_instance_adds_nothing() {
329 let reply = |rid: &str| {
330 cal(&format!(
331 "METHOD:REPLY
332BEGIN:VEVENT
333UID:m1
334DTSTAMP:20260101T000000Z
335RECURRENCE-ID:{rid}
336DTSTART:{rid}
337ORGANIZER:{ALICE}
338ATTENDEE;PARTSTAT=DECLINED:{BOB}
339END:VEVENT
340"
341 ))
342 };
343 let mut org = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
344 // A Tuesday, and a Monday after COUNT ends.
345 for rid in ["20260113T100000Z", "20260202T100000Z"] {
346 assert!(
347 !itip::apply_reply(&mut org, &reply(rid), &is(BOB)).changed,
348 "{rid}"
349 );
350 }
351 assert!(itip::apply_reply(&mut org, &reply("20260112T100000Z"), &is(BOB)).changed);
352}
353
354#[test]
355fn attendees_may_not_move_the_meeting() {
356 let copy = meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n"));
357 let moved = text(&copy).replace("DTSTART:20260105T100000Z", "DTSTART:20260105T120000Z");
358 assert_eq!(
359 itip::attend(&copy, ICalendar::parse(&moved).unwrap(), &is(BOB), now()).err(),
360 Some(Refused::AttendeeChange)
361 );
362 // Alarms and transparency are the attendee's.
363 let own = text(&copy).replace(
364 "SUMMARY:Sync",
365 "SUMMARY:Sync\r\nTRANSP:TRANSPARENT\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT5M\r\nEND:VALARM",
366 );
367 let (_, reply) = itip::attend(&copy, ICalendar::parse(&own).unwrap(), &is(BOB), now()).unwrap();
368 assert!(reply.is_none());
369}
370
371#[test]
372fn attendee_delete_declines() {
373 let copy = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
374 let reply = itip::decline(&copy, &is(BOB), now()).unwrap();
375 assert!(text(&reply.cal).contains(&format!("PARTSTAT=DECLINED:{BOB}")));
376 let client = text(&copy).replace(
377 &format!("ORGANIZER:{ALICE}"),
378 &format!("ORGANIZER;SCHEDULE-AGENT=CLIENT:{ALICE}"),
379 );
380 assert!(itip::decline(&ICalendar::parse(&client).unwrap(), &is(BOB), now()).is_none());
381}
382
383#[test]
384fn updates_keep_what_the_attendee_owns() {
385 let org = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
386 let (_, msgs) = organize(None, Some(org.clone()), &is(ALICE), now());
387 let copy = itip::receive(None, to(&msgs, BOB).unwrap()).unwrap();
388 let copy = ICalendar::parse(text(&copy).replace(
389 "SUMMARY:Sync",
390 "SUMMARY:Sync\r\nTRANSP:TRANSPARENT\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT5M\r\nEND:VALARM",
391 ))
392 .unwrap();
393 let retitled =
394 ICalendar::parse(text(&org).replace("SUMMARY:Sync", "SUMMARY:Planning")).unwrap();
395 let (_, msgs) = organize(Some(&org), Some(retitled), &is(ALICE), now());
396 let updated = text(&itip::receive(Some(&copy), to(&msgs, BOB).unwrap()).unwrap());
397 assert!(
398 updated.contains("SUMMARY:Planning")
399 && updated.contains("TRANSP:TRANSPARENT")
400 && updated.contains("TRIGGER:-PT5M"),
401 "{updated}"
402 );
403
404 let (_, msgs) = organize(Some(&org), None, &is(ALICE), now());
405 let cancelled = text(&itip::receive(Some(&copy), to(&msgs, BOB).unwrap()).unwrap());
406 assert!(cancelled.contains("STATUS:CANCELLED"), "{cancelled}");
407}
408
409#[test]
410fn sender_is_named_for_the_owner() {
411 let mut m = meeting(&format!("ATTENDEE:{BOB}\n"));
412 itip::stamp_sender(&mut m, &is(ALICE), Some(BOB));
413 let got = text(&m);
414 assert!(
415 got.contains(&format!("ORGANIZER;SENT-BY=\"{BOB}\":{ALICE}")),
416 "{got}"
417 );
418 assert!(!got.contains(&format!("SENT-BY=\"{BOB}\":{BOB}")), "{got}");
419 // An edit by the owner drops it again.
420 itip::stamp_sender(&mut m, &is(ALICE), None);
421 assert!(!text(&m).contains("SENT-BY"), "{}", text(&m));
422
423 // Who sent it is no reason to message the attendees.
424 let old = meeting(&format!("ATTENDEE:{BOB}\n"));
425 let mut new = old.clone();
426 itip::stamp_sender(&mut new, &is(ALICE), Some(BOB));
427 let msgs = itip::messages(Some(&old), Some(&new), &is(ALICE), &[], now());
428 assert!(msgs.is_empty(), "{msgs:?}");
429}
430
431#[test]
432fn rooms_answer_from_their_bookings() {
433 use pimdav::freebusy::{Busy, Period};
434 use pimdav::zone::Zone;
435 const ROOM: &str = "mailto:board@rooms.dovenest.invalid";
436 let at = |d: u32, h: u32| Utc.with_ymd_and_hms(2026, 1, d, h, 0, 0).unwrap();
437 // The weekly meeting runs on 5, 12, 19 and 26 January, 10:00 to 11:00.
438 let copy = meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{ROOM}\n"));
439 let window = at(1, 0)..at(31, 0);
440
441 let free = itip::auto_answer(&copy, &is(ROOM), &[], &window, &Zone::Utc);
442 assert!(
443 text(&free).contains(&format!("PARTSTAT=ACCEPTED:{ROOM}")),
444 "{}",
445 text(&free)
446 );
447
448 let taken = [Period {
449 kind: Busy::Busy,
450 start: at(12, 10),
451 end: at(12, 11),
452 }];
453 let answer = itip::auto_answer(&copy, &is(ROOM), &taken, &window, &Zone::Utc);
454 let got = text(&answer);
455 assert!(got.contains("RECURRENCE-ID:20260112T100000Z"), "{got}");
456 assert_eq!(
457 got.matches(&format!("PARTSTAT=DECLINED:{ROOM}")).count(),
458 1,
459 "{got}"
460 );
461 assert_eq!(
462 got.matches(&format!("PARTSTAT=ACCEPTED:{ROOM}")).count(),
463 1,
464 "{got}"
465 );
466
467 // The answer travels as a REPLY for the series and the one instance.
468 let (_, reply) = itip::attend(&copy, answer, &is(ROOM), now()).unwrap();
469 let reply = reply.unwrap();
470 assert_eq!(reply.method, Method::Reply);
471 assert!(text(&reply.cal).contains("RECURRENCE-ID:20260112T100000Z"));
472
473 // A single event on a taken slot is declined as a whole.
474 let single = cal(&format!(
475 "BEGIN:VEVENT\nUID:s1\nDTSTART:20260112T103000Z\nDURATION:PT1H\nORGANIZER:{ALICE}\n\
476 ATTENDEE:{ROOM}\nEND:VEVENT\n"
477 ));
478 let got = text(&itip::auto_answer(
479 &single,
480 &is(ROOM),
481 &taken,
482 &window,
483 &Zone::Utc,
484 ));
485 assert!(got.contains(&format!("PARTSTAT=DECLINED:{ROOM}")), "{got}");
486}
487
488#[test]
489fn forget_a_deleted_principal() {
490 let gone = is(BOB);
491 let tomb = "mailto:bob-7@deleted.dovenest.invalid";
492
493 // In alice's meeting bob becomes a tombstone that reaches no one.
494 let org = format!(
495 "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"
496 );
497 let out = itip::forget(&org, &gone, tomb).unwrap();
498 // The longer line is folded at 75 octets.
499 assert_eq!(
500 out.replace("\r\n ", ""),
501 org.replace(
502 &format!("ATTENDEE;CN=Bob;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=1.2:{BOB}"),
503 &format!("ATTENDEE;CN=Bob;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=3.7:{tomb}")
504 )
505 );
506
507 // A copy of bob's meeting is cancelled, the override too; LF stays LF.
508 let copy = format!(
509 "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",
510 BOB.to_uppercase()
511 );
512 let out = itip::forget(&copy, &gone, tomb).unwrap();
513 assert_eq!(
514 out,
515 format!(
516 "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"
517 )
518 );
519
520 assert_eq!(
521 itip::forget(&org, &is(CAROL.trim_end_matches('m')), tomb),
522 None
523 );
524}
525
526const ROOM: &str = "mailto:board@rooms.dovenest.invalid";
527
528fn at(d: u32, h: u32) -> chrono::DateTime<Utc> {
529 Utc.with_ymd_and_hms(2026, 1, d, h, 0, 0).unwrap()
530}
531
532fn booked(start: chrono::DateTime<Utc>, end: chrono::DateTime<Utc>) -> pimdav::freebusy::Period {
533 pimdav::freebusy::Period {
534 kind: pimdav::freebusy::Busy::Busy,
535 start,
536 end,
537 }
538}
539
540#[test]
541fn a_room_declines_a_series_with_many_conflicts_as_a_whole() {
542 use pimdav::zone::Zone;
543 let copy = cal(&format!(
544 "BEGIN:VEVENT\nUID:h1\nDTSTART:20260105T000000Z\nDURATION:PT30M\nRRULE:FREQ=HOURLY;COUNT=200\n\
545 ORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n"
546 ));
547 let taken = [booked(at(1, 0), at(31, 0))];
548 let got = text(&itip::auto_answer(
549 &copy,
550 &is(ROOM),
551 &taken,
552 &(at(1, 0)..at(31, 0)),
553 &Zone::Utc,
554 ));
555 assert!(!got.contains("RECURRENCE-ID"), "{got}");
556 assert!(got.contains(&format!("PARTSTAT=DECLINED:{ROOM}")), "{got}");
557}
558
559#[test]
560fn a_room_declines_one_instance_a_this_and_future_override_moves() {
561 use pimdav::zone::Zone;
562 // From the 12th on, the meeting runs two hours later.
563 let copy = cal(&format!(
564 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\nRRULE:FREQ=WEEKLY;COUNT=4\n\
565 ORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n\
566 BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\
567 DTSTART:20260112T120000Z\nDTEND:20260112T130000Z\nORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n"
568 ));
569 let taken = [booked(at(19, 12), at(19, 13))];
570 let answer = itip::auto_answer(&copy, &is(ROOM), &taken, &(at(1, 0)..at(31, 0)), &Zone::Utc);
571 let got = text(&answer);
572 assert!(
573 got.contains("RECURRENCE-ID:20260119T100000Z\r\nDTSTART:20260119T120000Z")
574 || got.contains("DTSTART:20260119T120000Z\r\nRECURRENCE-ID:20260119T100000Z"),
575 "{got}"
576 );
577 assert_eq!(
578 got.matches(&format!("PARTSTAT=DECLINED:{ROOM}")).count(),
579 1,
580 "{got}"
581 );
582 assert_eq!(
583 got.matches(&format!("PARTSTAT=ACCEPTED:{ROOM}")).count(),
584 2,
585 "{got}"
586 );
587
588 // The moved override passes as the room's own answer, and the
589 // organizer's copy takes it.
590 let (_, reply) = itip::attend(&copy, answer, &is(ROOM), now()).unwrap();
591 let mut org = copy.clone();
592 assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(ROOM)).changed);
593 let org = text(&org);
594 assert!(org.contains("DTSTART:20260119T120000Z"), "{org}");
595}
596
597#[test]
598fn a_client_scheduled_attendee_gets_no_cancel() {
599 let old = meeting(&format!("ATTENDEE:{BOB}\n"));
600 let new = meeting(&format!("ATTENDEE;SCHEDULE-AGENT=CLIENT:{BOB}\n"));
601 let (_, msgs) = organize(Some(&old), Some(new), &is(ALICE), now());
602 assert!(to(&msgs, BOB).is_none(), "{msgs:?}");
603}
604
605#[test]
606fn the_highest_sequence_does_not_overflow() {
607 let old = meeting(&format!(
608 "SEQUENCE:9223372036854775807\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\n"
609 ));
610 let moved = text(&old)
611 .replace("DTSTART:20260105T100000Z", "DTSTART:20260105T090000Z")
612 .replace(&format!("ATTENDEE:{CAROL}\r\n"), "");
613 let (store, msgs) = organize(
614 Some(&old),
615 Some(ICalendar::parse(&moved).unwrap()),
616 &is(ALICE),
617 now(),
618 );
619 assert!(text(&store.unwrap()).contains("SEQUENCE:9223372036854775807"));
620 assert!(text(&to(&msgs, CAROL).unwrap().cal).contains("SEQUENCE:9223372036854775807"));
621}
622
623#[test]
624fn answering_an_instance_the_series_lacks_changes_nothing() {
625 use pimdav::calcard::icalendar::ICalendarParticipationStatus::Declined;
626 use pimdav::zone::Zone;
627 let copy = meeting(&format!("ATTENDEE:{BOB}\n"));
628 // A Tuesday is no instance.
629 assert!(itip::respond(&copy, &is(BOB), Declined, Some(at(13, 10)), &Zone::Utc).is_none());
630 let got =
631 text(&itip::respond(&copy, &is(BOB), Declined, Some(at(12, 10)), &Zone::Utc).unwrap());
632 assert!(got.contains("RECURRENCE-ID:20260112T100000Z"), "{got}");
633 assert_eq!(
634 got.matches(&format!("PARTSTAT=DECLINED:{BOB}")).count(),
635 1,
636 "{got}"
637 );
638}
639
640#[test]
641fn a_date_until_on_the_same_day_extends_the_series() {
642 let old = text(&meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n")))
643 .replace("COUNT=4", "UNTIL=20260119T000000Z");
644 let old = ICalendar::parse(&old).unwrap();
645 // The DATE includes the instance at 10:00 on the 19th.
646 let longer = text(&old).replace("UNTIL=20260119T000000Z", "UNTIL=20260119");
647 let (store, _) = organize(
648 Some(&old),
649 Some(ICalendar::parse(&longer).unwrap()),
650 &is(ALICE),
651 now(),
652 );
653 let store = text(&store.unwrap());
654 assert!(
655 store.contains(&format!("PARTSTAT=NEEDS-ACTION:{BOB}")),
656 "{store}"
657 );
658}
659
660#[test]
661fn a_room_declines_only_the_first_instance_of_a_this_and_future_override() {
662 use pimdav::calcard::icalendar::ICalendarParticipationStatus::Declined;
663 use pimdav::zone::Zone;
664 // From the 12th on, the meeting runs two hours later.
665 let copy = cal(&format!(
666 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\nRRULE:FREQ=WEEKLY;COUNT=4\n\
667 ORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n\
668 BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\
669 DTSTART:20260112T120000Z\nDTEND:20260112T130000Z\nORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n"
670 ));
671 let taken = [booked(at(12, 12), at(12, 13))];
672 let answer = itip::auto_answer(&copy, &is(ROOM), &taken, &(at(1, 0)..at(31, 0)), &Zone::Utc);
673 let got = text(&answer);
674 assert!(got.contains("RECURRENCE-ID:20260112T100000Z"), "{got}");
675 assert!(
676 got.contains("RECURRENCE-ID;RANGE=THISANDFUTURE:20260119T100000Z"),
677 "{got}"
678 );
679 assert!(got.contains("DTSTART:20260119T120000Z"), "{got}");
680 let declined = format!("PARTSTAT=DECLINED:{ROOM}");
681 let accepted = format!("PARTSTAT=ACCEPTED:{ROOM}");
682 assert_eq!(got.matches(&declined).count(), 1, "{got}");
683 assert_eq!(got.matches(&accepted).count(), 2, "{got}");
684
685 // The organizer's copy splits its range the same way.
686 let (_, reply) = itip::attend(&copy, answer, &is(ROOM), now()).unwrap();
687 let mut org = copy.clone();
688 assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(ROOM)).changed);
689 let org = text(&org);
690 assert!(
691 org.contains("RECURRENCE-ID;RANGE=THISANDFUTURE:20260119T100000Z"),
692 "{org}"
693 );
694 assert_eq!(org.matches("PARTSTAT=DECLINED").count(), 1, "{org}");
695 assert_eq!(org.matches("PARTSTAT=ACCEPTED").count(), 2, "{org}");
696
697 // So does an answer for that one instance from the web.
698 let got =
699 text(&itip::respond(&copy, &is(ROOM), Declined, Some(at(12, 10)), &Zone::Utc).unwrap());
700 assert!(
701 got.contains("RECURRENCE-ID;RANGE=THISANDFUTURE:20260119T100000Z"),
702 "{got}"
703 );
704 assert_eq!(got.matches(&declined).count(), 1, "{got}");
705}
706
707#[test]
708fn a_new_override_takes_the_others_state_from_its_range() {
709 let old = cal(&format!(
710 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\nRRULE:FREQ=WEEKLY;COUNT=4\n\
711 ORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE;PARTSTAT=NEEDS-ACTION:{CAROL}\nEND:VEVENT\n\
712 BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\
713 DTSTART:20260112T120000Z\nDTEND:20260112T130000Z\nORGANIZER:{ALICE}\n\
714 ATTENDEE:{BOB}\nATTENDEE;PARTSTAT=ACCEPTED:{CAROL}\nEND:VEVENT\n"
715 ));
716 let new = cal(&format!(
717 "{}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260119T100000Z\nDTSTART:20260119T120000Z\n\
718 DTEND:20260119T130000Z\nORGANIZER:{ALICE}\nATTENDEE;PARTSTAT=DECLINED:{BOB}\n\
719 ATTENDEE;PARTSTAT=NEEDS-ACTION:{CAROL}\nEND:VEVENT\n",
720 text(&old)
721 .trim_start_matches("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n")
722 .trim_end_matches("END:VCALENDAR\r\n")
723 .replace("\r\n", "\n")
724 ));
725 let (store, reply) = itip::attend(&old, new, &is(BOB), now()).unwrap();
726 let store = text(&store);
727 assert_eq!(
728 store.matches(&format!("PARTSTAT=ACCEPTED:{CAROL}")).count(),
729 2,
730 "{store}"
731 );
732 assert!(reply.is_some());
733}
734
735#[test]
736fn an_attendee_dropped_from_the_master_gets_a_cancel_despite_a_client_override() {
737 let over = |agent: &str| {
738 format!(
739 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T120000Z\n\
740 DTEND:20260112T130000Z\nORGANIZER:{ALICE}\nATTENDEE{agent}:{BOB}\nEND:VEVENT\n"
741 )
742 };
743 let series = |extra: &str| {
744 format!(
745 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
746 RRULE:FREQ=WEEKLY;COUNT=4\nORGANIZER:{ALICE}\nATTENDEE:{CAROL}\n{extra}END:VEVENT\n"
747 )
748 };
749 let old = cal(&format!(
750 "{}{}",
751 series(&format!("ATTENDEE:{BOB}\n")),
752 over("")
753 ));
754 let new = cal(&format!("{}{}", series(""), over(";SCHEDULE-AGENT=CLIENT")));
755 let (_, msgs) = organize(Some(&old), Some(new), &is(ALICE), now());
756 let cancel = to(&msgs, BOB).expect("a CANCEL for the series");
757 assert_eq!(cancel.method, Method::Cancel);
758 assert!(
759 !text(&cancel.cal).contains("RECURRENCE-ID"),
760 "{}",
761 text(&cancel.cal)
762 );
763}
764
765/// alice's weekly meeting, two hours later from the 12th on, with bob in it.
766fn shifted_series() -> String {
767 format!(
768 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\nRRULE:FREQ=WEEKLY;COUNT=4\n\
769 ORGANIZER:{ALICE}\nATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n\
770 BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\
771 DTSTART:20260112T120000Z\nDTEND:20260112T130000Z\nORGANIZER:{ALICE}\n\
772 ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n"
773 )
774}
775
776#[test]
777fn a_new_override_at_the_shifted_time_is_no_move() {
778 let old = cal(&shifted_series());
779 let new = cal(&format!(
780 "{}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260119T100000Z\nDTSTART:20260119T120000Z\n\
781 DTEND:20260119T130000Z\nSUMMARY:renamed\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n",
782 shifted_series()
783 ));
784 let (store, _) = organize(Some(&old), Some(new), &is(ALICE), now());
785 let store = text(&store.unwrap());
786 assert_eq!(
787 store.matches(&format!("PARTSTAT=ACCEPTED:{BOB}")).count(),
788 3,
789 "{store}"
790 );
791 assert!(!store.contains("SEQUENCE:1"), "{store}");
792}
793
794#[test]
795fn a_copy_without_its_master_keeps_the_shift() {
796 let range = shifted_series()
797 .split_inclusive("END:VEVENT\n")
798 .nth(1)
799 .unwrap()
800 .to_string();
801 let old = cal(&range);
802 let new = cal(&format!(
803 "{range}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260119T100000Z\nDTSTART:20260119T120000Z\n\
804 DTEND:20260119T130000Z\nSUMMARY:renamed\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n"
805 ));
806 let (store, _) = organize(Some(&old), Some(new), &is(ALICE), now());
807 let store = text(&store.unwrap());
808 assert_eq!(
809 store.matches(&format!("PARTSTAT=ACCEPTED:{BOB}")).count(),
810 2,
811 "{store}"
812 );
813}
814
815#[test]
816fn a_new_override_that_moves_bumps_the_sequence() {
817 let old = cal(&shifted_series());
818 let new = cal(&format!(
819 "{}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260119T100000Z\nDTSTART:20260119T150000Z\n\
820 DTEND:20260119T160000Z\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n",
821 shifted_series()
822 ));
823 let (store, _) = organize(Some(&old), Some(new), &is(ALICE), now());
824 let store = text(&store.unwrap());
825 assert!(store.contains("SEQUENCE:1"), "{store}");
826 assert!(
827 store.contains(&format!("PARTSTAT=NEEDS-ACTION:{BOB}")),
828 "{store}"
829 );
830}
831
832#[test]
833fn a_changed_exrule_is_a_reschedule() {
834 let old = meeting(&format!(
835 "ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEXRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=1\n"
836 ));
837 let removed: String = text(&old)
838 .split_inclusive("\r\n")
839 .filter(|l| !l.starts_with("EXRULE"))
840 .collect();
841 assert_ne!(removed, text(&old));
842 let (store, _) = organize(
843 Some(&old),
844 Some(ICalendar::parse(&removed).unwrap()),
845 &is(ALICE),
846 now(),
847 );
848 let store = text(&store.unwrap());
849 assert!(
850 store.contains(&format!("PARTSTAT=NEEDS-ACTION:{BOB}")),
851 "{store}"
852 );
853}
854
855#[test]
856fn a_rule_in_another_form_is_the_same_rule() {
857 let old = text(&meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n"))).replace(
858 "RRULE:FREQ=WEEKLY;COUNT=4",
859 "RRULE:FREQ=WEEKLY;BYDAY=MO,TU;COUNT=4",
860 );
861 let old = ICalendar::parse(&old).unwrap();
862 // bob's client writes the rule back in its own form.
863 let new = text(&old)
864 .replace(
865 "RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO,TU",
866 "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU,MO;WKST=MO;COUNT=4",
867 )
868 .replace(
869 &format!("PARTSTAT=NEEDS-ACTION:{BOB}"),
870 &format!("PARTSTAT=ACCEPTED:{BOB}"),
871 );
872 assert_ne!(new, text(&old));
873 let (_, reply) = itip::attend(&old, ICalendar::parse(&new).unwrap(), &is(BOB), now()).unwrap();
874 assert!(reply.is_some());
875 // The same rewrite by the organizer keeps bob's answer.
876 let accepted = ICalendar::parse(text(&old).replace(
877 &format!("PARTSTAT=NEEDS-ACTION:{BOB}"),
878 &format!("PARTSTAT=ACCEPTED:{BOB}"),
879 ))
880 .unwrap();
881 let rewritten = text(&accepted).replace(
882 "RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO,TU",
883 "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU,MO;WKST=MO;COUNT=4",
884 );
885 let (store, _) = organize(
886 Some(&accepted),
887 Some(ICalendar::parse(&rewritten).unwrap()),
888 &is(ALICE),
889 now(),
890 );
891 assert!(
892 text(&store.unwrap()).contains(&format!("PARTSTAT=ACCEPTED:{BOB}")),
893 "rule rewrite reset bob"
894 );
895}
896
897#[test]
898fn a_cancelled_range_cancels_the_later_instances() {
899 use pimdav::expand::expand;
900 use pimdav::zone::Zone;
901 let copy = meeting(&format!("ATTENDEE:{BOB}\n"));
902 let msg = Message {
903 to: BOB.to_string(),
904 method: Method::Cancel,
905 quiet: false,
906 cal: std::sync::Arc::new(cal(&format!(
907 "METHOD:CANCEL\nBEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\
908 DTSTART:20260112T100000Z\nSEQUENCE:1\nSTATUS:CANCELLED\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n"
909 ))),
910 };
911 use pimdav::calcard::icalendar::{ICalendarProperty, ICalendarStatus, ICalendarValue};
912 let got = itip::receive(Some(&copy), &msg).unwrap();
913 let mut out = expand(&got, at(1, 0)..at(31, 0), Zone::Utc).instances;
914 out.sort_by_key(|i| i.start);
915 let cancelled: Vec<bool> = out
916 .iter()
917 .map(|i| {
918 matches!(
919 got.components[i.component]
920 .property(&ICalendarProperty::Status)
921 .and_then(|e| e.values.first()),
922 Some(ICalendarValue::Status(ICalendarStatus::Cancelled))
923 )
924 })
925 .collect();
926 assert_eq!(cancelled, [false, true, true, true], "{}", text(&got));
927}
928
929#[test]
930fn a_range_an_attendee_is_left_out_of_ends_their_series() {
931 use pimdav::expand::expand;
932 use pimdav::zone::Zone;
933 let org = cal(&format!(
934 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
935 RRULE:FREQ=WEEKLY;COUNT=8\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\nEND:VEVENT\n\
936 BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260119T100000Z\n\
937 DTSTART:20260119T100000Z\nDTEND:20260119T110000Z\nORGANIZER:{ALICE}\n\
938 ATTENDEE:{CAROL}\nEND:VEVENT\n"
939 ));
940 let (_, msgs) = organize(None, Some(org), &is(ALICE), now());
941 let bob = &to(&msgs, BOB).unwrap().cal;
942 let starts: Vec<_> = expand(
943 bob,
944 at(1, 0)..at(1, 0) + chrono::TimeDelta::days(90),
945 Zone::Utc,
946 )
947 .instances
948 .iter()
949 .map(|i| i.start)
950 .collect();
951 assert_eq!(starts, [at(5, 10), at(12, 10)], "{}", text(bob));
952 let carol = &to(&msgs, CAROL).unwrap().cal;
953 let window = at(1, 0)..at(1, 0) + chrono::TimeDelta::days(90);
954 assert_eq!(expand(carol, window, Zone::Utc).instances.len(), 8);
955}
956
957#[test]
958fn a_reply_status_keeps_only_its_code() {
959 let mut org = meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n"));
960 let reply = cal(&format!(
961 "METHOD:REPLY\nBEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nORGANIZER:{ALICE}\n\
962 ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nREQUEST-STATUS:2.0\",x:EVIL;Success\nEND:VEVENT\n"
963 ));
964 assert!(itip::apply_reply(&mut org, &reply, &is(BOB)).changed);
965 let org = text(&org);
966 assert!(
967 org.contains(&format!("SCHEDULE-STATUS=2.0:{BOB}")) && !org.contains("EVIL"),
968 "{org}"
969 );
970}
971
972#[test]
973fn attendees_may_not_change_an_instance_through_its_override() {
974 let base = "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
975 RRULE:FREQ=WEEKLY;COUNT=4\n";
976 let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
977 let over = |start: &str, end: &str| {
978 format!(
979 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:2026011{start}00Z\n\
980 DTEND:2026011{end}00Z\n{people}END:VEVENT\n"
981 )
982 };
983 let copy = cal(&format!("{base}{people}END:VEVENT\n"));
984 // A new override at the right start but longer.
985 let longer = cal(&format!(
986 "{base}{people}END:VEVENT\n{}",
987 over("2T1000", "2T1200")
988 ));
989 assert_eq!(
990 itip::attend(&copy, longer, &is(BOB), now()).err(),
991 Some(Refused::AttendeeChange)
992 );
993 // Dropping the organizer's move of an instance.
994 let moved = cal(&format!(
995 "{base}{people}END:VEVENT\n{}",
996 over("2T1400", "2T1500")
997 ));
998 assert_eq!(
999 itip::attend(&moved, copy.clone(), &is(BOB), now()).err(),
1000 Some(Refused::AttendeeChange)
1001 );
1002 // Dropping an override that only held a status is fine.
1003 let own = cal(&format!(
1004 "{base}{people}END:VEVENT\n{}",
1005 over("2T1000", "2T1100")
1006 ));
1007 assert!(itip::attend(&own, copy, &is(BOB), now()).is_ok());
1008}
1009
1010#[test]
1011fn attendees_may_delete_an_instance_or_drop_one_of_their_instances() {
1012 let base = "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1013 RRULE:FREQ=WEEKLY;COUNT=4\n";
1014 let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
1015 let moved = format!(
1016 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T140000Z\n\
1017 DTEND:20260112T150000Z\n{people}END:VEVENT\n"
1018 );
1019 // Deleting the instance the organizer moved: EXDATE and no override.
1020 let copy = cal(&format!("{base}{people}END:VEVENT\n{moved}"));
1021 let deleted = cal(&format!(
1022 "{base}EXDATE:20260112T100000Z\n{people}END:VEVENT\n"
1023 ));
1024 assert!(itip::attend(&copy, deleted, &is(BOB), now()).is_ok());
1025 // A copy of single instances: dropping one of them.
1026 let other = moved.replace("20260112T1", "20260119T1").replace(
1027 "RECURRENCE-ID:20260119T140000Z",
1028 "RECURRENCE-ID:20260119T100000Z",
1029 );
1030 let single = cal(&format!("{moved}{other}"));
1031 assert!(itip::attend(&single, cal(&other), &is(BOB), now()).is_ok());
1032}
1033
1034#[test]
1035fn an_override_without_end_has_the_default_length() {
1036 let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
1037 for (master, rid, start, end) in [
1038 (
1039 "DTSTART;VALUE=DATE:20260105",
1040 "RECURRENCE-ID;VALUE=DATE:20260112",
1041 "DTSTART;VALUE=DATE:20260112",
1042 "DTEND;VALUE=DATE:20260113",
1043 ),
1044 (
1045 "DTSTART:20260105T100000Z",
1046 "RECURRENCE-ID:20260112T100000Z",
1047 "DTSTART:20260112T100000Z",
1048 "DTEND:20260112T100000Z",
1049 ),
1050 ] {
1051 let series = format!(
1052 "BEGIN:VEVENT\nUID:m1\n{master}\nRRULE:FREQ=WEEKLY;COUNT=4\n{people}END:VEVENT\n"
1053 );
1054 let copy = cal(&series);
1055 let own = cal(&format!(
1056 "{series}BEGIN:VEVENT\nUID:m1\n{rid}\n{start}\n{end}\n{people}END:VEVENT\n"
1057 ));
1058 assert!(
1059 itip::attend(&copy, own, &is(BOB), now()).is_ok(),
1060 "{master}"
1061 );
1062 }
1063}
1064
1065#[test]
1066fn attendees_may_not_drop_a_cancelled_instance() {
1067 let series = format!(
1068 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1069 RRULE:FREQ=WEEKLY;COUNT=4\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n\
1070 END:VEVENT\n"
1071 );
1072 let cancelled = cal(&format!(
1073 "{series}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\n\
1074 DTSTART:20260112T100000Z\nDTEND:20260112T110000Z\nSTATUS:CANCELLED\n\
1075 ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n"
1076 ));
1077 assert_eq!(
1078 itip::attend(&cancelled, cal(&series), &is(BOB), now()).err(),
1079 Some(Refused::AttendeeChange)
1080 );
1081}
1082
1083#[test]
1084fn a_range_from_the_first_instance_cancels_the_whole_series() {
1085 let org = cal(&format!(
1086 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1087 RRULE:FREQ=WEEKLY;COUNT=8\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\nEND:VEVENT\n\
1088 BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260105T100000Z\n\
1089 DTSTART:20260105T120000Z\nDTEND:20260105T130000Z\nORGANIZER:{ALICE}\n\
1090 ATTENDEE:{CAROL}\nEND:VEVENT\n"
1091 ));
1092 let old = cal(&format!(
1093 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1094 RRULE:FREQ=WEEKLY;COUNT=8\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\nEND:VEVENT\n"
1095 ));
1096 let (_, msgs) = organize(Some(&old), Some(org), &is(ALICE), now());
1097 let bob = to(&msgs, BOB).unwrap();
1098 assert_eq!(bob.method, Method::Cancel, "{}", text(&bob.cal));
1099}
1100
1101#[test]
1102fn an_attendee_may_write_the_same_end_in_another_form() {
1103 let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
1104 for (stored, written) in [
1105 (
1106 "DTSTART:20260105T100000Z\nDURATION:PT1H",
1107 "DTSTART:20260105T100000Z\nDTEND:20260105T110000Z",
1108 ),
1109 (
1110 "DTSTART;VALUE=DATE:20260105",
1111 "DTSTART;VALUE=DATE:20260105\nDTEND;VALUE=DATE:20260106",
1112 ),
1113 (
1114 "DTSTART:20260105T100000Z",
1115 "DTSTART:20260105T100000Z\nDTEND:20260105T100000Z",
1116 ),
1117 // A nominal day across the October change lasts 25 hours.
1118 (
1119 "DTSTART;TZID=Europe/Berlin:20261024T120000\nDURATION:P1D",
1120 "DTSTART;TZID=Europe/Berlin:20261024T120000\nDTEND;TZID=Europe/Berlin:20261025T120000",
1121 ),
1122 ] {
1123 let event = |times: &str| {
1124 cal(&format!(
1125 "BEGIN:VEVENT\nUID:m1\n{times}\nRRULE:FREQ=WEEKLY;COUNT=4\n{people}END:VEVENT\n"
1126 ))
1127 };
1128 let accepted = text(&event(written)).replace(
1129 &format!("ATTENDEE:{BOB}"),
1130 &format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}"),
1131 );
1132 let (_, reply) = itip::attend(
1133 &event(stored),
1134 ICalendar::parse(&accepted).unwrap(),
1135 &is(BOB),
1136 now(),
1137 )
1138 .unwrap_or_else(|e| panic!("{stored}: {e:?}"));
1139 assert!(reply.is_some(), "{stored}");
1140 }
1141}
1142
1143#[test]
1144fn a_new_override_that_changes_the_length_is_a_reschedule() {
1145 let old = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
1146 let new = cal(&format!(
1147 "{}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\n\
1148 DTEND:20260112T130000Z\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n",
1149 text(&old)
1150 .trim_start_matches("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n")
1151 .trim_end_matches("END:VCALENDAR\r\n")
1152 .replace("\r\n", "\n")
1153 ));
1154 let (store, _) = organize(Some(&old), Some(new), &is(ALICE), now());
1155 let store = text(&store.unwrap());
1156 let longer = store.split("RECURRENCE-ID").nth(1).unwrap();
1157 assert!(longer.contains("SEQUENCE:1"), "{store}");
1158 assert!(
1159 longer.contains(&format!("PARTSTAT=NEEDS-ACTION:{BOB}")),
1160 "{store}"
1161 );
1162}
1163
1164#[test]
1165fn an_attendee_may_complete_one_instance() {
1166 let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
1167 let series = format!(
1168 "BEGIN:VTODO\nUID:t1\nDTSTART:20260105T100000Z\nDUE:20260105T110000Z\n\
1169 RRULE:FREQ=WEEKLY;COUNT=4\n{people}END:VTODO\n"
1170 );
1171 let done = cal(&format!(
1172 "{series}BEGIN:VTODO\nUID:t1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\n\
1173 DUE:20260112T110000Z\nSTATUS:COMPLETED\n{people}END:VTODO\n"
1174 ));
1175 assert!(itip::attend(&cal(&series), done, &is(BOB), now()).is_ok());
1176}
1177
1178#[test]
1179fn attendees_keep_the_organizers_status_of_an_instance() {
1180 let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
1181 let master = format!(
1182 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1183 RRULE:FREQ=WEEKLY;COUNT=4\nSTATUS:CONFIRMED\n{people}END:VEVENT\n"
1184 );
1185 let instance = |status: &str| {
1186 format!(
1187 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\n\
1188 DTEND:20260112T110000Z\nSTATUS:{status}\n{people}END:VEVENT\n"
1189 )
1190 };
1191 let tentative = cal(&format!("{master}{}", instance("TENTATIVE")));
1192 assert_eq!(
1193 itip::attend(&tentative, cal(&master), &is(BOB), now()).err(),
1194 Some(Refused::AttendeeChange)
1195 );
1196 let added = cal(&format!("{master}{}", instance("TENTATIVE")));
1197 assert_eq!(
1198 itip::attend(&cal(&master), added, &is(BOB), now()).err(),
1199 Some(Refused::AttendeeChange)
1200 );
1201 let same = cal(&format!("{master}{}", instance("CONFIRMED")));
1202 assert!(itip::attend(&cal(&master), same, &is(BOB), now()).is_ok());
1203}
1204
1205#[test]
1206fn dropping_a_declined_instance_replies_with_the_series_answer() {
1207 let master = format!(
1208 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1209 RRULE:FREQ=WEEKLY;COUNT=4\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n\
1210 ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n"
1211 );
1212 let copy = cal(&format!(
1213 "{master}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\n\
1214 DTEND:20260112T110000Z\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n\
1215 ATTENDEE;PARTSTAT=DECLINED:{BOB}\nEND:VEVENT\n"
1216 ));
1217 let (_, reply) = itip::attend(&copy, cal(&master), &is(BOB), now()).unwrap();
1218 let reply = text(&reply.unwrap().cal);
1219 assert!(reply.contains("RECURRENCE-ID:20260112T100000Z"), "{reply}");
1220 assert!(
1221 reply.contains(&format!("PARTSTAT=ACCEPTED:{BOB}")),
1222 "{reply}"
1223 );
1224}
1225
1226#[test]
1227fn a_reordered_save_sends_nothing() {
1228 let old = meeting(&format!(
1229 "ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nLOCATION:Room 1\n"
1230 ));
1231 let (store, _) = organize(None, Some(old), &is(ALICE), now());
1232 let old = store.unwrap();
1233 let reordered = text(&old)
1234 .replace("SUMMARY:Sync\r\n", "")
1235 .replace("LOCATION:Room 1\r\n", "LOCATION:Room 1\r\nSUMMARY:Sync\r\n")
1236 .replace("DTSTAMP:20260101T000000Z", "DTSTAMP:20260102T000000Z");
1237 let (_, msgs) = organize(
1238 Some(&old),
1239 Some(ICalendar::parse(&reordered).unwrap()),
1240 &is(ALICE),
1241 now(),
1242 );
1243 assert!(to(&msgs, BOB).is_none(), "{msgs:?}");
1244}
1245
1246#[test]
1247fn an_override_of_no_instance_is_refused_and_many_overrides_stay_fast() {
1248 let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
1249 let series = format!(
1250 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1251 RRULE:FREQ=DAILY;COUNT=5000\n{people}END:VEVENT\n"
1252 );
1253 let over = |rid: &str| {
1254 format!(
1255 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:{rid}\nDTSTART:{rid}\n\
1256 DTEND:{}\n{people}END:VEVENT\n",
1257 rid.replace("T10", "T11")
1258 )
1259 };
1260 let stray = cal(&format!("{series}{}", over("20260112T103000Z")));
1261 assert_eq!(
1262 itip::attend(&cal(&series), stray, &is(BOB), now()).err(),
1263 Some(Refused::AttendeeChange)
1264 );
1265 let day = |i: i64| {
1266 (chrono::NaiveDate::from_ymd_opt(2026, 1, 5).unwrap() + chrono::TimeDelta::days(i))
1267 .format("%Y%m%dT100000Z")
1268 .to_string()
1269 };
1270 // At most this many changes in one PUT.
1271 let many: String = (0..1000)
1272 .map(|i| {
1273 over(&day(i)).replace(
1274 &format!("ATTENDEE:{BOB}"),
1275 &format!("ATTENDEE;PARTSTAT=DECLINED:{BOB}"),
1276 )
1277 })
1278 .collect();
1279 let t = std::time::Instant::now();
1280 let (_, reply) = itip::attend(
1281 &cal(&series),
1282 cal(&format!("{series}{many}")),
1283 &is(BOB),
1284 now(),
1285 )
1286 .unwrap();
1287 let mut org = cal(&series);
1288 assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB)).changed);
1289 assert!(t.elapsed().as_secs() < 20, "{:?}", t.elapsed());
1290}
1291
1292#[test]
1293fn far_apart_replies_on_a_dense_series_all_land() {
1294 let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n");
1295 let series = format!(
1296 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDURATION:PT1M\n\
1297 RRULE:FREQ=MINUTELY\n{people}END:VEVENT\n"
1298 );
1299 let declined = |rid: &str| {
1300 format!(
1301 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:{rid}\nDTSTART:{rid}\nDURATION:PT1M\n\
1302 ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT=DECLINED:{BOB}\nEND:VEVENT\n"
1303 )
1304 };
1305 let copy = format!(
1306 "{series}{}{}",
1307 declined("20260105T100000Z"),
1308 declined("20280105T100000Z")
1309 );
1310 let (_, reply) = itip::attend(&cal(&series), cal(&copy), &is(BOB), now()).unwrap();
1311 let mut org = cal(&series);
1312 assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB)).changed);
1313 assert_eq!(
1314 text(&org).matches("PARTSTAT=DECLINED").count(),
1315 2,
1316 "{}",
1317 text(&org)
1318 );
1319}
1320
1321#[test]
1322fn a_stale_range_past_the_series_cuts_nothing() {
1323 let org = cal(&format!(
1324 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1325 RRULE:FREQ=DAILY;COUNT=3\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\nEND:VEVENT\n\
1326 BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260115T100000Z\n\
1327 DTSTART:20260115T120000Z\nDTEND:20260115T130000Z\nORGANIZER:{ALICE}\n\
1328 ATTENDEE:{CAROL}\nEND:VEVENT\n"
1329 ));
1330 let (_, msgs) = organize(None, Some(org), &is(ALICE), now());
1331 let bob = text(&to(&msgs, BOB).unwrap().cal);
1332 assert!(bob.contains("COUNT=3"), "{bob}");
1333 assert!(!bob.contains("UNTIL"), "{bob}");
1334}
1335
1336#[test]
1337fn a_huge_duration_does_not_panic() {
1338 let huge = cal(&format!(
1339 "BEGIN:VEVENT\nUID:m1\nDTSTART;TZID=Europe/Berlin:20260105T100000\nDURATION:P99999999W\n\
1340 ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\nEND:VEVENT\n"
1341 ));
1342 organize(Some(&huge), Some(huge.clone()), &is(ALICE), now());
1343 let accepted = ICalendar::parse(text(&huge).replace("NEEDS-ACTION", "ACCEPTED")).unwrap();
1344 assert!(itip::attend(&huge, accepted, &is(BOB), now()).is_ok());
1345}
1346
1347#[test]
1348fn many_attendees_and_ranges_stay_fast() {
1349 let people: String = (0..50)
1350 .map(|i| format!("ATTENDEE:mailto:u{i}@example.com\n"))
1351 .collect();
1352 let second = |n: i64| {
1353 (Utc.with_ymd_and_hms(2026, 1, 5, 10, 0, 0).unwrap() + chrono::TimeDelta::seconds(n))
1354 .format("%Y%m%dT%H%M%SZ")
1355 .to_string()
1356 };
1357 let ranges: String = (1..=20)
1358 .map(|i| {
1359 let at = second(i * 400);
1360 format!(
1361 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:{at}\nDTSTART:{at}\n\
1362 DURATION:PT1M\nORGANIZER:{ALICE}\nATTENDEE:mailto:u0@example.com\nEND:VEVENT\n"
1363 )
1364 })
1365 .collect();
1366 let org = cal(&format!(
1367 "BEGIN:VEVENT\nUID:m1\nDTSTART:{}\nDURATION:PT1M\nRRULE:FREQ=SECONDLY;COUNT=10000\n\
1368 ORGANIZER:{ALICE}\n{people}END:VEVENT\n{ranges}",
1369 second(0)
1370 ));
1371 let t = std::time::Instant::now();
1372 let (_, msgs) = organize(None, Some(org), &is(ALICE), now());
1373 assert!(t.elapsed().as_secs() < 5, "{:?}", t.elapsed());
1374 let u1 = text(&to(&msgs, "mailto:u1@example.com").unwrap().cal);
1375 assert!(u1.contains("UNTIL=20260105T100639Z"), "{u1}");
1376}
1377
1378#[test]
1379fn a_copy_with_many_overrides_is_answered_fast() {
1380 let people =
1381 format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n");
1382 let day = |i: i64| {
1383 (chrono::NaiveDate::from_ymd_opt(2026, 1, 5).unwrap() + chrono::TimeDelta::days(i))
1384 .format("%Y%m%dT100000")
1385 .to_string()
1386 };
1387 let overrides: String = (0..8000)
1388 .map(|i| {
1389 format!(
1390 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;TZID=Europe/Berlin:{d}\n\
1391 DTSTART;TZID=Europe/Berlin:{d}\nDURATION:PT1H\nSUMMARY:{i}\n{people}END:VEVENT\n",
1392 d = day(i)
1393 )
1394 })
1395 .collect();
1396 let copy = cal(&format!(
1397 "BEGIN:VEVENT\nUID:m1\nDTSTART;TZID=Europe/Berlin:{}\nDURATION:PT1H\n\
1398 RRULE:FREQ=DAILY;COUNT=9000\n{people}END:VEVENT\n{overrides}",
1399 day(0)
1400 ));
1401 let answered = ICalendar::parse(text(&copy).replacen(
1402 &format!("PARTSTAT=NEEDS-ACTION:{BOB}"),
1403 &format!("PARTSTAT=ACCEPTED:{BOB}"),
1404 1,
1405 ))
1406 .unwrap();
1407 let t = std::time::Instant::now();
1408 let (_, reply) = itip::attend(&copy, answered, &is(BOB), now()).unwrap();
1409 assert!(reply.is_some());
1410 assert!(t.elapsed().as_secs() < 10, "{:?}", t.elapsed());
1411}
1412
1413#[test]
1414fn clients_may_name_the_week_start_or_confirm_their_instance() {
1415 let people =
1416 format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n");
1417 let series = format!(
1418 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260107T100000Z\nDTEND:20260107T110000Z\n\
1419 RRULE:FREQ=WEEKLY;BYDAY=WE;COUNT=4\n{people}END:VEVENT\n"
1420 );
1421 let accepted = series.replace("NEEDS-ACTION", "ACCEPTED");
1422 let with_wkst = accepted.replace("COUNT=4", "COUNT=4;WKST=SU");
1423 assert!(itip::attend(&cal(&series), cal(&with_wkst), &is(BOB), now()).is_ok());
1424 // Every other week depends on where weeks start.
1425 let biweekly = series.replace("FREQ=WEEKLY", "FREQ=WEEKLY;INTERVAL=2");
1426 let moved = biweekly
1427 .replace("NEEDS-ACTION", "ACCEPTED")
1428 .replace("COUNT=4", "COUNT=4;WKST=SU");
1429 assert_eq!(
1430 itip::attend(&cal(&biweekly), cal(&moved), &is(BOB), now()).err(),
1431 Some(Refused::AttendeeChange)
1432 );
1433 let confirmed = cal(&format!(
1434 "{series}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260114T100000Z\nDTSTART:20260114T100000Z\n\
1435 DTEND:20260114T110000Z\nSTATUS:CONFIRMED\n{}END:VEVENT\n",
1436 people.replace("NEEDS-ACTION", "ACCEPTED")
1437 ));
1438 assert!(itip::attend(&cal(&series), confirmed.clone(), &is(BOB), now()).is_ok());
1439 // The organizer's CONFIRMED instance is not the attendee's to drop.
1440 assert_eq!(
1441 itip::attend(&confirmed, cal(&accepted), &is(BOB), now()).err(),
1442 Some(Refused::AttendeeChange)
1443 );
1444}
1445
1446#[test]
1447fn a_sequence_bump_reaches_the_copies_quietly() {
1448 let old = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"));
1449 let (store, _) = organize(None, Some(old), &is(ALICE), now());
1450 let old = store.unwrap();
1451 let bumped =
1452 ICalendar::parse(text(&old).replace("SUMMARY:Sync", "SUMMARY:Sync\r\nSEQUENCE:3")).unwrap();
1453 let (store, msgs) = organize(Some(&old), Some(bumped), &is(ALICE), now());
1454 let bob = to(&msgs, BOB).expect("no update for bob");
1455 assert!(bob.quiet);
1456 assert!(text(&bob.cal).contains("SEQUENCE:3"));
1457 // bob's later answer carries that SEQUENCE and lands.
1458 let copy = itip::receive(
1459 None,
1460 &Message {
1461 quiet: false,
1462 ..bob.clone()
1463 },
1464 )
1465 .unwrap();
1466 let declined = ICalendar::parse(text(&copy).replace(
1467 &format!("PARTSTAT=NEEDS-ACTION:{BOB}"),
1468 &format!("PARTSTAT=DECLINED:{BOB}"),
1469 ))
1470 .unwrap();
1471 let (_, reply) = itip::attend(&copy, declined, &is(BOB), now()).unwrap();
1472 assert!(reply.is_some(), "{}", text(&copy));
1473 let mut org = store.unwrap();
1474 assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB)).changed);
1475}
1476
1477#[test]
1478fn many_attendees_share_one_message_body() {
1479 let people: String = (0..2000)
1480 .map(|i| format!("ATTENDEE:mailto:u{i}@example.com\n"))
1481 .collect();
1482 let new = cal(&format!(
1483 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1484 SUMMARY:All hands\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n{people}END:VEVENT\n"
1485 ));
1486 let t = std::time::Instant::now();
1487 let (store, msgs) = organize(None, Some(new), &is(ALICE), now());
1488 assert_eq!(msgs.len(), 2000);
1489 assert!(
1490 msgs.iter()
1491 .all(|m| std::sync::Arc::ptr_eq(&m.cal, &msgs[0].cal))
1492 );
1493 let store = store.unwrap();
1494 let (_, again) = organize(Some(&store), Some(store.clone()), &is(ALICE), now());
1495 assert!(again.is_empty());
1496 assert!(t.elapsed().as_secs() < 2, "{:?}", t.elapsed());
1497}
1498
1499/// An endless daily series alice organizes, bob's copy of it, and bob's
1500/// copy with `n` instances deleted.
1501fn endless_with_exdates(n: i64) -> (ICalendar, ICalendar, ICalendar) {
1502 let series = |bob: &str, extra: &str| {
1503 cal(&format!(
1504 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1505 RRULE:FREQ=DAILY\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n\
1506 ATTENDEE;PARTSTAT={bob}:{BOB}\n{extra}END:VEVENT\n"
1507 ))
1508 };
1509 let days: Vec<String> = (1..=n)
1510 .map(|i| {
1511 (chrono::NaiveDate::from_ymd_opt(2026, 1, 5).unwrap() + chrono::TimeDelta::days(i))
1512 .format("%Y%m%dT100000Z")
1513 .to_string()
1514 })
1515 .collect();
1516 let exdate = format!("EXDATE:{}\n", days.join(","));
1517 (
1518 series("ACCEPTED", ""),
1519 series("ACCEPTED", ""),
1520 series("ACCEPTED", &exdate),
1521 )
1522}
1523
1524#[test]
1525fn an_attendee_may_delete_many_instances_but_not_without_end() {
1526 let (_, copy, many) = endless_with_exdates(8000);
1527 assert_eq!(
1528 itip::attend(&copy, many, &is(BOB), now()).err(),
1529 Some(Refused::AttendeeChange)
1530 );
1531 let (mut org, copy, some) = endless_with_exdates(1000);
1532 let t = std::time::Instant::now();
1533 let (_, reply) = itip::attend(&copy, some, &is(BOB), now()).unwrap();
1534 assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB)).changed);
1535 assert_eq!(org.components.len(), 1002);
1536 assert!(t.elapsed().as_secs() < 10, "{:?}", t.elapsed());
1537}
1538
1539#[test]
1540fn a_reply_never_grows_the_object_past_the_limit() {
1541 let (mut org, copy, some) = endless_with_exdates(1000);
1542 // 3101 components before the reply: the VCALENDAR, the master and 3099
1543 // overrides of later days.
1544 let overrides: String = (2000..5099)
1545 .map(|i| {
1546 let d = (chrono::NaiveDate::from_ymd_opt(2026, 1, 5).unwrap()
1547 + chrono::TimeDelta::days(i))
1548 .format("%Y%m%dT100000Z");
1549 format!(
1550 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:{d}\nDTSTART:{d}\nDTEND:{d}\n\
1551 ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n"
1552 )
1553 })
1554 .collect();
1555 org = ICalendar::parse(text(&org).replace(
1556 "END:VCALENDAR\r\n",
1557 &format!("{}END:VCALENDAR\r\n", overrides.replace('\n', "\r\n")),
1558 ))
1559 .unwrap();
1560 assert_eq!(org.components.len(), 3101);
1561 let (_, reply) = itip::attend(&copy, some, &is(BOB), now()).unwrap();
1562 let applied = itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB));
1563 assert!(applied.changed);
1564 assert!(applied.dropped > 0);
1565 assert_eq!(org.components.len(), 4000);
1566}
1567
1568#[test]
1569fn narrowing_a_range_never_grows_the_object_past_the_limit() {
1570 let day = |i: i64| {
1571 (chrono::NaiveDate::from_ymd_opt(2026, 2, 1).unwrap() + chrono::TimeDelta::days(i))
1572 .format("%Y%m%dT100000Z")
1573 .to_string()
1574 };
1575 let part = |rid: &str, extra: &str, bob: &str| {
1576 format!(
1577 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID{extra}:{rid}\nDTSTART:{rid}\nDTEND:{rid}\n\
1578 ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT={bob}:{BOB}\nEND:VEVENT\n"
1579 )
1580 };
1581 // 3993 components: the VCALENDAR, the master, a range from February 1
1582 // and 3990 plain overrides well after it.
1583 let padding: String = (2000..5990)
1584 .map(|i| part(&day(i), "", "ACCEPTED"))
1585 .collect();
1586 let mut org = cal(&format!(
1587 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1588 RRULE:FREQ=DAILY\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n\
1589 ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n{}{padding}",
1590 part(&day(0), ";RANGE=THISANDFUTURE", "ACCEPTED")
1591 ));
1592 assert_eq!(org.components.len(), 3993);
1593 // Each part for one instance under the range splits it again.
1594 let parts: String = (0..20).map(|i| part(&day(i), "", "DECLINED")).collect();
1595 let reply = ICalendar::parse(
1596 format!(
1597 "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//t//t//EN\nMETHOD:REPLY\n{parts}END:VCALENDAR\n"
1598 )
1599 .replace('\n', "\r\n"),
1600 )
1601 .unwrap();
1602 let applied = itip::apply_reply(&mut org, &reply, &is(BOB));
1603 assert!(applied.changed);
1604 assert!(applied.dropped > 0);
1605 assert!(org.components.len() <= 4000, "{}", org.components.len());
1606}
1607
1608/// What alice's save of `new` over `old` does to bob's ACCEPTED.
1609fn keeps_bobs_answer(old_rule: &str, new_rule: &str) -> bool {
1610 let series = |rule: &str| {
1611 let m = text(&meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n")));
1612 ICalendar::parse(m.replace("COUNT=4", rule)).unwrap()
1613 };
1614 let (store, _) = organize(
1615 Some(&series(old_rule)),
1616 Some(series(new_rule)),
1617 &is(ALICE),
1618 now(),
1619 );
1620 text(&store.unwrap()).contains(&format!("PARTSTAT=ACCEPTED:{BOB}"))
1621}
1622
1623#[test]
1624fn count_and_until_compare_by_the_last_instance() {
1625 // The meeting is on Mondays from January 5.
1626 assert!(keeps_bobs_answer("COUNT=4", "UNTIL=20260119T100000Z"));
1627 assert!(keeps_bobs_answer("UNTIL=20260126T100000Z", "COUNT=3"));
1628 assert!(keeps_bobs_answer("COUNT=4", "COUNT=3"));
1629 assert!(!keeps_bobs_answer("COUNT=3", "UNTIL=20260202T100000Z"));
1630}
1631
1632#[test]
1633fn an_attendee_may_write_the_same_rule_in_another_form() {
1634 let answer = |old_rule: &str, new_rule: &str| {
1635 let copy = text(&meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n")))
1636 .replace("COUNT=4", old_rule);
1637 let answered = copy
1638 .replace("PARTSTAT=NEEDS-ACTION", "PARTSTAT=ACCEPTED")
1639 .replace(old_rule, new_rule);
1640 itip::attend(
1641 &ICalendar::parse(&copy).unwrap(),
1642 ICalendar::parse(&answered).unwrap(),
1643 &is(BOB),
1644 now(),
1645 )
1646 .map(|_| ())
1647 };
1648 assert_eq!(answer("UNTIL=20260126", "UNTIL=20260126T100000Z"), Ok(()));
1649 assert_eq!(answer("COUNT=4", "COUNT=4;BYDAY=MO"), Ok(()));
1650 assert_eq!(answer("COUNT=4", "UNTIL=20260126T100000Z"), Ok(()));
1651 assert_eq!(
1652 answer("COUNT=4", "UNTIL=20260202T100000Z"),
1653 Err(Refused::AttendeeChange)
1654 );
1655 assert_eq!(
1656 answer("COUNT=4", "COUNT=4;BYDAY=MO,TU"),
1657 Err(Refused::AttendeeChange)
1658 );
1659}
1660
1661#[test]
1662fn moving_a_far_until_costs_only_the_gap() {
1663 let series = |until: &str, bob: &str| {
1664 cal(&format!(
1665 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T100100Z\n\
1666 RRULE:FREQ=MINUTELY;UNTIL={until}\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n\
1667 ATTENDEE;PARTSTAT={bob}:{BOB}\nEND:VEVENT\n"
1668 ))
1669 };
1670 let t = std::time::Instant::now();
1671 let (store, _) = organize(
1672 Some(&series("99991231T000000Z", "ACCEPTED")),
1673 Some(series("99991230T000000Z", "ACCEPTED")),
1674 &is(ALICE),
1675 now(),
1676 );
1677 assert!(text(&store.unwrap()).contains(&format!("PARTSTAT=ACCEPTED:{BOB}")));
1678 // A DATE end that keeps the same instances: none falls between the two.
1679 let copy = series("99991230T235930Z", "NEEDS-ACTION");
1680 let answered = series("99991230", "ACCEPTED");
1681 let attended = itip::attend(&copy, answered, &is(BOB), now());
1682 assert!(attended.is_ok(), "{:?}", attended.err());
1683 assert!(t.elapsed().as_millis() < 1000, "{:?}", t.elapsed());
1684}
1685
1686#[test]
1687fn rule_ends_compare_in_the_wall_time_of_the_start() {
1688 let series = |start: &str, rule: &str, bob: &str| {
1689 cal(&format!(
1690 "BEGIN:VEVENT\nUID:m1\nDTSTART;TZID={start}\nDURATION:PT1H\nRRULE:{rule}\n\
1691 ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT={bob}:{BOB}\nEND:VEVENT\n"
1692 ))
1693 };
1694 // Whether alice's edit keeps bob's answer, and whether bob may write it.
1695 let check = |start: &str, old: &str, new: &str| {
1696 let (store, _) = organize(
1697 Some(&series(start, old, "ACCEPTED")),
1698 Some(series(start, new, "ACCEPTED")),
1699 &is(ALICE),
1700 now(),
1701 );
1702 let kept = text(&store.unwrap()).contains(&format!("PARTSTAT=ACCEPTED:{BOB}"));
1703 let copy = series(start, old, "NEEDS-ACTION");
1704 let attended = itip::attend(&copy, series(start, new, "ACCEPTED"), &is(BOB), now());
1705 (kept, attended.is_ok())
1706 };
1707 let la = "America/Los_Angeles:20260105T170000";
1708 let tokyo = "Asia/Tokyo:20260105T080000";
1709 let daily = "FREQ=DAILY;COUNT=4";
1710 assert_eq!(check(la, daily, "FREQ=DAILY;UNTIL=20260108"), (true, true));
1711 assert_eq!(
1712 check(la, daily, "FREQ=DAILY;UNTIL=20260108T170000"),
1713 (true, true)
1714 );
1715 assert_eq!(
1716 check(tokyo, daily, "FREQ=DAILY;UNTIL=20260108"),
1717 (true, true)
1718 );
1719 // Saturday the 10th in Tokyo and Friday the 9th in Los Angeles are new.
1720 let saturday = "Asia/Tokyo:20260103T080000";
1721 let friday = "America/Los_Angeles:20260102T170000";
1722 let weekly = |until: &str| format!("FREQ=WEEKLY;UNTIL={until}");
1723 assert_eq!(
1724 check(saturday, &weekly("20260109"), &weekly("20260110")),
1725 (false, false)
1726 );
1727 assert_eq!(
1728 check(friday, &weekly("20260108"), &weekly("20260109")),
1729 (false, false)
1730 );
1731}
1732
1733#[test]
1734fn a_range_on_the_last_day_of_a_date_until_ends_the_series_before_it() {
1735 use pimdav::expand::expand;
1736 use pimdav::zone::Zone;
1737 let org = cal(&format!(
1738 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\
1739 RRULE:FREQ=WEEKLY;UNTIL=20260119\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\n\
1740 END:VEVENT\nBEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260119T100000Z\n\
1741 DTSTART:20260119T100000Z\nDTEND:20260119T110000Z\nORGANIZER:{ALICE}\n\
1742 ATTENDEE:{CAROL}\nEND:VEVENT\n"
1743 ));
1744 let (_, msgs) = organize(None, Some(org), &is(ALICE), now());
1745 let bob = &to(&msgs, BOB).unwrap().cal;
1746 let window = at(1, 0)..at(1, 0) + chrono::TimeDelta::days(90);
1747 let starts: Vec<_> = expand(bob, window, Zone::Utc)
1748 .instances
1749 .iter()
1750 .map(|i| i.start)
1751 .collect();
1752 assert_eq!(starts, [at(5, 10), at(12, 10)], "{}", text(bob));
1753}
1754
1755#[test]
1756fn far_rule_changes_on_many_components_share_one_budget() {
1757 let never = "BEGIN:STANDARD\nDTSTART:19700101T000000\nTZOFFSETFROM:+0100\nTZOFFSETTO:+0100\n\
1758 RRULE:FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=30\nEND:STANDARD\n";
1759 let zone = format!(
1760 "BEGIN:VTIMEZONE\nTZID:Never\n{}END:VTIMEZONE\n",
1761 never.repeat(20)
1762 );
1763 let series = |until: &str, bob: &str| {
1764 let tail = format!(
1765 "DURATION:PT1S\nRRULE:FREQ=SECONDLY;UNTIL={until}\nORGANIZER:{ALICE}\n\
1766 ATTENDEE:{ALICE}\nATTENDEE;PARTSTAT={bob}:{BOB}\nEND:VEVENT\n"
1767 );
1768 let mut body =
1769 format!("{zone}BEGIN:VEVENT\nUID:m1\nDTSTART;TZID=Never:20260105T100000\n{tail}");
1770 for n in 1..=10 {
1771 body += &format!(
1772 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;TZID=Never:20260105T1000{n:02}\n\
1773 DTSTART;TZID=Never:20260105T1000{n:02}\n{tail}"
1774 );
1775 }
1776 cal(&body)
1777 };
1778 let t = std::time::Instant::now();
1779 let (store, _) = organize(
1780 Some(&series("20270101T000000Z", "ACCEPTED")),
1781 Some(series("20570101T000000Z", "ACCEPTED")),
1782 &is(ALICE),
1783 now(),
1784 );
1785 assert!(!text(&store.unwrap()).contains(&format!("PARTSTAT=ACCEPTED:{BOB}")));
1786 let copy = series("20270101T000000Z", "NEEDS-ACTION");
1787 let answered = series("20570101T000000Z", "ACCEPTED");
1788 assert!(itip::attend(&copy, answered, &is(BOB), now()).is_err());
1789 assert!(t.elapsed().as_secs() < 10, "{:?}", t.elapsed());
1790}
1791
1792#[test]
1793fn many_far_apart_replies_on_an_endless_dense_series_stay_fast() {
1794 let series = format!(
1795 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDURATION:PT1S\n\
1796 RRULE:FREQ=SECONDLY\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n"
1797 );
1798 let parts: String = (1..=200)
1799 .map(|d| {
1800 let rid = (Utc.with_ymd_and_hms(2026, 1, 5, 10, 0, 0).unwrap()
1801 + chrono::TimeDelta::days(d))
1802 .format("%Y%m%dT%H%M%SZ");
1803 format!(
1804 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:{rid}\nDTSTART:{rid}\nDURATION:PT1S\n\
1805 ORGANIZER:{ALICE}\nATTENDEE;PARTSTAT=DECLINED:{BOB}\nEND:VEVENT\n"
1806 )
1807 })
1808 .collect();
1809 let mut org = cal(&series);
1810 let t = std::time::Instant::now();
1811 let applied = itip::apply_reply(&mut org, &cal(&parts), &is(BOB));
1812 assert!(t.elapsed().as_secs() < 10, "{:?}", t.elapsed());
1813 // Each far key costs two days of seconds to check, so the budget ends
1814 // early; the rest are reported, not taken unchecked.
1815 let landed = text(&org).matches("PARTSTAT=DECLINED").count();
1816 assert!(applied.changed && landed > 0 && applied.dropped > 0);
1817 assert_eq!(landed + applied.dropped, 200);
1818}
1819
1820#[test]
1821fn a_reply_may_not_add_an_override_of_no_instance_by_a_far_key() {
1822 let series = format!(
1823 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDURATION:PT1H\nRRULE:FREQ=DAILY\n\
1824 ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n"
1825 );
1826 let part = |rid: &str| {
1827 format!(
1828 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:{rid}\nDTSTART:{rid}\nDURATION:PT1H\n\
1829 ORGANIZER:{ALICE}\nATTENDEE;PARTSTAT=DECLINED:{BOB}\nEND:VEVENT\n"
1830 )
1831 };
1832 let reply = format!("{}{}", part("20260107T153000Z"), part("24000105T100000Z"));
1833 let mut org = cal(&series);
1834 itip::apply_reply(&mut org, &cal(&reply), &is(BOB));
1835 let out = text(&org);
1836 assert!(!out.contains("RECURRENCE-ID:20260107T153000Z"), "{out}");
1837 assert!(out.contains("RECURRENCE-ID:24000105T100000Z"), "{out}");
1838}
1839
1840#[test]
1841fn many_ranges_one_attendee_is_left_out_of_stay_fast() {
1842 let day = |n: i64| {
1843 (Utc.with_ymd_and_hms(2026, 1, 5, 10, 0, 0).unwrap() + chrono::TimeDelta::days(n))
1844 .format("%Y%m%dT%H%M%SZ")
1845 .to_string()
1846 };
1847 let ranges: String = (1..=1000)
1848 .map(|i| {
1849 let at = day(i * 10);
1850 format!(
1851 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:{at}\nDTSTART:{at}\n\
1852 DURATION:PT1H\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n"
1853 )
1854 })
1855 .collect();
1856 let org = cal(&format!(
1857 "BEGIN:VEVENT\nUID:m1\nDTSTART:{}\nDURATION:PT1H\nRRULE:FREQ=DAILY;COUNT=100000\n\
1858 ORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\nEND:VEVENT\n{ranges}",
1859 day(0)
1860 ));
1861 let t = std::time::Instant::now();
1862 let (_, msgs) = organize(None, Some(org), &is(ALICE), now());
1863 assert!(t.elapsed().as_secs() < 5, "{:?}", t.elapsed());
1864 let carol = text(&to(&msgs, CAROL).unwrap().cal);
1865 assert!(carol.contains("UNTIL=20260115T095959Z"), "{carol}");
1866}
1867
1868#[test]
1869fn clients_may_spell_out_the_month_and_day_of_a_rule() {
1870 let copy = |rule: &str| {
1871 text(&meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n")))
1872 .replace("FREQ=WEEKLY;COUNT=4", rule)
1873 };
1874 let answer = |old_rule: &str, new_rule: &str| {
1875 itip::attend(
1876 &ICalendar::parse(copy(old_rule)).unwrap(),
1877 ICalendar::parse(copy(new_rule).replace("ACCEPTED:mailto:bob", "DECLINED:mailto:bob"))
1878 .unwrap(),
1879 &is(BOB),
1880 now(),
1881 )
1882 .map(|_| ())
1883 };
1884 assert_eq!(
1885 answer(
1886 "FREQ=YEARLY;COUNT=3",
1887 "FREQ=YEARLY;COUNT=3;BYMONTH=1;BYMONTHDAY=5"
1888 ),
1889 Ok(())
1890 );
1891 assert_eq!(
1892 answer("FREQ=MONTHLY;COUNT=3", "FREQ=MONTHLY;COUNT=3;BYMONTHDAY=5"),
1893 Ok(())
1894 );
1895 assert_eq!(
1896 answer("FREQ=MONTHLY;COUNT=3", "FREQ=MONTHLY;COUNT=3;BYMONTHDAY=6"),
1897 Err(Refused::AttendeeChange)
1898 );
1899 let (store, _) = organize(
1900 Some(&ICalendar::parse(copy("FREQ=YEARLY")).unwrap()),
1901 Some(ICalendar::parse(copy("FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=5")).unwrap()),
1902 &is(ALICE),
1903 now(),
1904 );
1905 let store = text(&store.unwrap());
1906 assert!(
1907 store.contains(&format!("PARTSTAT=ACCEPTED:{BOB}")),
1908 "{store}"
1909 );
1910}
1911
1912#[test]
1913fn a_spelled_out_rule_that_adds_leap_days_is_a_change() {
1914 let copy = |rule: &str, stat: &str| {
1915 text(&meeting(&format!("ATTENDEE;PARTSTAT={stat}:{BOB}\n")))
1916 .replace("DTSTART:20260105T100000Z", "DTSTART:20260228T100000Z")
1917 .replace("DTEND:20260105T110000Z", "DTEND:20260228T110000Z")
1918 .replace("FREQ=WEEKLY;COUNT=4", rule)
1919 };
1920 let (old, new) = ("FREQ=YEARLY", "FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=28,29");
1921 let answer = itip::attend(
1922 &ICalendar::parse(copy(old, "ACCEPTED")).unwrap(),
1923 ICalendar::parse(copy(new, "DECLINED")).unwrap(),
1924 &is(BOB),
1925 now(),
1926 );
1927 assert_eq!(answer.map(|_| ()), Err(Refused::AttendeeChange));
1928 let (store, _) = organize(
1929 Some(&ICalendar::parse(copy(old, "ACCEPTED")).unwrap()),
1930 Some(ICalendar::parse(copy(new, "ACCEPTED")).unwrap()),
1931 &is(ALICE),
1932 now(),
1933 );
1934 let store = text(&store.unwrap());
1935 assert!(
1936 store.contains(&format!("PARTSTAT=NEEDS-ACTION:{BOB}")),
1937 "{store}"
1938 );
1939}
1940
1941#[test]
1942fn far_ranges_on_an_endless_dense_series_stay_fast() {
1943 let day = |n: i64| {
1944 (Utc.with_ymd_and_hms(2026, 1, 5, 10, 0, 0).unwrap() + chrono::TimeDelta::days(n))
1945 .format("%Y%m%dT%H%M%SZ")
1946 .to_string()
1947 };
1948 let ranges: String = (1..=60)
1949 .map(|i| {
1950 let at = day(i * 30);
1951 format!(
1952 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:{at}\nDTSTART:{at}\n\
1953 DURATION:PT1M\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n"
1954 )
1955 })
1956 .collect();
1957 let org = cal(&format!(
1958 "BEGIN:VEVENT\nUID:m1\nDTSTART:{}\nDURATION:PT1M\nRRULE:FREQ=MINUTELY\n\
1959 ORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\nEND:VEVENT\n{ranges}",
1960 day(0)
1961 ));
1962 let t = std::time::Instant::now();
1963 let (_, msgs) = organize(None, Some(org), &is(ALICE), now());
1964 assert!(t.elapsed().as_secs() < 5, "{:?}", t.elapsed());
1965 assert!(to(&msgs, CAROL).is_some());
1966}
1967
1968#[test]
1969fn a_series_left_out_of_two_far_ranges_ends_at_the_first() {
1970 let range = |at: &str| {
1971 format!(
1972 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:{at}\nDTSTART:{at}\n\
1973 DURATION:PT1S\nORGANIZER:{ALICE}\nATTENDEE:{CAROL}\nEND:VEVENT\n"
1974 )
1975 };
1976 // One expansion over both ranges is cut, and so is a small one per key:
1977 // two days of seconds.
1978 for (a, b) in [
1979 ("20260601T100000Z", "20260901T100000Z"),
1980 ("20260901T100000Z", "20260601T100000Z"),
1981 ] {
1982 let org = cal(&format!(
1983 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260101T100000Z\nDURATION:PT1S\n\
1984 RRULE:FREQ=SECONDLY\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\n\
1985 END:VEVENT\n{}{}",
1986 range(a),
1987 range(b)
1988 ));
1989 let (_, msgs) = organize(None, Some(org), &is(ALICE), now());
1990 let bob = text(&to(&msgs, BOB).expect("bob is invited").cal);
1991 assert!(bob.contains("UNTIL=20260601T095"), "{bob}");
1992 }
1993}
1994
1995#[test]
1996fn a_month_rule_that_adds_leap_days_is_a_change() {
1997 let copy = |rule: &str, stat: &str| {
1998 text(&meeting(&format!("ATTENDEE;PARTSTAT={stat}:{BOB}\n")))
1999 .replace("DTSTART:20260105T100000Z", "DTSTART:20260129T100000Z")
2000 .replace("DTEND:20260105T110000Z", "DTEND:20260129T110000Z")
2001 .replace("FREQ=WEEKLY;COUNT=4", rule)
2002 };
2003 let (old, new) = (
2004 "FREQ=MONTHLY;BYMONTH=2",
2005 "FREQ=MONTHLY;BYMONTH=2;BYMONTHDAY=30",
2006 );
2007 let answer = itip::attend(
2008 &ICalendar::parse(copy(old, "ACCEPTED")).unwrap(),
2009 ICalendar::parse(copy(new, "DECLINED")).unwrap(),
2010 &is(BOB),
2011 now(),
2012 );
2013 assert_eq!(answer.map(|_| ()), Err(Refused::AttendeeChange));
2014}
2015
2016#[test]
2017fn an_attendee_may_not_answer_an_instance_no_one_can_confirm() {
2018 let series = format!(
2019 "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDURATION:PT1S\n\
2020 RRULE:FREQ=SECONDLY\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n\
2021 ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n"
2022 );
2023 // Each key costs two days of seconds to check, so the budget ends
2024 // before the tenth.
2025 let overrides: String = (10..20)
2026 .map(|d| {
2027 let rid = format!("203001{d}T100000Z");
2028 format!(
2029 "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:{rid}\nDTSTART:{rid}\nDURATION:PT1S\n\
2030 ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT=DECLINED:{BOB}\n\
2031 END:VEVENT\n"
2032 )
2033 })
2034 .collect();
2035 let copy = format!("{series}{overrides}");
2036 let answer = itip::attend(&cal(&series), cal(&copy), &is(BOB), now());
2037 assert_eq!(answer.map(|_| ()), Err(Refused::AttendeeChange));
2038}
2039