//! 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))); 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))); 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)), "{rid}"); } assert!(itip::apply_reply( &mut org, &reply("20260112T100000Z"), &is(BOB) )); } #[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))); 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))); 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: 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))); 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)); }