Add the RFC 5545 domain model
VEvent and friends, transcribed from the previous calendar-models crate and tightened so that the states which caused its timezone and recurrence bugs cannot be represented. The substantive changes from v1: CalendarDateTime is an enum over the four forms RFC 5545 actually admits (date, floating, UTC, zoned) instead of a NaiveDateTime plus a loose Option<String> zone plus an all_day flag that could all disagree. A zoned value carries an IANA identifier, never a UTC offset -- an offset cannot tell standard time from daylight time, which is why recurring events drifted an hour across DST. EventEnd is an enum, because DTEND and DURATION are mutually exclusive. Priority validates 0-9 on construction and on deserialisation. EditScope replaces dispatch on strings like "this_and_future", which appeared 53 times and turned typos into silent fallthrough. CalendarObject models a CalDAV resource as it really is: one UID, one master, N RECURRENCE-ID overrides. v1 flattened this to a bare event list, which made overrides look like duplicates and motivated ~500 lines of title-matching heuristics that silently discarded events. Dropped VJournal, VFreeBusy, VTimeZone, TodoStatus, FreeBusyType and Period: defined but never used. 28 tests cover serde round-trips, the exact JSON shape (the model is the wire format, so changing it should be deliberate), duration fallbacks, validation boundaries and master/override separation. uuid needs an explicit entropy source on wasm; without it the frontend cannot compile the shared model. Verified that the default feature set pulls in neither icalendar, rrule, chrono-tz, quick-xml nor reqwest.
This commit is contained in:
@@ -0,0 +1,521 @@
|
||||
//! 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(),
|
||||
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.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.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",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user