//! 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.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 = 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::(expected).unwrap(), scope ); } } #[test] fn an_unknown_edit_scope_is_rejected_rather_than_defaulted() { let result: Result = 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", ); }