Parsing goes through icalendar's low-level parser, which keeps properties in order and keeps repeated ones. Writing is ours: that crate's writer escapes a whole property value as text, so CATEGORIES:Work,Personal would go out as one category named "Work,Personal" to every other client. Anything the model does not interpret is carried rather than dropped -- X-MOZ-LASTACK, X-EVOLUTION-ALARM-UID, ACKNOWLEDGED, the X-MICROSOFT-CDO set, unrecognised ATTENDEE parameters, and whole VTODO/VJOURNAL components. A calendar has several clients writing to it and this one is not the authority on which properties matter. VTIMEZONE is modelled properly, and TZID is stored exactly as written: Exchange names its zones "Pacific Standard Time", which no IANA lookup resolves, and normalising at parse time would make the document unrepresentable. Mapping to a real zone belongs at the point of use. Tested against a golden corpus captured from the live Baikal (seven producing clients over five years) and a published Outlook feed, scrubbed of private content with the structure left byte-for-byte. Eight hand-written fixtures cover what neither server had: DURATION, floating times, RDATE, DST boundaries, leap day, and the full escape set. The contract is that parse -> write -> parse is stable, plus a check that no property name loses occurrences across the trip, since a parser that dropped ATTENDEE entirely would round-trip perfectly and still be wrong.
522 lines
15 KiB
Rust
522 lines
15 KiB
Rust
//! Behaviour of the domain model, exercised through the public API.
|
|
//!
|
|
//! Several of these assert the exact JSON shape. That is deliberate: the model
|
|
//! is also the wire format, so a change to it is a change to the API contract
|
|
//! and should have to be made on purpose.
|
|
|
|
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
|
|
|
use chrono::{DateTime, NaiveDate, TimeDelta, TimeZone, Utc};
|
|
use pretty_assertions::assert_eq;
|
|
use runway_core::model::*;
|
|
use serde_json::json;
|
|
|
|
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
|
|
NaiveDate::from_ymd_opt(y, m, d).unwrap()
|
|
}
|
|
|
|
fn naive(y: i32, m: u32, d: u32, h: u32, min: u32) -> chrono::NaiveDateTime {
|
|
date(y, m, d).and_hms_opt(h, min, 0).unwrap()
|
|
}
|
|
|
|
fn utc(y: i32, m: u32, d: u32, h: u32, min: u32) -> DateTime<Utc> {
|
|
Utc.with_ymd_and_hms(y, m, d, h, min, 0).unwrap()
|
|
}
|
|
|
|
fn denver() -> TzId {
|
|
TzId::new("America/Denver").unwrap()
|
|
}
|
|
|
|
// ---------------------------------------------------------------- date-times
|
|
|
|
#[test]
|
|
fn each_datetime_variant_survives_a_json_round_trip() {
|
|
let cases = vec![
|
|
CalendarDateTime::Date {
|
|
date: date(2026, 12, 25),
|
|
},
|
|
CalendarDateTime::Floating {
|
|
local: naive(2026, 12, 25, 9, 30),
|
|
},
|
|
CalendarDateTime::Utc {
|
|
utc: utc(2026, 12, 25, 9, 30),
|
|
},
|
|
CalendarDateTime::Zoned {
|
|
local: naive(2026, 12, 25, 9, 30),
|
|
tzid: denver(),
|
|
},
|
|
];
|
|
|
|
for original in cases {
|
|
let encoded = serde_json::to_string(&original).unwrap();
|
|
let decoded: CalendarDateTime = serde_json::from_str(&encoded).unwrap();
|
|
assert_eq!(original, decoded, "round trip failed for {encoded}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn zoned_datetime_json_names_its_zone() {
|
|
let value = CalendarDateTime::Zoned {
|
|
local: naive(2026, 12, 25, 9, 30),
|
|
tzid: denver(),
|
|
};
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(&value).unwrap(),
|
|
json!({
|
|
"kind": "zoned",
|
|
"local": "2026-12-25T09:30:00",
|
|
"tzid": "America/Denver",
|
|
}),
|
|
"the wire format must carry an IANA zone, never a bare UTC offset: \
|
|
an offset cannot distinguish standard time from daylight time, which \
|
|
is what made recurring events drift across DST in the last iteration",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn date_only_values_are_recognisable_without_a_separate_flag() {
|
|
assert!(
|
|
CalendarDateTime::Date {
|
|
date: date(2026, 12, 25)
|
|
}
|
|
.is_date_only()
|
|
);
|
|
assert!(
|
|
!CalendarDateTime::Utc {
|
|
utc: utc(2026, 12, 25, 9, 0)
|
|
}
|
|
.is_date_only()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shifting_preserves_the_variant_and_the_zone() {
|
|
let zoned = CalendarDateTime::Zoned {
|
|
local: naive(2026, 12, 25, 9, 0),
|
|
tzid: denver(),
|
|
};
|
|
|
|
let shifted = zoned.shifted(TimeDelta::hours(2));
|
|
|
|
assert_eq!(
|
|
shifted,
|
|
CalendarDateTime::Zoned {
|
|
local: naive(2026, 12, 25, 11, 0),
|
|
tzid: denver()
|
|
},
|
|
"moving an event must not silently change what zone it is expressed in",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shifting_a_date_only_value_moves_whole_days() {
|
|
let all_day = CalendarDateTime::Date {
|
|
date: date(2026, 12, 25),
|
|
};
|
|
|
|
assert_eq!(
|
|
all_day.shifted(TimeDelta::days(3)),
|
|
CalendarDateTime::Date {
|
|
date: date(2026, 12, 28)
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_time_zone_identifiers_are_rejected() {
|
|
assert!(TzId::new("").is_err());
|
|
assert!(TzId::new(" ").is_err());
|
|
assert!(TzId::new("America/Denver").is_ok());
|
|
}
|
|
|
|
// ----------------------------------------------------------------- durations
|
|
|
|
#[test]
|
|
fn durations_are_carried_as_whole_seconds() {
|
|
let fifteen_minutes = IcalDuration::minutes(15).unwrap();
|
|
|
|
assert_eq!(serde_json::to_value(fifteen_minutes).unwrap(), json!(900));
|
|
}
|
|
|
|
#[test]
|
|
fn negative_durations_round_trip() {
|
|
let before = IcalDuration::minutes(-15).unwrap();
|
|
|
|
let decoded: IcalDuration =
|
|
serde_json::from_value(serde_json::to_value(before).unwrap()).unwrap();
|
|
|
|
assert_eq!(decoded, before);
|
|
assert_eq!(decoded.as_time_delta(), TimeDelta::minutes(-15));
|
|
}
|
|
|
|
// -------------------------------------------------------------------- events
|
|
|
|
#[test]
|
|
fn all_day_is_derived_from_the_start_value() {
|
|
let all_day = VEvent::new(CalendarDateTime::Date {
|
|
date: date(2026, 12, 25),
|
|
});
|
|
let timed = VEvent::new(CalendarDateTime::Utc {
|
|
utc: utc(2026, 12, 25, 9, 0),
|
|
});
|
|
|
|
assert!(all_day.is_all_day());
|
|
assert!(!timed.is_all_day());
|
|
}
|
|
|
|
#[test]
|
|
fn an_all_day_event_without_an_end_lasts_one_day() {
|
|
let event = VEvent::new(CalendarDateTime::Date {
|
|
date: date(2026, 12, 25),
|
|
});
|
|
|
|
assert_eq!(event.duration(), TimeDelta::days(1));
|
|
assert_eq!(
|
|
event.dtend(),
|
|
CalendarDateTime::Date {
|
|
date: date(2026, 12, 26)
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_timed_event_without_an_end_has_no_duration() {
|
|
let event = VEvent::new(CalendarDateTime::Utc {
|
|
utc: utc(2026, 12, 25, 9, 0),
|
|
});
|
|
|
|
assert_eq!(
|
|
event.duration(),
|
|
TimeDelta::zero(),
|
|
"RFC 5545 gives no default length for a timed event; inventing one \
|
|
(the last iteration assumed an hour) silently corrupts imported data",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_end_expressed_as_a_duration_resolves_to_an_instant() {
|
|
let event = VEvent::new(CalendarDateTime::Zoned {
|
|
local: naive(2026, 12, 25, 9, 0),
|
|
tzid: denver(),
|
|
})
|
|
.lasting(IcalDuration::hours(2).unwrap());
|
|
|
|
assert_eq!(event.duration(), TimeDelta::hours(2));
|
|
assert_eq!(
|
|
event.dtend(),
|
|
CalendarDateTime::Zoned {
|
|
local: naive(2026, 12, 25, 11, 0),
|
|
tzid: denver()
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_explicit_end_wins_over_any_computation() {
|
|
let event = VEvent::new(CalendarDateTime::Utc {
|
|
utc: utc(2026, 12, 25, 9, 0),
|
|
})
|
|
.ending_at(CalendarDateTime::Utc {
|
|
utc: utc(2026, 12, 25, 17, 30),
|
|
});
|
|
|
|
assert_eq!(
|
|
event.duration(),
|
|
TimeDelta::hours(8) + TimeDelta::minutes(30)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_blank_summary_counts_as_no_title() {
|
|
let mut event = VEvent::new(CalendarDateTime::Date {
|
|
date: date(2026, 12, 25),
|
|
});
|
|
assert_eq!(event.title(), None);
|
|
|
|
event.summary = Some(" ".to_owned());
|
|
assert_eq!(event.title(), None);
|
|
|
|
event.summary = Some("Dentist".to_owned());
|
|
assert_eq!(event.title(), Some("Dentist"));
|
|
}
|
|
|
|
#[test]
|
|
fn recurrence_is_distinguished_from_being_an_override() {
|
|
let mut series = VEvent::new(CalendarDateTime::Date {
|
|
date: date(2026, 12, 25),
|
|
});
|
|
series.rrule = Some("FREQ=WEEKLY;BYDAY=FR".to_owned());
|
|
|
|
let mut exception = VEvent::with_uid(
|
|
&series.uid,
|
|
CalendarDateTime::Date {
|
|
date: date(2027, 1, 1),
|
|
},
|
|
);
|
|
exception.recurrence_id = Some(CalendarDateTime::Date {
|
|
date: date(2027, 1, 1),
|
|
});
|
|
|
|
assert!(series.is_recurring() && !series.is_override());
|
|
assert!(exception.is_override() && !exception.is_recurring());
|
|
assert_eq!(
|
|
series.uid, exception.uid,
|
|
"an override shares the UID of the series it modifies; the last \
|
|
iteration instead minted synthetic UIDs and then tried to recover the \
|
|
original by splitting on the final hyphen",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_fully_populated_event_survives_a_json_round_trip() {
|
|
let mut event = VEvent::new(CalendarDateTime::Zoned {
|
|
local: naive(2026, 12, 25, 9, 0),
|
|
tzid: denver(),
|
|
})
|
|
.titled("Quarterly review")
|
|
.lasting(IcalDuration::hours(1).unwrap());
|
|
|
|
event.description = Some("Bring the numbers.\nAnd coffee.".to_owned());
|
|
event.location = Some("Room 3".to_owned());
|
|
event.status = Some(EventStatus::Tentative);
|
|
event.class = Some(EventClass::Private);
|
|
event.transparency = Some(Transparency::Transparent);
|
|
event.priority = Some(Priority::new(2).unwrap());
|
|
event.organizer = Some(CalendarUser::new("mailto:me@example.com"));
|
|
event.attendees = vec![Attendee {
|
|
common_name: Some("Alex".to_owned()),
|
|
role: Some(Role::ReqParticipant),
|
|
participation_status: Some(ParticipationStatus::Accepted),
|
|
rsvp: Some(true),
|
|
..Attendee::new("mailto:alex@example.com")
|
|
}];
|
|
event.categories = vec!["work".to_owned(), "finance".to_owned()];
|
|
event.geo = Some(GeoPosition {
|
|
latitude: 39.7392,
|
|
longitude: -104.9903,
|
|
});
|
|
event.rrule = Some("FREQ=MONTHLY;BYDAY=1FR".to_owned());
|
|
event.exdate = vec![CalendarDateTime::Zoned {
|
|
local: naive(2027, 2, 5, 9, 0),
|
|
tzid: denver(),
|
|
}];
|
|
event.sequence = 3;
|
|
event.alarms = vec![VAlarm::display_before(
|
|
IcalDuration::minutes(-15).unwrap(),
|
|
"Quarterly review",
|
|
)];
|
|
|
|
let encoded = serde_json::to_string(&event).unwrap();
|
|
let decoded: VEvent = serde_json::from_str(&encoded).unwrap();
|
|
|
|
assert_eq!(event, decoded);
|
|
}
|
|
|
|
#[test]
|
|
fn absent_fields_stay_out_of_the_wire_format() {
|
|
let event = VEvent::with_uid(
|
|
"abc",
|
|
CalendarDateTime::Date {
|
|
date: date(2026, 12, 25),
|
|
},
|
|
);
|
|
|
|
let encoded = serde_json::to_value(&event).unwrap();
|
|
// serde_json orders object keys, so compare as a sorted set.
|
|
let mut keys: Vec<&str> = encoded
|
|
.as_object()
|
|
.unwrap()
|
|
.keys()
|
|
.map(String::as_str)
|
|
.collect();
|
|
keys.sort_unstable();
|
|
|
|
assert_eq!(
|
|
keys,
|
|
vec![
|
|
"created",
|
|
"dtstamp",
|
|
"dtstart",
|
|
"last_modified",
|
|
"sequence",
|
|
"uid"
|
|
],
|
|
"an empty event should not serialise a dozen nulls and empty arrays",
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ priority
|
|
|
|
#[test]
|
|
fn priority_outside_the_permitted_range_is_rejected() {
|
|
assert!(Priority::new(0).is_ok());
|
|
assert!(Priority::new(9).is_ok());
|
|
assert!(Priority::new(10).is_err());
|
|
assert!(Priority::new(255).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn an_out_of_range_priority_is_rejected_at_the_api_boundary_too() {
|
|
let result: Result<Priority, _> = serde_json::from_value(json!(10));
|
|
|
|
assert!(
|
|
result.is_err(),
|
|
"validation must hold on deserialisation, not just on construction, \
|
|
or invalid values enter through the API and bypass the type",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn priority_bands_follow_the_rfc() {
|
|
let band = |n: u8| Priority::new(n).unwrap().band();
|
|
|
|
assert_eq!(band(0), PriorityBand::Undefined);
|
|
assert_eq!(band(1), PriorityBand::High);
|
|
assert_eq!(band(4), PriorityBand::High);
|
|
assert_eq!(band(5), PriorityBand::Normal);
|
|
assert_eq!(band(6), PriorityBand::Low);
|
|
assert_eq!(band(9), PriorityBand::Low);
|
|
}
|
|
|
|
// -------------------------------------------------------------------- alarms
|
|
|
|
#[test]
|
|
fn a_reminder_before_the_event_carries_a_negative_offset() {
|
|
let alarm = VAlarm::display_before(IcalDuration::minutes(-15).unwrap(), "Stand-up");
|
|
|
|
match alarm.trigger {
|
|
AlarmTrigger::Relative { offset, related } => {
|
|
assert!(
|
|
offset.as_time_delta() < TimeDelta::zero(),
|
|
"RFC 5545 expresses 'before' as a negative offset; the sign \
|
|
convention is easy to invert and fires alarms too late",
|
|
);
|
|
assert_eq!(related, TriggerRelation::Start);
|
|
}
|
|
AlarmTrigger::Absolute { .. } => panic!("expected a relative trigger"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn alarm_triggers_round_trip_in_both_forms() {
|
|
let relative = AlarmTrigger::Relative {
|
|
offset: IcalDuration::minutes(-10).unwrap(),
|
|
related: TriggerRelation::End,
|
|
};
|
|
let absolute = AlarmTrigger::Absolute {
|
|
at: utc(2026, 12, 25, 8, 45),
|
|
};
|
|
|
|
for trigger in [relative, absolute] {
|
|
let encoded = serde_json::to_string(&trigger).unwrap();
|
|
let decoded: AlarmTrigger = serde_json::from_str(&encoded).unwrap();
|
|
assert_eq!(trigger, decoded);
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------- objects
|
|
|
|
fn series_with_one_override() -> CalendarObject {
|
|
let mut master = VEvent::with_uid(
|
|
"shared-uid",
|
|
CalendarDateTime::Zoned {
|
|
local: naive(2026, 12, 4, 9, 0),
|
|
tzid: denver(),
|
|
},
|
|
);
|
|
master.rrule = Some("FREQ=WEEKLY;BYDAY=FR".to_owned());
|
|
|
|
let mut moved = VEvent::with_uid(
|
|
"shared-uid",
|
|
CalendarDateTime::Zoned {
|
|
local: naive(2026, 12, 11, 14, 0),
|
|
tzid: denver(),
|
|
},
|
|
);
|
|
moved.recurrence_id = Some(CalendarDateTime::Zoned {
|
|
local: naive(2026, 12, 11, 9, 0),
|
|
tzid: denver(),
|
|
});
|
|
|
|
CalendarObject {
|
|
href: "/calendars/connor/personal/shared-uid.ics".to_owned(),
|
|
etag: Some("\"abc123\"".to_owned()),
|
|
calendar_path: "/calendars/connor/personal/".to_owned(),
|
|
calendar: VCalendar::with_events(vec![master, moved]),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_resource_separates_its_master_from_its_overrides() {
|
|
let object = series_with_one_override();
|
|
|
|
assert!(object.master().is_some());
|
|
assert_eq!(object.overrides().count(), 1);
|
|
assert_eq!(
|
|
object.events().len(),
|
|
2,
|
|
"a master and its override are two VEVENTs in one resource, not a \
|
|
duplicate to be deduplicated away",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn every_event_in_a_resource_shares_one_uid() {
|
|
let object = series_with_one_override();
|
|
|
|
assert_eq!(object.uid(), Some("shared-uid"));
|
|
assert!(object.has_consistent_uid());
|
|
}
|
|
|
|
#[test]
|
|
fn a_resource_with_mismatched_uids_is_detected() {
|
|
let mut object = series_with_one_override();
|
|
object.calendar.events[1].uid = "different".to_owned();
|
|
|
|
assert!(
|
|
!object.has_consistent_uid(),
|
|
"RFC 4791 requires one UID per resource; detecting a violation beats \
|
|
guessing which event was meant",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_resource_holding_only_overrides_has_no_master() {
|
|
let mut object = series_with_one_override();
|
|
object.calendar.events.retain(VEvent::is_override);
|
|
|
|
assert!(object.master().is_none());
|
|
assert_eq!(object.overrides().count(), 1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- edit scope
|
|
|
|
#[test]
|
|
fn edit_scope_uses_a_stable_wire_representation() {
|
|
let cases = [
|
|
(EditScope::ThisOnly, json!("this_only")),
|
|
(EditScope::ThisAndFuture, json!("this_and_future")),
|
|
(EditScope::EntireSeries, json!("entire_series")),
|
|
];
|
|
|
|
for (scope, expected) in cases {
|
|
assert_eq!(serde_json::to_value(scope).unwrap(), expected);
|
|
assert_eq!(
|
|
serde_json::from_value::<EditScope>(expected).unwrap(),
|
|
scope
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_unknown_edit_scope_is_rejected_rather_than_defaulted() {
|
|
let result: Result<EditScope, _> = serde_json::from_value(json!("delete_everything"));
|
|
|
|
assert!(
|
|
result.is_err(),
|
|
"the last iteration matched these as strings with a catch-all arm, so \
|
|
a typo silently fell through to the wrong behaviour",
|
|
);
|
|
}
|