//! 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@filebrowser.invalid"; const BOB: &str = "mailto:bob@filebrowser.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 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.filebrowser.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.filebrowser.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 ); }