Files
runway/crates/runway-core/tests/model.rs
T
connor 7043a151f6 Add the iCalendar round-trip
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.
2026-08-26 14:32:38 -04:00

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",
);
}