//! Editing and deleting parts of a series. //! //! The three scopes are the subtlest thing a calendar does, and v1 shipped them //! across 1,165 lines of duplicated handlers with no automated coverage at all. //! Each case here states what should end up in the stored `.ics`, then checks //! it by expanding the result — because the question that matters is not "what //! did we write" but "what does a calendar client see afterwards". #![allow(clippy::unwrap_used, clippy::expect_used)] use chrono::{DateTime, TimeDelta, Utc}; use chrono_tz::Tz; use pretty_assertions::assert_eq; use runway_core::ical; use runway_core::model::*; use runway_core::recurrence::{Window, Zones, expand}; use runway_core::series::{ DeleteOutcome, EditOutcome, SeriesError, apply_delete, apply_edit, set_until, strip_bounds, }; fn denver() -> Tz { Tz::America__Denver } fn zones() -> Zones { Zones::new(denver()) } fn at(day: u32, hour: u32) -> CalendarDateTime { CalendarDateTime::Zoned { local: chrono::NaiveDate::from_ymd_opt(2026, 1, day) .unwrap() .and_hms_opt(hour, 0, 0) .unwrap(), tzid: TzId::new("America/Denver").unwrap(), } } fn instant(text: &str) -> DateTime { text.parse().unwrap() } /// A weekly Monday 09:00 series starting 2026-01-05, six occurrences. fn weekly_series() -> VCalendar { let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n\ BEGIN:VEVENT\r\nUID:standup@test\r\nDTSTAMP:20260101T000000Z\r\n\ DTSTART;TZID=America/Denver:20260105T090000\r\n\ DTEND;TZID=America/Denver:20260105T093000\r\n\ SUMMARY:Standup\r\nRRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=6\r\n\ END:VEVENT\r\nEND:VCALENDAR\r\n"; ical::parse(ics).unwrap() } fn one_off() -> VCalendar { let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n\ BEGIN:VEVENT\r\nUID:dentist@test\r\nDTSTAMP:20260101T000000Z\r\n\ DTSTART;TZID=America/Denver:20260105T090000\r\n\ DTEND;TZID=America/Denver:20260105T093000\r\n\ SUMMARY:Dentist\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; ical::parse(ics).unwrap() } /// The edited form of an occurrence: moved to 14:00 and renamed. fn moved_to_afternoon(day: u32) -> VEvent { let mut event = VEvent::with_uid("ignored-by-the-server", at(day, 14)).titled("Standup (moved)"); event.end = Some(EventEnd::DateTime { dtend: at(day, 15) }); event } /// Local start times an outcome produces over January, as a calendar would /// show them. fn shown(calendars: &[&VCalendar]) -> Vec { let window = Window::new( instant("2026-01-01T00:00:00Z"), instant("2026-03-01T00:00:00Z"), ); let mut out: Vec<(DateTime, String)> = Vec::new(); for calendar in calendars { for occurrence in expand(calendar, window, zones()).unwrap() { out.push(( occurrence.start_utc, format!( "{} {}", occurrence.start.naive_local().format("%Y-%m-%d %H:%M"), occurrence.title().unwrap_or("(untitled)"), ), )); } } out.sort_by_key(|(instant, _)| *instant); out.into_iter().map(|(_, label)| label).collect() } fn replaced(outcome: EditOutcome) -> VCalendar { match outcome { EditOutcome::Replace(calendar) => calendar, EditOutcome::Split { .. } => panic!("expected a single resource, got a split"), } } // ------------------------------------------------------------- entire series -- #[test] fn editing_the_whole_series_edits_the_master_in_place() { let series = weekly_series(); let mut renamed = series.master().unwrap().clone(); renamed.summary = Some("Standup (renamed)".to_owned()); let result = replaced(apply_edit(&series, EditScope::EntireSeries, None, &renamed, zones()).unwrap()); assert_eq!(result.events.len(), 1, "one master, no override"); assert_eq!( result.master().unwrap().rrule.as_deref(), Some("FREQ=WEEKLY;BYDAY=MO;COUNT=6"), "the rule is untouched", ); assert!(shown(&[&result]).iter().all(|s| s.contains("renamed"))); assert_eq!(shown(&[&result]).len(), 6); } #[test] fn editing_the_whole_series_keeps_its_identity() { let series = weekly_series(); let mut renamed = series.master().unwrap().clone(); renamed.uid = "a-uid-the-client-made-up".to_owned(); let result = replaced(apply_edit(&series, EditScope::EntireSeries, None, &renamed, zones()).unwrap()); assert_eq!( result.master().unwrap().uid, "standup@test", "the UID is the server's business; taking it from the request would let \ a mistyped edit overwrite a different event", ); } #[test] fn moving_a_series_carries_its_overrides_with_it() { // The case that would otherwise lose data: an override names an occurrence // of the *old* pattern, so moving the master leaves it pointing at nothing. let mut series = weekly_series(); let mut exception = moved_to_afternoon(12); exception.uid = "standup@test".to_owned(); exception.recurrence_id = Some(at(12, 9)); series.events.push(exception); let mut moved = series.master().unwrap().clone(); moved.dtstart = at(5, 11); moved.end = Some(EventEnd::DateTime { dtend: at(5, 12) }); let result = replaced(apply_edit(&series, EditScope::EntireSeries, None, &moved, zones()).unwrap()); assert_eq!(result.overrides().count(), 1, "the override survived"); assert_eq!( result.overrides().next().unwrap().recurrence_id, Some(at(12, 11)), "and its RECURRENCE-ID moved by the same two hours, so it still names \ a real occurrence instead of being silently discarded", ); } // ----------------------------------------------------------------- this only -- #[test] fn editing_one_occurrence_writes_an_override_and_nothing_else() { let series = weekly_series(); let result = replaced( apply_edit( &series, EditScope::ThisOnly, Some(&at(12, 9)), &moved_to_afternoon(12), zones(), ) .unwrap(), ); assert_eq!(result.events.len(), 2, "master plus one override"); let exception = result.overrides().next().unwrap(); assert_eq!(exception.uid, "standup@test", "one resource, one UID"); assert_eq!(exception.recurrence_id, Some(at(12, 9))); assert_eq!(exception.dtstart, at(12, 14)); assert!( exception.rrule.is_none(), "an override describes one occurrence; a second RRULE would make this \ two series in one resource", ); assert!( result.master().unwrap().exdate.is_empty(), "no EXDATE: an EXDATE says the occurrence does not happen, an override \ says it happens differently, and writing both is contradictory", ); } #[test] fn an_edited_occurrence_appears_once_at_its_new_time() { let series = weekly_series(); let result = replaced( apply_edit( &series, EditScope::ThisOnly, Some(&at(12, 9)), &moved_to_afternoon(12), zones(), ) .unwrap(), ); assert_eq!( shown(&[&result]), vec![ "2026-01-05 09:00 Standup", "2026-01-12 14:00 Standup (moved)", "2026-01-19 09:00 Standup", "2026-01-26 09:00 Standup", "2026-02-02 09:00 Standup", "2026-02-09 09:00 Standup", ], "still six occurrences, one of them moved -- not seven, and not five", ); } #[test] fn editing_the_same_occurrence_twice_replaces_its_override() { let series = weekly_series(); let once = replaced( apply_edit( &series, EditScope::ThisOnly, Some(&at(12, 9)), &moved_to_afternoon(12), zones(), ) .unwrap(), ); let mut again = moved_to_afternoon(12); again.dtstart = at(12, 16); again.end = Some(EventEnd::DateTime { dtend: at(12, 17) }); let twice = replaced( apply_edit( &once, EditScope::ThisOnly, Some(&at(12, 9)), &again, zones(), ) .unwrap(), ); assert_eq!( twice.overrides().count(), 1, "a second edit of the same occurrence must replace the override, not \ add a duplicate", ); assert_eq!(twice.overrides().next().unwrap().dtstart, at(12, 16)); } #[test] fn editing_one_occurrence_without_saying_which_is_refused() { let series = weekly_series(); assert_eq!( apply_edit( &series, EditScope::ThisOnly, None, &moved_to_afternoon(12), zones() ) .unwrap_err(), SeriesError::MissingRecurrenceId, ); } // ----------------------------------------------------------- this and future -- #[test] fn splitting_a_series_truncates_the_first_and_starts_a_second() { let series = weekly_series(); let outcome = apply_edit( &series, EditScope::ThisAndFuture, Some(&at(19, 9)), &moved_to_afternoon(19), zones(), ) .unwrap(); let EditOutcome::Split { existing, new_series, } = outcome else { panic!("this-and-future is two resources, not one"); }; let rule = existing.master().unwrap().rrule.clone().unwrap(); assert!( rule.contains("UNTIL=20260119T155959Z"), "the original ends one second before the split point: {rule}", ); assert!( !rule.contains("COUNT"), "COUNT and UNTIL are mutually exclusive: {rule}", ); assert_ne!( new_series.master().unwrap().uid, "standup@test", "a new series is a new resource with its own UID", ); assert_eq!( new_series.master().unwrap().rrule.as_deref(), Some("FREQ=WEEKLY;BYDAY=MO;COUNT=4"), "it inherits the pattern and what is left of the bound: two of the six \ stayed with the original, so four remain. Carrying COUNT=6 over would \ add occurrences; dropping it would turn \"six times\" into forever", ); } #[test] fn a_split_series_shows_the_right_occurrences_on_both_sides() { let series = weekly_series(); let EditOutcome::Split { existing, new_series, } = apply_edit( &series, EditScope::ThisAndFuture, Some(&at(19, 9)), &moved_to_afternoon(19), zones(), ) .unwrap() else { panic!("expected a split"); }; assert_eq!( shown(&[&existing]), vec!["2026-01-05 09:00 Standup", "2026-01-12 09:00 Standup"], "the old series stops before the split", ); assert_eq!( shown(&[&new_series]), vec![ "2026-01-19 14:00 Standup (moved)", "2026-01-26 14:00 Standup (moved)", "2026-02-02 14:00 Standup (moved)", "2026-02-09 14:00 Standup (moved)", ], "and the new one takes over at the new time, for the four that were \ left -- six in total across both halves, as originally asked for", ); assert!( !shown(&[&existing, &new_series]).contains(&"2026-01-19 09:00 Standup".to_owned()), "nothing may appear at the old time on the split day", ); } #[test] fn splitting_at_the_first_occurrence_is_just_editing_the_series() { let series = weekly_series(); let outcome = apply_edit( &series, EditScope::ThisAndFuture, Some(&at(5, 9)), &moved_to_afternoon(5), zones(), ) .unwrap(); let result = replaced(outcome); assert_eq!( result.master().unwrap().uid, "standup@test", "splitting at the very start would leave an empty truncated series and \ a new one, which is two resources where one belongs", ); assert_eq!( result.master().unwrap().rrule.as_deref(), Some("FREQ=WEEKLY;BYDAY=MO;COUNT=6"), "and the rule survives: the person asked to change every occurrence \ from the first one onwards, not to stop the series recurring", ); assert_eq!(shown(&[&result]).len(), 6); assert!(shown(&[&result]).iter().all(|s| s.contains("14:00"))); } #[test] fn clearing_the_rule_on_the_whole_series_makes_it_a_single_event() { // Under EntireSeries the submitted event *is* the master, so dropping the // rule is an instruction, not an omission. The split path is the opposite // case and carries the rule forward; see the test above. let series = weekly_series(); let mut once_only = series.master().unwrap().clone(); once_only.rrule = None; let result = replaced(apply_edit(&series, EditScope::EntireSeries, None, &once_only, zones()).unwrap()); assert_eq!(shown(&[&result]), vec!["2026-01-05 09:00 Standup"]); } #[test] fn a_split_hands_later_overrides_to_the_new_series() { let mut series = weekly_series(); for day in [12, 26] { let mut exception = moved_to_afternoon(day); exception.uid = "standup@test".to_owned(); exception.recurrence_id = Some(at(day, 9)); series.events.push(exception); } let EditOutcome::Split { existing, .. } = apply_edit( &series, EditScope::ThisAndFuture, Some(&at(19, 9)), &moved_to_afternoon(19), zones(), ) .unwrap() else { panic!("expected a split"); }; let kept: Vec<_> = existing.overrides().collect(); assert_eq!(kept.len(), 1, "only the override before the split stays"); assert_eq!(kept[0].recurrence_id, Some(at(12, 9))); } // ------------------------------------------------------------------ deleting -- #[test] fn deleting_one_occurrence_adds_an_exdate() { let series = weekly_series(); let DeleteOutcome::Replace(result) = apply_delete(&series, EditScope::ThisOnly, Some(&at(12, 9)), zones()).unwrap() else { panic!("deleting one occurrence keeps the resource"); }; assert_eq!(result.master().unwrap().exdate, vec![at(12, 9)]); assert_eq!( shown(&[&result]), vec![ "2026-01-05 09:00 Standup", "2026-01-19 09:00 Standup", "2026-01-26 09:00 Standup", "2026-02-02 09:00 Standup", "2026-02-09 09:00 Standup", ], "the 12th is gone and the rest are untouched", ); } #[test] fn deleting_an_occurrence_that_was_edited_removes_its_override_too() { let series = weekly_series(); let edited = replaced( apply_edit( &series, EditScope::ThisOnly, Some(&at(12, 9)), &moved_to_afternoon(12), zones(), ) .unwrap(), ); let DeleteOutcome::Replace(result) = apply_delete(&edited, EditScope::ThisOnly, Some(&at(12, 9)), zones()).unwrap() else { panic!("expected the resource to survive"); }; assert_eq!( result.overrides().count(), 0, "an EXDATE without removing the override would leave the moved copy \ showing on a day the person just deleted", ); assert!(!shown(&[&result]).iter().any(|s| s.contains("2026-01-12"))); } #[test] fn deleting_this_and_future_truncates_the_series() { let series = weekly_series(); let DeleteOutcome::Replace(result) = apply_delete(&series, EditScope::ThisAndFuture, Some(&at(19, 9)), zones()).unwrap() else { panic!("expected the resource to survive"); }; assert_eq!( shown(&[&result]), vec!["2026-01-05 09:00 Standup", "2026-01-12 09:00 Standup"], ); } #[test] fn deleting_the_entire_series_removes_the_resource() { let series = weekly_series(); assert_eq!( apply_delete(&series, EditScope::EntireSeries, None, zones()).unwrap(), DeleteOutcome::Remove, "there is nothing left to write back", ); } // -------------------------------------------------------------- one-off events #[test] fn every_scope_means_the_same_thing_for_a_one_off_event() { let event = one_off(); let mut renamed = event.master().unwrap().clone(); renamed.summary = Some("Dentist (rescheduled)".to_owned()); for scope in [ EditScope::ThisOnly, EditScope::ThisAndFuture, EditScope::EntireSeries, ] { let result = replaced(apply_edit(&event, scope, Some(&at(5, 9)), &renamed, zones()).unwrap()); assert_eq!(result.events.len(), 1, "{scope:?}"); assert_eq!(result.master().unwrap().uid, "dentist@test", "{scope:?}"); assert_eq!(shown(&[&result]).len(), 1, "{scope:?}"); } } #[test] fn deleting_a_one_off_event_removes_it_whatever_the_scope() { let event = one_off(); for scope in [ EditScope::ThisOnly, EditScope::ThisAndFuture, EditScope::EntireSeries, ] { assert_eq!( apply_delete(&event, scope, Some(&at(5, 9)), zones()).unwrap(), DeleteOutcome::Remove, "{scope:?}", ); } } // ---------------------------------------------------------------- rule edits -- #[test] fn setting_until_replaces_any_existing_bound() { let until = instant("2026-01-19T15:59:59Z"); assert_eq!( set_until("FREQ=WEEKLY;BYDAY=MO;COUNT=6", until).unwrap(), "FREQ=WEEKLY;BYDAY=MO;UNTIL=20260119T155959Z", ); assert_eq!( set_until("FREQ=WEEKLY;UNTIL=20270101T000000Z", until).unwrap(), "FREQ=WEEKLY;UNTIL=20260119T155959Z", ); } #[test] fn setting_until_leaves_every_other_part_exactly_as_written() { // Exchange writes WKST=SU and orders its parts its own way. Parsing the // rule and rendering it back would normalise both; this touches only the // bound. let rule = "FREQ=WEEKLY;INTERVAL=1;BYDAY=WE;WKST=SU"; let bounded = set_until(rule, instant("2026-01-19T15:59:59Z")).unwrap(); assert!(bounded.starts_with(rule), "{bounded}"); } #[test] fn an_unbounded_series_stays_unbounded_when_split() { let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n\ BEGIN:VEVENT\r\nUID:forever@test\r\nDTSTAMP:20260101T000000Z\r\n\ DTSTART;TZID=America/Denver:20260105T090000\r\n\ SUMMARY:Standup\r\nRRULE:FREQ=WEEKLY;BYDAY=MO\r\n\ END:VEVENT\r\nEND:VCALENDAR\r\n"; let series = ical::parse(ics).unwrap(); let EditOutcome::Split { new_series, .. } = apply_edit( &series, EditScope::ThisAndFuture, Some(&at(19, 9)), &moved_to_afternoon(19), zones(), ) .unwrap() else { panic!("expected a split"); }; assert_eq!( new_series.master().unwrap().rrule.as_deref(), Some("FREQ=WEEKLY;BYDAY=MO"), "there was no bound to divide up", ); } #[test] fn a_counted_series_keeps_its_total_across_a_split() { // The property that matters: however the series is cut, the number of // occurrences the person asked for is the number they end up with. let series = weekly_series(); for split_day in [12, 19, 26] { let EditOutcome::Split { existing, new_series, } = apply_edit( &series, EditScope::ThisAndFuture, Some(&at(split_day, 9)), &moved_to_afternoon(split_day), zones(), ) .unwrap() else { panic!("expected a split at day {split_day}"); }; assert_eq!( shown(&[&existing, &new_series]).len(), 6, "splitting at the {split_day}th changed the total", ); } } #[test] fn stripping_bounds_leaves_the_pattern() { assert_eq!( strip_bounds("FREQ=MONTHLY;BYDAY=3TU;COUNT=12"), "FREQ=MONTHLY;BYDAY=3TU", ); assert_eq!(strip_bounds("FREQ=DAILY"), "FREQ=DAILY"); } #[test] fn a_rule_with_no_frequency_is_refused() { assert!(set_until("COUNT=5", instant("2026-01-19T15:59:59Z")).is_err()); assert!(set_until("", instant("2026-01-19T15:59:59Z")).is_err()); } // ------------------------------------------------------------ written output -- #[test] fn every_outcome_is_still_valid_icalendar() { // The results are written to a real server, so they have to survive the // round trip the whole of M3 exists to guarantee. let series = weekly_series(); let mut outcomes = vec![replaced( apply_edit( &series, EditScope::ThisOnly, Some(&at(12, 9)), &moved_to_afternoon(12), zones(), ) .unwrap(), )]; if let EditOutcome::Split { existing, new_series, } = apply_edit( &series, EditScope::ThisAndFuture, Some(&at(19, 9)), &moved_to_afternoon(19), zones(), ) .unwrap() { outcomes.push(existing); outcomes.push(new_series); } for calendar in &outcomes { let written = ical::write(calendar); let reparsed = ical::parse(&written).expect("outcomes must be readable"); assert_eq!(&reparsed, calendar, "changed meaning on the way out"); for line in written.split("\r\n") { assert!(line.len() <= 75, "unfolded line: {line}"); } } } #[test] fn an_edit_bumps_the_sequence_number() { let series = weekly_series(); let before = series.master().unwrap().sequence; let mut renamed = series.master().unwrap().clone(); renamed.summary = Some("Renamed".to_owned()); let result = replaced(apply_edit(&series, EditScope::EntireSeries, None, &renamed, zones()).unwrap()); assert_eq!( result.master().unwrap().sequence, before + 1, "SEQUENCE is how other clients know a revision happened; leaving it \ still makes an edited invitation look unchanged", ); assert!(result.master().unwrap().last_modified.is_some()); } #[test] fn a_resource_with_only_overrides_cannot_be_edited() { let mut orphan = weekly_series(); orphan.events.retain(VEvent::is_override); orphan.events.push({ let mut exception = moved_to_afternoon(12); exception.recurrence_id = Some(at(12, 9)); exception }); assert_eq!( apply_edit( &orphan, EditScope::EntireSeries, None, &moved_to_afternoon(12), zones() ) .unwrap_err(), SeriesError::NoMaster, "there is no series to apply a scope to, and guessing would be worse \ than saying so", ); } #[test] fn an_edit_across_a_dst_boundary_keeps_the_wall_clock_time() { let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n\ BEGIN:VEVENT\r\nUID:dst@test\r\nDTSTAMP:20260101T000000Z\r\n\ DTSTART;TZID=America/Denver:20260302T090000\r\n\ DTEND;TZID=America/Denver:20260302T093000\r\n\ SUMMARY:Weekly\r\nRRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=4\r\n\ END:VEVENT\r\nEND:VCALENDAR\r\n"; let series = ical::parse(ics).unwrap(); // Denver springs forward on 2026-03-08, so the split point is on the far // side of the change and UNTIL has to be computed from the right offset. let split_at = CalendarDateTime::Zoned { local: chrono::NaiveDate::from_ymd_opt(2026, 3, 16) .unwrap() .and_hms_opt(9, 0, 0) .unwrap(), tzid: TzId::new("America/Denver").unwrap(), }; let DeleteOutcome::Replace(result) = apply_delete(&series, EditScope::ThisAndFuture, Some(&split_at), zones()).unwrap() else { panic!("expected the resource to survive"); }; let rule = result.master().unwrap().rrule.clone().unwrap(); assert!( rule.contains("UNTIL=20260316T145959Z"), "09:00 Denver on 16 March is 15:00 UTC because daylight time has begun; \ computing it from the winter offset would delete a week too many or \ too few: {rule}", ); let window = Window::new( instant("2026-03-01T00:00:00Z"), instant("2026-04-01T00:00:00Z"), ); let remaining = expand(&result, window, zones()).unwrap(); assert_eq!( remaining.len(), 2, "2 and 9 March survive, 16 and 23 do not" ); } #[test] fn scopes_round_trip_through_json() { for scope in [ EditScope::ThisOnly, EditScope::ThisAndFuture, EditScope::EntireSeries, ] { let encoded = serde_json::to_string(&scope).unwrap(); assert_eq!(serde_json::from_str::(&encoded).unwrap(), scope); } assert_eq!( serde_json::to_string(&EditScope::ThisAndFuture).unwrap(), "\"this_and_future\"", "the wire form is stable; v1 dispatched on 53 hand-written string \ literals and a typo was a runtime fallthrough", ); } #[test] fn a_shifted_series_keeps_its_duration() { let series = weekly_series(); let mut moved = series.master().unwrap().clone(); moved.dtstart = at(5, 11); moved.end = Some(EventEnd::DateTime { dtend: at(5, 12) }); let result = replaced(apply_edit(&series, EditScope::EntireSeries, None, &moved, zones()).unwrap()); assert_eq!(result.master().unwrap().duration(), TimeDelta::hours(1)); }