//! Implicit scheduling: what organizer and attendee changes send, and how //! the copies change. use chrono::{TimeZone, Utc}; use pimdav::calcard::icalendar::ICalendar; use pimdav::itip::{self, Message, Method, Refused, Role}; const ALICE: &str = "mailto:alice@dovenest.invalid"; const BOB: &str = "mailto:bob@dovenest.invalid"; const CAROL: &str = "mailto:carol@example.com"; fn is(who: &'static str) -> impl Fn(&str) -> bool { move |a: &str| a.eq_ignore_ascii_case(who) } fn cal(body: &str) -> ICalendar { let text = format!( "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n{}END:VCALENDAR\r\n", body.replace('\n', "\r\n") ); ICalendar::parse(&text).unwrap() } /// A weekly event organized by alice, with the given extra lines. fn meeting(extra: &str) -> ICalendar { cal(&format!( "BEGIN:VEVENT UID:m1 DTSTAMP:20260101T000000Z DTSTART:20260105T100000Z DTEND:20260105T110000Z RRULE:FREQ=WEEKLY;COUNT=4 SUMMARY:Sync ORGANIZER:{ALICE} ATTENDEE;PARTSTAT=ACCEPTED:{ALICE} {extra}END:VEVENT " )) } fn now() -> chrono::DateTime { Utc.with_ymd_and_hms(2026, 1, 2, 12, 0, 0).unwrap() } fn text(c: &ICalendar) -> String { c.to_string().replace("\r\n ", "") } fn to<'a>(msgs: &'a [Message], who: &str) -> Option<&'a Message> { msgs.iter().find(|m| m.to.eq_ignore_ascii_case(who)) } #[test] fn roles() { let m = meeting(&format!("ATTENDEE:{BOB}\n")); assert_eq!(itip::role(&m, &is(ALICE)), Ok(Role::Organizer)); assert_eq!(itip::role(&m, &is(BOB)), Ok(Role::Attendee)); assert_eq!(itip::role(&m, &is(CAROL)), Ok(Role::None)); let split = cal(&format!( "BEGIN:VEVENT\nUID:x\nDTSTART:20260105T100000Z\nORGANIZER:{ALICE}\nEND:VEVENT\n\ BEGIN:VEVENT\nUID:x\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\nORGANIZER:{BOB}\nEND:VEVENT\n" )); assert_eq!(itip::role(&split, &is(ALICE)), Err(Refused::SameOrganizer)); } #[test] fn invite() { // The client claims bob accepted; only bob can say that. let new = cal(&format!( "BEGIN:VEVENT UID:m1 DTSTAMP:20260101T000000Z DTSTART:20260105T100000Z SUMMARY:Sync ORGANIZER:{ALICE} ATTENDEE;PARTSTAT=ACCEPTED:{ALICE} ATTENDEE;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=1.2:{BOB} ATTENDEE;SCHEDULE-AGENT=CLIENT;PARTSTAT=ACCEPTED:{CAROL} BEGIN:VALARM ACTION:DISPLAY TRIGGER:-PT5M END:VALARM END:VEVENT " )); let (store, msgs) = itip::organize(None, Some(new), &is(ALICE), now()); let store = text(&store.unwrap()); assert!( store.contains(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}")), "{store}" ); assert!( store.contains("SCHEDULE-AGENT=CLIENT;PARTSTAT=ACCEPTED"), "{store}" ); // Nothing to alice herself or to the attendee the client schedules. assert_eq!(msgs.len(), 1); let m = to(&msgs, BOB).unwrap(); assert_eq!(m.method, Method::Request); let body = text(&m.cal); assert!(body.contains("METHOD:REQUEST")); assert!(body.contains("DTSTAMP:20260102T120000Z")); assert!(!body.contains("VALARM")); assert!(!body.contains("SCHEDULE-"), "{body}"); } #[test] fn reschedule_resets_participation() { let old = meeting(&format!( "ATTENDEE;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:{BOB}\n" )); // A new title keeps bob's answer, even from a client with a stale copy. let retitled = text(&meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n"))) .replace("SUMMARY:Sync", "SUMMARY:Weekly sync"); let (store, msgs) = itip::organize( Some(&old), Some(ICalendar::parse(&retitled).unwrap()), &is(ALICE), now(), ); let store = text(&store.unwrap()); assert!( store.contains(&format!("PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:{BOB}")), "{store}" ); assert!(!to(&msgs, BOB).unwrap().quiet); let moved = text(&old).replace("DTSTART:20260105T100000Z", "DTSTART:20260105T090000Z"); let (store, msgs) = itip::organize( Some(&old), Some(ICalendar::parse(&moved).unwrap()), &is(ALICE), now(), ); let store = text(&store.unwrap()); assert!(store.contains("PARTSTAT=NEEDS-ACTION"), "{store}"); assert!(store.contains("SEQUENCE:1"), "{store}"); assert!(text(&to(&msgs, BOB).unwrap().cal).contains("DTSTART:20260105T090000Z")); // Shortening the series is no reschedule. let shorter = text(&old).replace("COUNT=4", "COUNT=2"); let (store, _) = itip::organize( Some(&old), Some(ICalendar::parse(&shorter).unwrap()), &is(ALICE), now(), ); assert!( text(&store.unwrap()).contains(&format!("PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:{BOB}")) ); } #[test] fn unchanged_or_forced() { let old = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n")); let resaved = text(&old) .replace("DTSTAMP:20260101T000000Z", "DTSTAMP:20260101T080000Z") .replace("SUMMARY:Sync", "SUMMARY:Sync\r\nX-MOZ-GENERATION:3"); let (_, msgs) = itip::organize( Some(&old), Some(ICalendar::parse(&resaved).unwrap()), &is(ALICE), now(), ); assert!(msgs.is_empty(), "{msgs:?}"); let forced = text(&old).replace( "ATTENDEE;PARTSTAT=ACCEPTED:mailto:bob", "ATTENDEE;SCHEDULE-FORCE-SEND=REQUEST;PARTSTAT=ACCEPTED:mailto:bob", ); let (store, msgs) = itip::organize( Some(&old), Some(ICalendar::parse(&forced).unwrap()), &is(ALICE), now(), ); assert!(!text(&store.unwrap()).contains("FORCE-SEND")); assert_eq!(to(&msgs, BOB).unwrap().method, Method::Request); } #[test] fn removed_attendee_and_delete_cancel() { let old = meeting(&format!("ATTENDEE:{BOB}\nATTENDEE:{CAROL}\n")); let (_, msgs) = itip::organize( Some(&old), Some(meeting(&format!("ATTENDEE:{BOB}\n"))), &is(ALICE), now(), ); let cancel = to(&msgs, CAROL).unwrap(); assert_eq!(cancel.method, Method::Cancel); assert!(text(&cancel.cal).contains("STATUS:CANCELLED")); // The attendee list changed for bob too. assert_eq!(to(&msgs, BOB).unwrap().method, Method::Request); let (store, msgs) = itip::organize(Some(&old), None, &is(ALICE), now()); assert!(store.is_none()); assert!(msgs.iter().all(|m| m.method == Method::Cancel)); assert_eq!(msgs.len(), 2); } #[test] fn instances_only_reach_their_attendees() { let old = cal(&format!( "BEGIN:VEVENT UID:m1 DTSTART:20260105T100000Z RRULE:FREQ=WEEKLY;COUNT=4 ORGANIZER:{ALICE} ATTENDEE:{BOB} ATTENDEE:{CAROL} END:VEVENT BEGIN:VEVENT UID:m1 RECURRENCE-ID:20260112T100000Z DTSTART:20260112T140000Z ORGANIZER:{ALICE} ATTENDEE:{BOB} END:VEVENT " )); let (_, msgs) = itip::organize(None, Some(old), &is(ALICE), now()); let carol = text(&to(&msgs, CAROL).unwrap().cal); assert!(carol.contains("EXDATE:20260112T100000Z"), "{carol}"); assert!(!carol.contains("RECURRENCE-ID"), "{carol}"); let bob = text(&to(&msgs, BOB).unwrap().cal); assert!( bob.contains("RECURRENCE-ID:20260112T100000Z") && !bob.contains("EXDATE"), "{bob}" ); } #[test] fn accept_and_apply_reply() { let org = meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n")); let (_, msgs) = itip::organize(None, Some(org.clone()), &is(ALICE), now()); let copy = itip::receive(None, to(&msgs, BOB).unwrap()).unwrap(); assert!(!text(©).contains("METHOD")); let accepted = text(©).replace( &format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}"), &format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}"), ); let (_, reply) = itip::attend(©, ICalendar::parse(&accepted).unwrap(), &is(BOB), now()).unwrap(); let reply = reply.unwrap(); assert_eq!((reply.method, reply.to.as_str()), (Method::Reply, ALICE)); let body = text(&reply.cal); assert!(body.contains("METHOD:REPLY"), "{body}"); assert!( !body.contains(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{ALICE}")), "{body}" ); let mut org = org; assert!(itip::apply_reply(&mut org, &reply.cal, &is(BOB)).changed); let org = text(&org); assert!( org.contains(&format!("PARTSTAT=ACCEPTED;SCHEDULE-STATUS=2.0:{BOB}")), "{org}" ); } #[test] fn declining_one_instance() { let org = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n")); let copy = org.clone(); // bob overrides the second week to decline it. let with_override = format!( "{}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", text(©).trim_end_matches("END:VCALENDAR\r\n") ); let (_, reply) = itip::attend( ©, ICalendar::parse(&with_override).unwrap(), &is(BOB), now(), ) .unwrap(); let reply = reply.unwrap(); assert!(text(&reply.cal).contains("RECURRENCE-ID:20260112T100000Z")); let mut org = org; assert!(itip::apply_reply(&mut org, &reply.cal, &is(BOB)).changed); let org_text = text(&org); assert!( org_text.contains("RECURRENCE-ID:20260112T100000Z"), "{org_text}" ); assert!(org_text.contains("DURATION:PT1H"), "{org_text}"); assert!( org_text.contains(&format!("PARTSTAT=DECLINED;SCHEDULE-STATUS=2.0:{BOB}")), "{org_text}" ); // An EXDATE declines too. let excluded = text(©).replace( "RRULE:FREQ=WEEKLY;COUNT=4", "RRULE:FREQ=WEEKLY;COUNT=4\r\nEXDATE:20260119T100000Z", ); let (_, reply) = itip::attend(©, ICalendar::parse(&excluded).unwrap(), &is(BOB), now()).unwrap(); let body = text(&reply.unwrap().cal); assert!( body.contains("RECURRENCE-ID:20260119T100000Z") && body.contains("PARTSTAT=DECLINED"), "{body}" ); } #[test] fn reply_for_a_missing_instance_adds_nothing() { let reply = |rid: &str| { cal(&format!( "METHOD:REPLY BEGIN:VEVENT UID:m1 DTSTAMP:20260101T000000Z RECURRENCE-ID:{rid} DTSTART:{rid} ORGANIZER:{ALICE} ATTENDEE;PARTSTAT=DECLINED:{BOB} END:VEVENT " )) }; let mut org = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n")); // A Tuesday, and a Monday after COUNT ends. for rid in ["20260113T100000Z", "20260202T100000Z"] { assert!( !itip::apply_reply(&mut org, &reply(rid), &is(BOB)).changed, "{rid}" ); } assert!(itip::apply_reply(&mut org, &reply("20260112T100000Z"), &is(BOB)).changed); } #[test] fn attendees_may_not_move_the_meeting() { let copy = meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n")); let moved = text(©).replace("DTSTART:20260105T100000Z", "DTSTART:20260105T120000Z"); assert_eq!( itip::attend(©, ICalendar::parse(&moved).unwrap(), &is(BOB), now()).err(), Some(Refused::AttendeeChange) ); // Alarms and transparency are the attendee's. let own = text(©).replace( "SUMMARY:Sync", "SUMMARY:Sync\r\nTRANSP:TRANSPARENT\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT5M\r\nEND:VALARM", ); let (_, reply) = itip::attend(©, ICalendar::parse(&own).unwrap(), &is(BOB), now()).unwrap(); assert!(reply.is_none()); } #[test] fn attendee_delete_declines() { let copy = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n")); let reply = itip::decline(©, &is(BOB), now()).unwrap(); assert!(text(&reply.cal).contains(&format!("PARTSTAT=DECLINED:{BOB}"))); let client = text(©).replace( &format!("ORGANIZER:{ALICE}"), &format!("ORGANIZER;SCHEDULE-AGENT=CLIENT:{ALICE}"), ); assert!(itip::decline(&ICalendar::parse(&client).unwrap(), &is(BOB), now()).is_none()); } #[test] fn updates_keep_what_the_attendee_owns() { let org = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n")); let (_, msgs) = itip::organize(None, Some(org.clone()), &is(ALICE), now()); let copy = itip::receive(None, to(&msgs, BOB).unwrap()).unwrap(); let copy = ICalendar::parse(text(©).replace( "SUMMARY:Sync", "SUMMARY:Sync\r\nTRANSP:TRANSPARENT\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT5M\r\nEND:VALARM", )) .unwrap(); let retitled = ICalendar::parse(text(&org).replace("SUMMARY:Sync", "SUMMARY:Planning")).unwrap(); let (_, msgs) = itip::organize(Some(&org), Some(retitled), &is(ALICE), now()); let updated = text(&itip::receive(Some(©), to(&msgs, BOB).unwrap()).unwrap()); assert!( updated.contains("SUMMARY:Planning") && updated.contains("TRANSP:TRANSPARENT") && updated.contains("TRIGGER:-PT5M"), "{updated}" ); let (_, msgs) = itip::organize(Some(&org), None, &is(ALICE), now()); let cancelled = text(&itip::receive(Some(©), to(&msgs, BOB).unwrap()).unwrap()); assert!(cancelled.contains("STATUS:CANCELLED"), "{cancelled}"); } #[test] fn sender_is_named_for_the_owner() { let mut m = meeting(&format!("ATTENDEE:{BOB}\n")); itip::stamp_sender(&mut m, &is(ALICE), Some(BOB)); let got = text(&m); assert!( got.contains(&format!("ORGANIZER;SENT-BY=\"{BOB}\":{ALICE}")), "{got}" ); assert!(!got.contains(&format!("SENT-BY=\"{BOB}\":{BOB}")), "{got}"); // An edit by the owner drops it again. itip::stamp_sender(&mut m, &is(ALICE), None); assert!(!text(&m).contains("SENT-BY"), "{}", text(&m)); // Who sent it is no reason to message the attendees. let old = meeting(&format!("ATTENDEE:{BOB}\n")); let mut new = old.clone(); itip::stamp_sender(&mut new, &is(ALICE), Some(BOB)); let msgs = itip::messages(Some(&old), Some(&new), &is(ALICE), &[], now()); assert!(msgs.is_empty(), "{msgs:?}"); } #[test] fn rooms_answer_from_their_bookings() { use pimdav::freebusy::{Busy, Period}; use pimdav::zone::Zone; const ROOM: &str = "mailto:board@rooms.dovenest.invalid"; let at = |d: u32, h: u32| Utc.with_ymd_and_hms(2026, 1, d, h, 0, 0).unwrap(); // The weekly meeting runs on 5, 12, 19 and 26 January, 10:00 to 11:00. let copy = meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{ROOM}\n")); let window = at(1, 0)..at(31, 0); let free = itip::auto_answer(©, &is(ROOM), &[], &window, &Zone::Utc); assert!( text(&free).contains(&format!("PARTSTAT=ACCEPTED:{ROOM}")), "{}", text(&free) ); let taken = [Period { kind: Busy::Busy, start: at(12, 10), end: at(12, 11), }]; let answer = itip::auto_answer(©, &is(ROOM), &taken, &window, &Zone::Utc); let got = text(&answer); assert!(got.contains("RECURRENCE-ID:20260112T100000Z"), "{got}"); assert_eq!( got.matches(&format!("PARTSTAT=DECLINED:{ROOM}")).count(), 1, "{got}" ); assert_eq!( got.matches(&format!("PARTSTAT=ACCEPTED:{ROOM}")).count(), 1, "{got}" ); // The answer travels as a REPLY for the series and the one instance. let (_, reply) = itip::attend(©, answer, &is(ROOM), now()).unwrap(); let reply = reply.unwrap(); assert_eq!(reply.method, Method::Reply); assert!(text(&reply.cal).contains("RECURRENCE-ID:20260112T100000Z")); // A single event on a taken slot is declined as a whole. let single = cal(&format!( "BEGIN:VEVENT\nUID:s1\nDTSTART:20260112T103000Z\nDURATION:PT1H\nORGANIZER:{ALICE}\n\ ATTENDEE:{ROOM}\nEND:VEVENT\n" )); let got = text(&itip::auto_answer( &single, &is(ROOM), &taken, &window, &Zone::Utc, )); assert!(got.contains(&format!("PARTSTAT=DECLINED:{ROOM}")), "{got}"); } #[test] fn forget_a_deleted_principal() { let gone = is(BOB); let tomb = "mailto:bob-7@deleted.dovenest.invalid"; // In alice's meeting bob becomes a tombstone that reaches no one. let org = format!( "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" ); let out = itip::forget(&org, &gone, tomb).unwrap(); // The longer line is folded at 75 octets. assert_eq!( out.replace("\r\n ", ""), org.replace( &format!("ATTENDEE;CN=Bob;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=1.2:{BOB}"), &format!("ATTENDEE;CN=Bob;PARTSTAT=ACCEPTED;SCHEDULE-STATUS=3.7:{tomb}") ) ); // A copy of bob's meeting is cancelled, the override too; LF stays LF. let copy = format!( "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", BOB.to_uppercase() ); let out = itip::forget(©, &gone, tomb).unwrap(); assert_eq!( out, format!( "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" ) ); assert_eq!( itip::forget(&org, &is(CAROL.trim_end_matches('m')), tomb), None ); } const ROOM: &str = "mailto:board@rooms.dovenest.invalid"; fn at(d: u32, h: u32) -> chrono::DateTime { Utc.with_ymd_and_hms(2026, 1, d, h, 0, 0).unwrap() } fn booked(start: chrono::DateTime, end: chrono::DateTime) -> pimdav::freebusy::Period { pimdav::freebusy::Period { kind: pimdav::freebusy::Busy::Busy, start, end, } } #[test] fn a_room_declines_a_series_with_many_conflicts_as_a_whole() { use pimdav::zone::Zone; let copy = cal(&format!( "BEGIN:VEVENT\nUID:h1\nDTSTART:20260105T000000Z\nDURATION:PT30M\nRRULE:FREQ=HOURLY;COUNT=200\n\ ORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n" )); let taken = [booked(at(1, 0), at(31, 0))]; let got = text(&itip::auto_answer( ©, &is(ROOM), &taken, &(at(1, 0)..at(31, 0)), &Zone::Utc, )); assert!(!got.contains("RECURRENCE-ID"), "{got}"); assert!(got.contains(&format!("PARTSTAT=DECLINED:{ROOM}")), "{got}"); } #[test] fn a_room_declines_one_instance_a_this_and_future_override_moves() { use pimdav::zone::Zone; // From the 12th on, the meeting runs two hours later. let copy = cal(&format!( "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\nRRULE:FREQ=WEEKLY;COUNT=4\n\ ORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n\ BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\ DTSTART:20260112T120000Z\nDTEND:20260112T130000Z\nORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n" )); let taken = [booked(at(19, 12), at(19, 13))]; let answer = itip::auto_answer(©, &is(ROOM), &taken, &(at(1, 0)..at(31, 0)), &Zone::Utc); let got = text(&answer); assert!( got.contains("RECURRENCE-ID:20260119T100000Z\r\nDTSTART:20260119T120000Z") || got.contains("DTSTART:20260119T120000Z\r\nRECURRENCE-ID:20260119T100000Z"), "{got}" ); assert_eq!( got.matches(&format!("PARTSTAT=DECLINED:{ROOM}")).count(), 1, "{got}" ); assert_eq!( got.matches(&format!("PARTSTAT=ACCEPTED:{ROOM}")).count(), 2, "{got}" ); // The moved override passes as the room's own answer, and the // organizer's copy takes it. let (_, reply) = itip::attend(©, answer, &is(ROOM), now()).unwrap(); let mut org = copy.clone(); assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(ROOM)).changed); let org = text(&org); assert!(org.contains("DTSTART:20260119T120000Z"), "{org}"); } #[test] fn a_client_scheduled_attendee_gets_no_cancel() { let old = meeting(&format!("ATTENDEE:{BOB}\n")); let new = meeting(&format!("ATTENDEE;SCHEDULE-AGENT=CLIENT:{BOB}\n")); let (_, msgs) = itip::organize(Some(&old), Some(new), &is(ALICE), now()); assert!(to(&msgs, BOB).is_none(), "{msgs:?}"); } #[test] fn the_highest_sequence_does_not_overflow() { let old = meeting(&format!( "SEQUENCE:9223372036854775807\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\n" )); let moved = text(&old) .replace("DTSTART:20260105T100000Z", "DTSTART:20260105T090000Z") .replace(&format!("ATTENDEE:{CAROL}\r\n"), ""); let (store, msgs) = itip::organize( Some(&old), Some(ICalendar::parse(&moved).unwrap()), &is(ALICE), now(), ); assert!(text(&store.unwrap()).contains("SEQUENCE:9223372036854775807")); assert!(text(&to(&msgs, CAROL).unwrap().cal).contains("SEQUENCE:9223372036854775807")); } #[test] fn answering_an_instance_the_series_lacks_changes_nothing() { use pimdav::calcard::icalendar::ICalendarParticipationStatus::Declined; use pimdav::zone::Zone; let copy = meeting(&format!("ATTENDEE:{BOB}\n")); // A Tuesday is no instance. assert!(itip::respond(©, &is(BOB), Declined, Some(at(13, 10)), &Zone::Utc).is_none()); let got = text(&itip::respond(©, &is(BOB), Declined, Some(at(12, 10)), &Zone::Utc).unwrap()); assert!(got.contains("RECURRENCE-ID:20260112T100000Z"), "{got}"); assert_eq!( got.matches(&format!("PARTSTAT=DECLINED:{BOB}")).count(), 1, "{got}" ); } #[test] fn a_date_until_on_the_same_day_extends_the_series() { let old = text(&meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"))) .replace("COUNT=4", "UNTIL=20260119T000000Z"); let old = ICalendar::parse(&old).unwrap(); // The DATE includes the instance at 10:00 on the 19th. let longer = text(&old).replace("UNTIL=20260119T000000Z", "UNTIL=20260119"); let (store, _) = itip::organize( Some(&old), Some(ICalendar::parse(&longer).unwrap()), &is(ALICE), now(), ); let store = text(&store.unwrap()); assert!( store.contains(&format!("PARTSTAT=NEEDS-ACTION:{BOB}")), "{store}" ); } #[test] fn a_room_declines_only_the_first_instance_of_a_this_and_future_override() { use pimdav::calcard::icalendar::ICalendarParticipationStatus::Declined; use pimdav::zone::Zone; // From the 12th on, the meeting runs two hours later. let copy = cal(&format!( "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\nRRULE:FREQ=WEEKLY;COUNT=4\n\ ORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n\ BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\ DTSTART:20260112T120000Z\nDTEND:20260112T130000Z\nORGANIZER:{ALICE}\nATTENDEE:{ROOM}\nEND:VEVENT\n" )); let taken = [booked(at(12, 12), at(12, 13))]; let answer = itip::auto_answer(©, &is(ROOM), &taken, &(at(1, 0)..at(31, 0)), &Zone::Utc); let got = text(&answer); assert!(got.contains("RECURRENCE-ID:20260112T100000Z"), "{got}"); assert!( got.contains("RECURRENCE-ID;RANGE=THISANDFUTURE:20260119T100000Z"), "{got}" ); assert!(got.contains("DTSTART:20260119T120000Z"), "{got}"); let declined = format!("PARTSTAT=DECLINED:{ROOM}"); let accepted = format!("PARTSTAT=ACCEPTED:{ROOM}"); assert_eq!(got.matches(&declined).count(), 1, "{got}"); assert_eq!(got.matches(&accepted).count(), 2, "{got}"); // The organizer's copy splits its range the same way. let (_, reply) = itip::attend(©, answer, &is(ROOM), now()).unwrap(); let mut org = copy.clone(); assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(ROOM)).changed); let org = text(&org); assert!( org.contains("RECURRENCE-ID;RANGE=THISANDFUTURE:20260119T100000Z"), "{org}" ); assert_eq!(org.matches("PARTSTAT=DECLINED").count(), 1, "{org}"); assert_eq!(org.matches("PARTSTAT=ACCEPTED").count(), 2, "{org}"); // So does an answer for that one instance from the web. let got = text(&itip::respond(©, &is(ROOM), Declined, Some(at(12, 10)), &Zone::Utc).unwrap()); assert!( got.contains("RECURRENCE-ID;RANGE=THISANDFUTURE:20260119T100000Z"), "{got}" ); assert_eq!(got.matches(&declined).count(), 1, "{got}"); } #[test] fn a_new_override_takes_the_others_state_from_its_range() { let old = cal(&format!( "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\nRRULE:FREQ=WEEKLY;COUNT=4\n\ ORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE;PARTSTAT=NEEDS-ACTION:{CAROL}\nEND:VEVENT\n\ BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\ DTSTART:20260112T120000Z\nDTEND:20260112T130000Z\nORGANIZER:{ALICE}\n\ ATTENDEE:{BOB}\nATTENDEE;PARTSTAT=ACCEPTED:{CAROL}\nEND:VEVENT\n" )); let new = cal(&format!( "{}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260119T100000Z\nDTSTART:20260119T120000Z\n\ DTEND:20260119T130000Z\nORGANIZER:{ALICE}\nATTENDEE;PARTSTAT=DECLINED:{BOB}\n\ ATTENDEE;PARTSTAT=NEEDS-ACTION:{CAROL}\nEND:VEVENT\n", text(&old) .trim_start_matches("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n") .trim_end_matches("END:VCALENDAR\r\n") .replace("\r\n", "\n") )); let (store, reply) = itip::attend(&old, new, &is(BOB), now()).unwrap(); let store = text(&store); assert_eq!( store.matches(&format!("PARTSTAT=ACCEPTED:{CAROL}")).count(), 2, "{store}" ); assert!(reply.is_some()); } #[test] fn an_attendee_dropped_from_the_master_gets_a_cancel_despite_a_client_override() { let over = |agent: &str| { format!( "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T120000Z\n\ DTEND:20260112T130000Z\nORGANIZER:{ALICE}\nATTENDEE{agent}:{BOB}\nEND:VEVENT\n" ) }; let series = |extra: &str| { format!( "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\ RRULE:FREQ=WEEKLY;COUNT=4\nORGANIZER:{ALICE}\nATTENDEE:{CAROL}\n{extra}END:VEVENT\n" ) }; let old = cal(&format!( "{}{}", series(&format!("ATTENDEE:{BOB}\n")), over("") )); let new = cal(&format!("{}{}", series(""), over(";SCHEDULE-AGENT=CLIENT"))); let (_, msgs) = itip::organize(Some(&old), Some(new), &is(ALICE), now()); let cancel = to(&msgs, BOB).expect("a CANCEL for the series"); assert_eq!(cancel.method, Method::Cancel); assert!( !text(&cancel.cal).contains("RECURRENCE-ID"), "{}", text(&cancel.cal) ); } /// alice's weekly meeting, two hours later from the 12th on, with bob in it. fn shifted_series() -> String { format!( "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\nRRULE:FREQ=WEEKLY;COUNT=4\n\ ORGANIZER:{ALICE}\nATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n\ BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\ DTSTART:20260112T120000Z\nDTEND:20260112T130000Z\nORGANIZER:{ALICE}\n\ ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n" ) } #[test] fn a_new_override_at_the_shifted_time_is_no_move() { let old = cal(&shifted_series()); let new = cal(&format!( "{}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260119T100000Z\nDTSTART:20260119T120000Z\n\ DTEND:20260119T130000Z\nSUMMARY:renamed\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n", shifted_series() )); let (store, _) = itip::organize(Some(&old), Some(new), &is(ALICE), now()); let store = text(&store.unwrap()); assert_eq!( store.matches(&format!("PARTSTAT=ACCEPTED:{BOB}")).count(), 3, "{store}" ); assert!(!store.contains("SEQUENCE:1"), "{store}"); } #[test] fn a_copy_without_its_master_keeps_the_shift() { let range = shifted_series() .split_inclusive("END:VEVENT\n") .nth(1) .unwrap() .to_string(); let old = cal(&range); let new = cal(&format!( "{range}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260119T100000Z\nDTSTART:20260119T120000Z\n\ DTEND:20260119T130000Z\nSUMMARY:renamed\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n" )); let (store, _) = itip::organize(Some(&old), Some(new), &is(ALICE), now()); let store = text(&store.unwrap()); assert_eq!( store.matches(&format!("PARTSTAT=ACCEPTED:{BOB}")).count(), 2, "{store}" ); } #[test] fn a_new_override_that_moves_bumps_the_sequence() { let old = cal(&shifted_series()); let new = cal(&format!( "{}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260119T100000Z\nDTSTART:20260119T150000Z\n\ DTEND:20260119T160000Z\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n", shifted_series() )); let (store, _) = itip::organize(Some(&old), Some(new), &is(ALICE), now()); let store = text(&store.unwrap()); assert!(store.contains("SEQUENCE:1"), "{store}"); assert!( store.contains(&format!("PARTSTAT=NEEDS-ACTION:{BOB}")), "{store}" ); } #[test] fn a_changed_exrule_is_a_reschedule() { let old = meeting(&format!( "ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEXRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=1\n" )); let removed: String = text(&old) .split_inclusive("\r\n") .filter(|l| !l.starts_with("EXRULE")) .collect(); assert_ne!(removed, text(&old)); let (store, _) = itip::organize( Some(&old), Some(ICalendar::parse(&removed).unwrap()), &is(ALICE), now(), ); let store = text(&store.unwrap()); assert!( store.contains(&format!("PARTSTAT=NEEDS-ACTION:{BOB}")), "{store}" ); } #[test] fn a_rule_in_another_form_is_the_same_rule() { let old = text(&meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n"))).replace( "RRULE:FREQ=WEEKLY;COUNT=4", "RRULE:FREQ=WEEKLY;BYDAY=MO,TU;COUNT=4", ); let old = ICalendar::parse(&old).unwrap(); // bob's client writes the rule back in its own form. let new = text(&old) .replace( "RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO,TU", "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU,MO;WKST=MO;COUNT=4", ) .replace( &format!("PARTSTAT=NEEDS-ACTION:{BOB}"), &format!("PARTSTAT=ACCEPTED:{BOB}"), ); assert_ne!(new, text(&old)); let (_, reply) = itip::attend(&old, ICalendar::parse(&new).unwrap(), &is(BOB), now()).unwrap(); assert!(reply.is_some()); // The same rewrite by the organizer keeps bob's answer. let accepted = ICalendar::parse(text(&old).replace( &format!("PARTSTAT=NEEDS-ACTION:{BOB}"), &format!("PARTSTAT=ACCEPTED:{BOB}"), )) .unwrap(); let rewritten = text(&accepted).replace( "RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO,TU", "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU,MO;WKST=MO;COUNT=4", ); let (store, _) = itip::organize( Some(&accepted), Some(ICalendar::parse(&rewritten).unwrap()), &is(ALICE), now(), ); assert!( text(&store.unwrap()).contains(&format!("PARTSTAT=ACCEPTED:{BOB}")), "rule rewrite reset bob" ); } #[test] fn a_cancelled_range_cancels_the_later_instances() { use pimdav::expand::expand; use pimdav::zone::Zone; let copy = meeting(&format!("ATTENDEE:{BOB}\n")); let msg = Message { to: BOB.to_string(), method: Method::Cancel, quiet: false, cal: std::sync::Arc::new(cal(&format!( "METHOD:CANCEL\nBEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260112T100000Z\n\ DTSTART:20260112T100000Z\nSEQUENCE:1\nSTATUS:CANCELLED\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n" ))), }; use pimdav::calcard::icalendar::{ICalendarProperty, ICalendarStatus, ICalendarValue}; let got = itip::receive(Some(©), &msg).unwrap(); let mut out = expand(&got, at(1, 0)..at(31, 0), Zone::Utc).instances; out.sort_by_key(|i| i.start); let cancelled: Vec = out .iter() .map(|i| { matches!( got.components[i.component] .property(&ICalendarProperty::Status) .and_then(|e| e.values.first()), Some(ICalendarValue::Status(ICalendarStatus::Cancelled)) ) }) .collect(); assert_eq!(cancelled, [false, true, true, true], "{}", text(&got)); } #[test] fn a_range_an_attendee_is_left_out_of_ends_their_series() { use pimdav::expand::expand; use pimdav::zone::Zone; let org = cal(&format!( "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\ RRULE:FREQ=WEEKLY;COUNT=8\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\nEND:VEVENT\n\ BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260119T100000Z\n\ DTSTART:20260119T100000Z\nDTEND:20260119T110000Z\nORGANIZER:{ALICE}\n\ ATTENDEE:{CAROL}\nEND:VEVENT\n" )); let (_, msgs) = itip::organize(None, Some(org), &is(ALICE), now()); let bob = &to(&msgs, BOB).unwrap().cal; let starts: Vec<_> = expand( bob, at(1, 0)..at(1, 0) + chrono::TimeDelta::days(90), Zone::Utc, ) .instances .iter() .map(|i| i.start) .collect(); assert_eq!(starts, [at(5, 10), at(12, 10)], "{}", text(bob)); let carol = &to(&msgs, CAROL).unwrap().cal; let window = at(1, 0)..at(1, 0) + chrono::TimeDelta::days(90); assert_eq!(expand(carol, window, Zone::Utc).instances.len(), 8); } #[test] fn a_reply_status_keeps_only_its_code() { let mut org = meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n")); let reply = cal(&format!( "METHOD:REPLY\nBEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nORGANIZER:{ALICE}\n\ ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nREQUEST-STATUS:2.0\",x:EVIL;Success\nEND:VEVENT\n" )); assert!(itip::apply_reply(&mut org, &reply, &is(BOB)).changed); let org = text(&org); assert!( org.contains(&format!("SCHEDULE-STATUS=2.0:{BOB}")) && !org.contains("EVIL"), "{org}" ); } #[test] fn attendees_may_not_change_an_instance_through_its_override() { let base = "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\ RRULE:FREQ=WEEKLY;COUNT=4\n"; let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n"); let over = |start: &str, end: &str| { format!( "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:2026011{start}00Z\n\ DTEND:2026011{end}00Z\n{people}END:VEVENT\n" ) }; let copy = cal(&format!("{base}{people}END:VEVENT\n")); // A new override at the right start but longer. let longer = cal(&format!( "{base}{people}END:VEVENT\n{}", over("2T1000", "2T1200") )); assert_eq!( itip::attend(©, longer, &is(BOB), now()).err(), Some(Refused::AttendeeChange) ); // Dropping the organizer's move of an instance. let moved = cal(&format!( "{base}{people}END:VEVENT\n{}", over("2T1400", "2T1500") )); assert_eq!( itip::attend(&moved, copy.clone(), &is(BOB), now()).err(), Some(Refused::AttendeeChange) ); // Dropping an override that only held a status is fine. let own = cal(&format!( "{base}{people}END:VEVENT\n{}", over("2T1000", "2T1100") )); assert!(itip::attend(&own, copy, &is(BOB), now()).is_ok()); } #[test] fn attendees_may_delete_an_instance_or_drop_one_of_their_instances() { let base = "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\ RRULE:FREQ=WEEKLY;COUNT=4\n"; let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n"); let moved = format!( "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T140000Z\n\ DTEND:20260112T150000Z\n{people}END:VEVENT\n" ); // Deleting the instance the organizer moved: EXDATE and no override. let copy = cal(&format!("{base}{people}END:VEVENT\n{moved}")); let deleted = cal(&format!( "{base}EXDATE:20260112T100000Z\n{people}END:VEVENT\n" )); assert!(itip::attend(©, deleted, &is(BOB), now()).is_ok()); // A copy of single instances: dropping one of them. let other = moved.replace("20260112T1", "20260119T1").replace( "RECURRENCE-ID:20260119T140000Z", "RECURRENCE-ID:20260119T100000Z", ); let single = cal(&format!("{moved}{other}")); assert!(itip::attend(&single, cal(&other), &is(BOB), now()).is_ok()); } #[test] fn an_override_without_end_has_the_default_length() { let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n"); for (master, rid, start, end) in [ ( "DTSTART;VALUE=DATE:20260105", "RECURRENCE-ID;VALUE=DATE:20260112", "DTSTART;VALUE=DATE:20260112", "DTEND;VALUE=DATE:20260113", ), ( "DTSTART:20260105T100000Z", "RECURRENCE-ID:20260112T100000Z", "DTSTART:20260112T100000Z", "DTEND:20260112T100000Z", ), ] { let series = format!( "BEGIN:VEVENT\nUID:m1\n{master}\nRRULE:FREQ=WEEKLY;COUNT=4\n{people}END:VEVENT\n" ); let copy = cal(&series); let own = cal(&format!( "{series}BEGIN:VEVENT\nUID:m1\n{rid}\n{start}\n{end}\n{people}END:VEVENT\n" )); assert!( itip::attend(©, own, &is(BOB), now()).is_ok(), "{master}" ); } } #[test] fn attendees_may_not_drop_a_cancelled_instance() { let series = format!( "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\ RRULE:FREQ=WEEKLY;COUNT=4\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n\ END:VEVENT\n" ); let cancelled = cal(&format!( "{series}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\n\ DTSTART:20260112T100000Z\nDTEND:20260112T110000Z\nSTATUS:CANCELLED\n\ ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n" )); assert_eq!( itip::attend(&cancelled, cal(&series), &is(BOB), now()).err(), Some(Refused::AttendeeChange) ); } #[test] fn a_range_from_the_first_instance_cancels_the_whole_series() { let org = cal(&format!( "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\ RRULE:FREQ=WEEKLY;COUNT=8\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\nEND:VEVENT\n\ BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260105T100000Z\n\ DTSTART:20260105T120000Z\nDTEND:20260105T130000Z\nORGANIZER:{ALICE}\n\ ATTENDEE:{CAROL}\nEND:VEVENT\n" )); let old = cal(&format!( "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\ RRULE:FREQ=WEEKLY;COUNT=8\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\nEND:VEVENT\n" )); let (_, msgs) = itip::organize(Some(&old), Some(org), &is(ALICE), now()); let bob = to(&msgs, BOB).unwrap(); assert_eq!(bob.method, Method::Cancel, "{}", text(&bob.cal)); } #[test] fn an_attendee_may_write_the_same_end_in_another_form() { let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n"); for (stored, written) in [ ( "DTSTART:20260105T100000Z\nDURATION:PT1H", "DTSTART:20260105T100000Z\nDTEND:20260105T110000Z", ), ( "DTSTART;VALUE=DATE:20260105", "DTSTART;VALUE=DATE:20260105\nDTEND;VALUE=DATE:20260106", ), ( "DTSTART:20260105T100000Z", "DTSTART:20260105T100000Z\nDTEND:20260105T100000Z", ), // A nominal day across the October change lasts 25 hours. ( "DTSTART;TZID=Europe/Berlin:20261024T120000\nDURATION:P1D", "DTSTART;TZID=Europe/Berlin:20261024T120000\nDTEND;TZID=Europe/Berlin:20261025T120000", ), ] { let event = |times: &str| { cal(&format!( "BEGIN:VEVENT\nUID:m1\n{times}\nRRULE:FREQ=WEEKLY;COUNT=4\n{people}END:VEVENT\n" )) }; let accepted = text(&event(written)).replace( &format!("ATTENDEE:{BOB}"), &format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}"), ); let (_, reply) = itip::attend( &event(stored), ICalendar::parse(&accepted).unwrap(), &is(BOB), now(), ) .unwrap_or_else(|e| panic!("{stored}: {e:?}")); assert!(reply.is_some(), "{stored}"); } } #[test] fn a_new_override_that_changes_the_length_is_a_reschedule() { let old = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n")); let new = cal(&format!( "{}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\n\ DTEND:20260112T130000Z\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nEND:VEVENT\n", text(&old) .trim_start_matches("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//t//EN\r\n") .trim_end_matches("END:VCALENDAR\r\n") .replace("\r\n", "\n") )); let (store, _) = itip::organize(Some(&old), Some(new), &is(ALICE), now()); let store = text(&store.unwrap()); let longer = store.split("RECURRENCE-ID").nth(1).unwrap(); assert!(longer.contains("SEQUENCE:1"), "{store}"); assert!( longer.contains(&format!("PARTSTAT=NEEDS-ACTION:{BOB}")), "{store}" ); } #[test] fn an_attendee_may_complete_one_instance() { let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n"); let series = format!( "BEGIN:VTODO\nUID:t1\nDTSTART:20260105T100000Z\nDUE:20260105T110000Z\n\ RRULE:FREQ=WEEKLY;COUNT=4\n{people}END:VTODO\n" ); let done = cal(&format!( "{series}BEGIN:VTODO\nUID:t1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\n\ DUE:20260112T110000Z\nSTATUS:COMPLETED\n{people}END:VTODO\n" )); assert!(itip::attend(&cal(&series), done, &is(BOB), now()).is_ok()); } #[test] fn attendees_keep_the_organizers_status_of_an_instance() { let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n"); let master = format!( "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\ RRULE:FREQ=WEEKLY;COUNT=4\nSTATUS:CONFIRMED\n{people}END:VEVENT\n" ); let instance = |status: &str| { format!( "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\n\ DTEND:20260112T110000Z\nSTATUS:{status}\n{people}END:VEVENT\n" ) }; let tentative = cal(&format!("{master}{}", instance("TENTATIVE"))); assert_eq!( itip::attend(&tentative, cal(&master), &is(BOB), now()).err(), Some(Refused::AttendeeChange) ); let added = cal(&format!("{master}{}", instance("TENTATIVE"))); assert_eq!( itip::attend(&cal(&master), added, &is(BOB), now()).err(), Some(Refused::AttendeeChange) ); let same = cal(&format!("{master}{}", instance("CONFIRMED"))); assert!(itip::attend(&cal(&master), same, &is(BOB), now()).is_ok()); } #[test] fn dropping_a_declined_instance_replies_with_the_series_answer() { let master = format!( "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\ RRULE:FREQ=WEEKLY;COUNT=4\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n\ ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n" ); let copy = cal(&format!( "{master}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260112T100000Z\nDTSTART:20260112T100000Z\n\ DTEND:20260112T110000Z\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n\ ATTENDEE;PARTSTAT=DECLINED:{BOB}\nEND:VEVENT\n" )); let (_, reply) = itip::attend(©, cal(&master), &is(BOB), now()).unwrap(); let reply = text(&reply.unwrap().cal); assert!(reply.contains("RECURRENCE-ID:20260112T100000Z"), "{reply}"); assert!( reply.contains(&format!("PARTSTAT=ACCEPTED:{BOB}")), "{reply}" ); } #[test] fn a_reordered_save_sends_nothing() { let old = meeting(&format!( "ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nLOCATION:Room 1\n" )); let (store, _) = itip::organize(None, Some(old), &is(ALICE), now()); let old = store.unwrap(); let reordered = text(&old) .replace("SUMMARY:Sync\r\n", "") .replace("LOCATION:Room 1\r\n", "LOCATION:Room 1\r\nSUMMARY:Sync\r\n") .replace("DTSTAMP:20260101T000000Z", "DTSTAMP:20260102T000000Z"); let (_, msgs) = itip::organize( Some(&old), Some(ICalendar::parse(&reordered).unwrap()), &is(ALICE), now(), ); assert!(to(&msgs, BOB).is_none(), "{msgs:?}"); } #[test] fn an_override_of_no_instance_is_refused_and_many_overrides_stay_fast() { let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n"); let series = format!( "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\ RRULE:FREQ=DAILY;COUNT=5000\n{people}END:VEVENT\n" ); let over = |rid: &str| { format!( "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:{rid}\nDTSTART:{rid}\n\ DTEND:{}\n{people}END:VEVENT\n", rid.replace("T10", "T11") ) }; let stray = cal(&format!("{series}{}", over("20260112T103000Z"))); assert_eq!( itip::attend(&cal(&series), stray, &is(BOB), now()).err(), Some(Refused::AttendeeChange) ); let day = |i: i64| { (chrono::NaiveDate::from_ymd_opt(2026, 1, 5).unwrap() + chrono::TimeDelta::days(i)) .format("%Y%m%dT100000Z") .to_string() }; // At most this many changes in one PUT. let many: String = (0..1000) .map(|i| { over(&day(i)).replace( &format!("ATTENDEE:{BOB}"), &format!("ATTENDEE;PARTSTAT=DECLINED:{BOB}"), ) }) .collect(); let t = std::time::Instant::now(); let (_, reply) = itip::attend( &cal(&series), cal(&format!("{series}{many}")), &is(BOB), now(), ) .unwrap(); let mut org = cal(&series); assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB)).changed); assert!(t.elapsed().as_secs() < 20, "{:?}", t.elapsed()); } #[test] fn far_apart_replies_on_a_dense_series_all_land() { let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE:{BOB}\n"); let series = format!( "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDURATION:PT1M\n\ RRULE:FREQ=MINUTELY\n{people}END:VEVENT\n" ); let declined = |rid: &str| { format!( "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:{rid}\nDTSTART:{rid}\nDURATION:PT1M\n\ ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT=DECLINED:{BOB}\nEND:VEVENT\n" ) }; let copy = format!( "{series}{}{}", declined("20260105T100000Z"), declined("20280105T100000Z") ); let (_, reply) = itip::attend(&cal(&series), cal(©), &is(BOB), now()).unwrap(); let mut org = cal(&series); assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB)).changed); assert_eq!( text(&org).matches("PARTSTAT=DECLINED").count(), 2, "{}", text(&org) ); } #[test] fn a_stale_range_past_the_series_cuts_nothing() { let org = cal(&format!( "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\ RRULE:FREQ=DAILY;COUNT=3\nORGANIZER:{ALICE}\nATTENDEE:{BOB}\nATTENDEE:{CAROL}\nEND:VEVENT\n\ BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260115T100000Z\n\ DTSTART:20260115T120000Z\nDTEND:20260115T130000Z\nORGANIZER:{ALICE}\n\ ATTENDEE:{CAROL}\nEND:VEVENT\n" )); let (_, msgs) = itip::organize(None, Some(org), &is(ALICE), now()); let bob = text(&to(&msgs, BOB).unwrap().cal); assert!(bob.contains("COUNT=3"), "{bob}"); assert!(!bob.contains("UNTIL"), "{bob}"); } #[test] fn a_huge_duration_does_not_panic() { let huge = cal(&format!( "BEGIN:VEVENT\nUID:m1\nDTSTART;TZID=Europe/Berlin:20260105T100000\nDURATION:P99999999W\n\ ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\nEND:VEVENT\n" )); itip::organize(Some(&huge), Some(huge.clone()), &is(ALICE), now()); let accepted = ICalendar::parse(text(&huge).replace("NEEDS-ACTION", "ACCEPTED")).unwrap(); assert!(itip::attend(&huge, accepted, &is(BOB), now()).is_ok()); } #[test] fn many_attendees_and_ranges_stay_fast() { let people: String = (0..50) .map(|i| format!("ATTENDEE:mailto:u{i}@example.com\n")) .collect(); let second = |n: i64| { (Utc.with_ymd_and_hms(2026, 1, 5, 10, 0, 0).unwrap() + chrono::TimeDelta::seconds(n)) .format("%Y%m%dT%H%M%SZ") .to_string() }; let ranges: String = (1..=20) .map(|i| { let at = second(i * 400); format!( "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;RANGE=THISANDFUTURE:{at}\nDTSTART:{at}\n\ DURATION:PT1M\nORGANIZER:{ALICE}\nATTENDEE:mailto:u0@example.com\nEND:VEVENT\n" ) }) .collect(); let org = cal(&format!( "BEGIN:VEVENT\nUID:m1\nDTSTART:{}\nDURATION:PT1M\nRRULE:FREQ=SECONDLY;COUNT=10000\n\ ORGANIZER:{ALICE}\n{people}END:VEVENT\n{ranges}", second(0) )); let t = std::time::Instant::now(); let (_, msgs) = itip::organize(None, Some(org), &is(ALICE), now()); assert!(t.elapsed().as_secs() < 5, "{:?}", t.elapsed()); let u1 = text(&to(&msgs, "mailto:u1@example.com").unwrap().cal); assert!(u1.contains("UNTIL=20260105T100639Z"), "{u1}"); } #[test] fn a_copy_with_many_overrides_is_answered_fast() { let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n"); let day = |i: i64| { (chrono::NaiveDate::from_ymd_opt(2026, 1, 5).unwrap() + chrono::TimeDelta::days(i)) .format("%Y%m%dT100000") .to_string() }; let overrides: String = (0..8000) .map(|i| { format!( "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID;TZID=Europe/Berlin:{d}\n\ DTSTART;TZID=Europe/Berlin:{d}\nDURATION:PT1H\nSUMMARY:{i}\n{people}END:VEVENT\n", d = day(i) ) }) .collect(); let copy = cal(&format!( "BEGIN:VEVENT\nUID:m1\nDTSTART;TZID=Europe/Berlin:{}\nDURATION:PT1H\n\ RRULE:FREQ=DAILY;COUNT=9000\n{people}END:VEVENT\n{overrides}", day(0) )); let answered = ICalendar::parse(text(©).replacen( &format!("PARTSTAT=NEEDS-ACTION:{BOB}"), &format!("PARTSTAT=ACCEPTED:{BOB}"), 1, )) .unwrap(); let t = std::time::Instant::now(); let (_, reply) = itip::attend(©, answered, &is(BOB), now()).unwrap(); assert!(reply.is_some()); assert!(t.elapsed().as_secs() < 10, "{:?}", t.elapsed()); } #[test] fn clients_may_name_the_week_start_or_confirm_their_instance() { let people = format!("ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n"); let series = format!( "BEGIN:VEVENT\nUID:m1\nDTSTART:20260107T100000Z\nDTEND:20260107T110000Z\n\ RRULE:FREQ=WEEKLY;BYDAY=WE;COUNT=4\n{people}END:VEVENT\n" ); let accepted = series.replace("NEEDS-ACTION", "ACCEPTED"); let with_wkst = accepted.replace("COUNT=4", "COUNT=4;WKST=SU"); assert!(itip::attend(&cal(&series), cal(&with_wkst), &is(BOB), now()).is_ok()); // Every other week depends on where weeks start. let biweekly = series.replace("FREQ=WEEKLY", "FREQ=WEEKLY;INTERVAL=2"); let moved = biweekly .replace("NEEDS-ACTION", "ACCEPTED") .replace("COUNT=4", "COUNT=4;WKST=SU"); assert_eq!( itip::attend(&cal(&biweekly), cal(&moved), &is(BOB), now()).err(), Some(Refused::AttendeeChange) ); let confirmed = cal(&format!( "{series}BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:20260114T100000Z\nDTSTART:20260114T100000Z\n\ DTEND:20260114T110000Z\nSTATUS:CONFIRMED\n{}END:VEVENT\n", people.replace("NEEDS-ACTION", "ACCEPTED") )); assert!(itip::attend(&cal(&series), confirmed.clone(), &is(BOB), now()).is_ok()); // The organizer's CONFIRMED instance is not the attendee's to drop. assert_eq!( itip::attend(&confirmed, cal(&accepted), &is(BOB), now()).err(), Some(Refused::AttendeeChange) ); } #[test] fn a_sequence_bump_reaches_the_copies_quietly() { let old = meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n")); let (store, _) = itip::organize(None, Some(old), &is(ALICE), now()); let old = store.unwrap(); let bumped = ICalendar::parse(text(&old).replace("SUMMARY:Sync", "SUMMARY:Sync\r\nSEQUENCE:3")).unwrap(); let (store, msgs) = itip::organize(Some(&old), Some(bumped), &is(ALICE), now()); let bob = to(&msgs, BOB).expect("no update for bob"); assert!(bob.quiet); assert!(text(&bob.cal).contains("SEQUENCE:3")); // bob's later answer carries that SEQUENCE and lands. let copy = itip::receive( None, &Message { quiet: false, ..bob.clone() }, ) .unwrap(); let declined = ICalendar::parse(text(©).replace( &format!("PARTSTAT=NEEDS-ACTION:{BOB}"), &format!("PARTSTAT=DECLINED:{BOB}"), )) .unwrap(); let (_, reply) = itip::attend(©, declined, &is(BOB), now()).unwrap(); assert!(reply.is_some(), "{}", text(©)); let mut org = store.unwrap(); assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB)).changed); } #[test] fn many_attendees_share_one_message_body() { let people: String = (0..2000) .map(|i| format!("ATTENDEE:mailto:u{i}@example.com\n")) .collect(); let new = cal(&format!( "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\ SUMMARY:All hands\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n{people}END:VEVENT\n" )); let t = std::time::Instant::now(); let (store, msgs) = itip::organize(None, Some(new), &is(ALICE), now()); assert_eq!(msgs.len(), 2000); assert!( msgs.iter() .all(|m| std::sync::Arc::ptr_eq(&m.cal, &msgs[0].cal)) ); let store = store.unwrap(); let (_, again) = itip::organize(Some(&store), Some(store.clone()), &is(ALICE), now()); assert!(again.is_empty()); assert!(t.elapsed().as_secs() < 2, "{:?}", t.elapsed()); } /// An endless daily series alice organizes, bob's copy of it, and bob's /// copy with `n` instances deleted. fn endless_with_exdates(n: i64) -> (ICalendar, ICalendar, ICalendar) { let series = |bob: &str, extra: &str| { cal(&format!( "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\ RRULE:FREQ=DAILY\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n\ ATTENDEE;PARTSTAT={bob}:{BOB}\n{extra}END:VEVENT\n" )) }; let days: Vec = (1..=n) .map(|i| { (chrono::NaiveDate::from_ymd_opt(2026, 1, 5).unwrap() + chrono::TimeDelta::days(i)) .format("%Y%m%dT100000Z") .to_string() }) .collect(); let exdate = format!("EXDATE:{}\n", days.join(",")); ( series("ACCEPTED", ""), series("ACCEPTED", ""), series("ACCEPTED", &exdate), ) } #[test] fn an_attendee_may_delete_many_instances_but_not_without_end() { let (_, copy, many) = endless_with_exdates(8000); assert_eq!( itip::attend(©, many, &is(BOB), now()).err(), Some(Refused::AttendeeChange) ); let (mut org, copy, some) = endless_with_exdates(1000); let t = std::time::Instant::now(); let (_, reply) = itip::attend(©, some, &is(BOB), now()).unwrap(); assert!(itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB)).changed); assert_eq!(org.components.len(), 1002); assert!(t.elapsed().as_secs() < 10, "{:?}", t.elapsed()); } #[test] fn a_reply_never_grows_the_object_past_the_limit() { let (mut org, copy, some) = endless_with_exdates(1000); // 3101 components before the reply: the VCALENDAR, the master and 3099 // overrides of later days. let overrides: String = (2000..5099) .map(|i| { let d = (chrono::NaiveDate::from_ymd_opt(2026, 1, 5).unwrap() + chrono::TimeDelta::days(i)) .format("%Y%m%dT100000Z"); format!( "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID:{d}\nDTSTART:{d}\nDTEND:{d}\n\ ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n" ) }) .collect(); org = ICalendar::parse(text(&org).replace( "END:VCALENDAR\r\n", &format!("{}END:VCALENDAR\r\n", overrides.replace('\n', "\r\n")), )) .unwrap(); assert_eq!(org.components.len(), 3101); let (_, reply) = itip::attend(©, some, &is(BOB), now()).unwrap(); let applied = itip::apply_reply(&mut org, &reply.unwrap().cal, &is(BOB)); assert!(applied.changed); assert!(applied.dropped > 0); assert_eq!(org.components.len(), 4000); } #[test] fn narrowing_a_range_never_grows_the_object_past_the_limit() { let day = |i: i64| { (chrono::NaiveDate::from_ymd_opt(2026, 2, 1).unwrap() + chrono::TimeDelta::days(i)) .format("%Y%m%dT100000Z") .to_string() }; let part = |rid: &str, extra: &str, bob: &str| { format!( "BEGIN:VEVENT\nUID:m1\nRECURRENCE-ID{extra}:{rid}\nDTSTART:{rid}\nDTEND:{rid}\n\ ORGANIZER:{ALICE}\nATTENDEE:{ALICE}\nATTENDEE;PARTSTAT={bob}:{BOB}\nEND:VEVENT\n" ) }; // 3993 components: the VCALENDAR, the master, a range from February 1 // and 3990 plain overrides well after it. let padding: String = (2000..5990) .map(|i| part(&day(i), "", "ACCEPTED")) .collect(); let mut org = cal(&format!( "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T110000Z\n\ RRULE:FREQ=DAILY\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n\ ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\nEND:VEVENT\n{}{padding}", part(&day(0), ";RANGE=THISANDFUTURE", "ACCEPTED") )); assert_eq!(org.components.len(), 3993); // Each part for one instance under the range splits it again. let parts: String = (0..20).map(|i| part(&day(i), "", "DECLINED")).collect(); let reply = ICalendar::parse( format!( "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//t//t//EN\nMETHOD:REPLY\n{parts}END:VCALENDAR\n" ) .replace('\n', "\r\n"), ) .unwrap(); let applied = itip::apply_reply(&mut org, &reply, &is(BOB)); assert!(applied.changed); assert!(applied.dropped > 0); assert!(org.components.len() <= 4000, "{}", org.components.len()); } /// What alice's save of `new` over `old` does to bob's ACCEPTED. fn keeps_bobs_answer(old_rule: &str, new_rule: &str) -> bool { let series = |rule: &str| { let m = text(&meeting(&format!("ATTENDEE;PARTSTAT=ACCEPTED:{BOB}\n"))); ICalendar::parse(m.replace("COUNT=4", rule)).unwrap() }; let (store, _) = itip::organize( Some(&series(old_rule)), Some(series(new_rule)), &is(ALICE), now(), ); text(&store.unwrap()).contains(&format!("PARTSTAT=ACCEPTED:{BOB}")) } #[test] fn count_and_until_compare_by_the_last_instance() { // The meeting is on Mondays from January 5. assert!(keeps_bobs_answer("COUNT=4", "UNTIL=20260119T100000Z")); assert!(keeps_bobs_answer("UNTIL=20260126T100000Z", "COUNT=3")); assert!(keeps_bobs_answer("COUNT=4", "COUNT=3")); assert!(!keeps_bobs_answer("COUNT=3", "UNTIL=20260202T100000Z")); } #[test] fn an_attendee_may_write_the_same_rule_in_another_form() { let answer = |old_rule: &str, new_rule: &str| { let copy = text(&meeting(&format!("ATTENDEE;PARTSTAT=NEEDS-ACTION:{BOB}\n"))) .replace("COUNT=4", old_rule); let answered = copy .replace("PARTSTAT=NEEDS-ACTION", "PARTSTAT=ACCEPTED") .replace(old_rule, new_rule); itip::attend( &ICalendar::parse(©).unwrap(), ICalendar::parse(&answered).unwrap(), &is(BOB), now(), ) .map(|_| ()) }; assert_eq!(answer("UNTIL=20260126", "UNTIL=20260126T100000Z"), Ok(())); assert_eq!(answer("COUNT=4", "COUNT=4;BYDAY=MO"), Ok(())); assert_eq!(answer("COUNT=4", "UNTIL=20260126T100000Z"), Ok(())); assert_eq!( answer("COUNT=4", "UNTIL=20260202T100000Z"), Err(Refused::AttendeeChange) ); assert_eq!( answer("COUNT=4", "COUNT=4;BYDAY=MO,TU"), Err(Refused::AttendeeChange) ); } #[test] fn moving_a_far_until_costs_only_the_gap() { let series = |until: &str, bob: &str| { cal(&format!( "BEGIN:VEVENT\nUID:m1\nDTSTART:20260105T100000Z\nDTEND:20260105T100100Z\n\ RRULE:FREQ=MINUTELY;UNTIL={until}\nORGANIZER:{ALICE}\nATTENDEE:{ALICE}\n\ ATTENDEE;PARTSTAT={bob}:{BOB}\nEND:VEVENT\n" )) }; let t = std::time::Instant::now(); let (store, _) = itip::organize( Some(&series("99991231T000000Z", "ACCEPTED")), Some(series("99991230T000000Z", "ACCEPTED")), &is(ALICE), now(), ); assert!(text(&store.unwrap()).contains(&format!("PARTSTAT=ACCEPTED:{BOB}"))); // A DATE end that keeps the same instances: none falls between the two. let copy = series("99991230T235930Z", "NEEDS-ACTION"); let answered = series("99991230", "ACCEPTED"); let attended = itip::attend(©, answered, &is(BOB), now()); assert!(attended.is_ok(), "{:?}", attended.err()); assert!(t.elapsed().as_millis() < 1000, "{:?}", t.elapsed()); }