//! End-to-end tests against a real CalDAV server. //! //! Not mocks. A mock encodes what we already believe the protocol does, which //! is precisely the belief worth checking — the previous iteration's //! integration suite duplicated the router instead of importing it, and rotted //! until it no longer compiled. //! //! These are skipped unless a server is configured, so `cargo test` works //! offline. To run them: //! //! ```sh //! crates/runway-caldav/tests/baikal/run.sh //! ``` //! //! which starts a throwaway Baikal in a container, installs it, and sets the //! three variables below. Point them at any RFC-compliant server to test //! against that instead. **Every test creates its own calendar collection and //! deletes it afterwards**, so nothing touches data that was already there. #![allow(clippy::unwrap_used, clippy::expect_used)] use chrono::{TimeZone, Utc}; use runway_caldav::{CalDavClient, CalDavError, Credentials, Precondition, href_for}; use runway_core::ical; use runway_core::model::{CalendarDateTime, TzId, VCalendar, VEvent}; /// A client, or `None` when no server is configured. /// /// A skipped test still reports "ok", which is the kind of quiet no-op this /// project exists to stop shipping. `RUNWAY_REQUIRE_CALDAV=1` -- which /// `run.sh` sets -- turns "no server" into a failure, so in the one place /// these are meant to run, not running is loud. fn client() -> Option<(CalDavClient, String)> { let details = (|| { Some(( std::env::var("RUNWAY_CALDAV_URL").ok()?, std::env::var("RUNWAY_CALDAV_USER").ok()?, std::env::var("RUNWAY_CALDAV_PASSWORD").ok()?, )) })(); let required = std::env::var("RUNWAY_REQUIRE_CALDAV").is_ok_and(|v| v == "1"); assert!( details.is_some() || !required, "RUNWAY_REQUIRE_CALDAV is set but no server is configured", ); let (url, user, password) = details?; let client = CalDavClient::new(&url, Credentials::new(&user, password)) .expect("the configured CalDAV URL is not valid"); Some((client, user)) } /// Runs a test body against a scratch calendar, removing it afterwards. /// /// The calendar is created and destroyed per test so the tests cannot see each /// other's leftovers, and so a failure never leaves rubbish behind on a real /// server. async fn with_calendar(name: &str, body: F) where F: FnOnce(CalDavClient, String) -> Fut, Fut: Future, { let Some((client, user)) = client() else { eprintln!( "SKIPPED: no CalDAV server configured. Run \ crates/runway-caldav/tests/baikal/run.sh to run these against a container." ); return; }; let href = format!("/dav.php/calendars/{user}/{name}/"); // A previous run that died mid-test would leave this behind. let _ = client.delete_calendar(&href).await; client .create_calendar(&href, &format!("Runway test {name}"), Some("#336699")) .await .expect("could not create the scratch calendar"); body(client.clone(), href.clone()).await; client .delete_calendar(&href) .await .expect("could not remove the scratch calendar"); } fn event(uid: &str, summary: &str, hour: u32) -> VEvent { let start = CalendarDateTime::Zoned { local: chrono::NaiveDate::from_ymd_opt(2026, 3, 10) .unwrap() .and_hms_opt(hour, 0, 0) .unwrap(), tzid: TzId::new("America/Denver").unwrap(), }; VEvent::with_uid(uid, start) .titled(summary) .lasting(runway_core::model::IcalDuration::hours(1).unwrap()) } fn calendar_of(event: VEvent) -> VCalendar { VCalendar::with_events(vec![event]) } // --------------------------------------------------------------- discovery -- #[tokio::test] async fn discovery_finds_the_scratch_calendar() { with_calendar("discovery", |client, href| async move { let principal = client.current_user_principal().await.unwrap(); assert!(principal.contains("principals"), "got {principal}"); let home = client.calendar_home(&principal).await.unwrap(); let calendars = client.calendars_in(&home).await.unwrap(); let found = calendars .iter() .find(|c| c.href.trim_end_matches('/') == href.trim_end_matches('/')) .expect("the calendar just created was not listed"); assert_eq!(found.display_name.as_deref(), Some("Runway test discovery")); assert!(found.supports_events()); assert!( !calendars.iter().any(|c| c.href.contains("outbox")), "scheduling collections must not appear as calendars", ); }) .await; } // ------------------------------------------------------------------- writes -- #[tokio::test] async fn an_event_survives_a_round_trip_through_the_server() { with_calendar("roundtrip", |client, calendar| async move { let uid = "runway-roundtrip@test"; let href = href_for(&calendar, uid); let mut original = event(uid, "Round trip", 9); original.description = Some("Two lines\nand a comma, kept".to_owned()); original.categories = vec!["Work".to_owned(), "Personal".to_owned()]; client .put_object(&href, &calendar_of(original.clone()), &Precondition::New) .await .unwrap(); let fetched = client.get_object(&calendar, &href).await.unwrap(); let stored = &fetched.events()[0]; assert_eq!(stored.uid, original.uid); assert_eq!(stored.summary, original.summary); assert_eq!(stored.description, original.description); assert_eq!( stored.categories, vec!["Work", "Personal"], "the separator must survive the server, not come back as one \ category called \"Work,Personal\"", ); assert_eq!( stored.dtstart.tzid().map(TzId::as_str), Some("America/Denver"), "the zone has to make it to the server -- this is what the phone \ reads when it decides when to ring", ); }) .await; } #[tokio::test] async fn a_stale_etag_is_refused_rather_than_overwriting() { with_calendar("conflict", |client, calendar| async move { let uid = "runway-conflict@test"; let href = href_for(&calendar, uid); client .put_object( &href, &calendar_of(event(uid, "First", 9)), &Precondition::New, ) .await .unwrap(); // Read it, then let somebody else write. let first = client.get_object(&calendar, &href).await.unwrap(); let stale = first.etag.clone().expect("Baikal returns an ETag"); client .put_object( &href, &calendar_of(event(uid, "Someone else's edit", 10)), &Precondition::Force, ) .await .unwrap(); // Now try to save the edit built on the stale read. let result = client .put_object( &href, &calendar_of(event(uid, "My edit", 11)), &Precondition::Unchanged(stale), ) .await; assert!( matches!(result, Err(CalDavError::Conflict { .. })), "expected a conflict, got {result:?} -- without this the second \ person to hit save silently destroys the first person's change", ); let current = client.get_object(&calendar, &href).await.unwrap(); assert_eq!( current.events()[0].summary.as_deref(), Some("Someone else's edit"), "and the refused write must not have landed", ); }) .await; } #[tokio::test] async fn creating_the_same_resource_twice_is_refused() { with_calendar("create-twice", |client, calendar| async move { let uid = "runway-exists@test"; let href = href_for(&calendar, uid); client .put_object( &href, &calendar_of(event(uid, "First", 9)), &Precondition::New, ) .await .unwrap(); let result = client .put_object( &href, &calendar_of(event(uid, "Second", 9)), &Precondition::New, ) .await; assert!( result.is_err(), "If-None-Match: * must stop a create from clobbering an existing \ resource, got {result:?}", ); }) .await; } #[tokio::test] async fn an_event_can_be_deleted() { with_calendar("delete", |client, calendar| async move { let uid = "runway-delete@test"; let href = href_for(&calendar, uid); client .put_object( &href, &calendar_of(event(uid, "Doomed", 9)), &Precondition::New, ) .await .unwrap(); client .delete_object(&href, &Precondition::Force) .await .unwrap(); assert!( matches!( client.get_object(&calendar, &href).await, Err(CalDavError::NotFound { .. }) ), "a deleted resource should be reported as gone, not as an error \ with no name", ); }) .await; } // -------------------------------------------------------------- time ranges -- #[tokio::test] async fn a_time_range_query_returns_only_what_it_should() { with_calendar("time-range", |client, calendar| async move { for (uid, summary, day) in [ ("in-window@test", "Inside", 10), ("out-of-window@test", "Outside", 25), ] { let start = CalendarDateTime::Zoned { local: chrono::NaiveDate::from_ymd_opt(2026, 3, day) .unwrap() .and_hms_opt(9, 0, 0) .unwrap(), tzid: TzId::new("America/Denver").unwrap(), }; let event = VEvent::with_uid(uid, start) .titled(summary) .lasting(runway_core::model::IcalDuration::hours(1).unwrap()); client .put_object( &href_for(&calendar, uid), &calendar_of(event), &Precondition::New, ) .await .unwrap(); } let found = client .events_in_range( &calendar, Utc.with_ymd_and_hms(2026, 3, 9, 0, 0, 0).unwrap(), Utc.with_ymd_and_hms(2026, 3, 12, 0, 0, 0).unwrap(), ) .await .unwrap(); let summaries: Vec<&str> = found .iter() .filter_map(|o| o.events().first()) .filter_map(|e| e.summary.as_deref()) .collect(); assert_eq!( summaries, vec!["Inside"], "the server filters by time-range; the last iteration fetched every \ event in the calendar on every view change and filtered in the \ browser", ); assert_eq!( client.all_events(&calendar).await.unwrap().len(), 2, "and both are really there", ); }) .await; } #[tokio::test] async fn a_recurring_series_is_returned_whole() { with_calendar("series", |client, calendar| async move { let uid = "runway-series@test"; let mut master = event(uid, "Weekly", 9); master.rrule = Some("FREQ=WEEKLY;BYDAY=TU;COUNT=6".to_owned()); let mut moved = event(uid, "Weekly (moved)", 14); moved.dtstart = CalendarDateTime::Zoned { local: chrono::NaiveDate::from_ymd_opt(2026, 3, 17) .unwrap() .and_hms_opt(14, 0, 0) .unwrap(), tzid: TzId::new("America/Denver").unwrap(), }; moved.recurrence_id = Some(CalendarDateTime::Zoned { local: chrono::NaiveDate::from_ymd_opt(2026, 3, 17) .unwrap() .and_hms_opt(9, 0, 0) .unwrap(), tzid: TzId::new("America/Denver").unwrap(), }); let mut resource = VCalendar::with_events(vec![master, moved]); resource.timezones.clear(); client .put_object(&href_for(&calendar, uid), &resource, &Precondition::New) .await .unwrap(); let found = client .events_in_range( &calendar, Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(), Utc.with_ymd_and_hms(2026, 5, 1, 0, 0, 0).unwrap(), ) .await .unwrap(); assert_eq!( found.len(), 1, "one UID is one resource, however many VEVENTs" ); let object = &found[0]; assert_eq!( object.events().len(), 2, "the master and its override come back together; reading them as \ two unrelated events is what made a correct calendar look like it \ was full of duplicates", ); assert!(object.master().is_some()); assert_eq!(object.overrides().count(), 1); assert!(object.has_consistent_uid()); }) .await; } // -------------------------------------------------------------------- sync -- #[tokio::test] async fn etags_can_be_listed_without_the_bodies() { with_calendar("etags", |client, calendar| async move { for uid in ["one@test", "two@test"] { client .put_object( &href_for(&calendar, uid), &calendar_of(event(uid, "Something", 9)), &Precondition::New, ) .await .unwrap(); } let etags = client.etags(&calendar).await.unwrap(); assert_eq!(etags.len(), 2, "the collection itself must not be listed"); assert!(etags.iter().all(|(_, etag)| !etag.is_empty())); let hrefs: Vec = etags.iter().map(|(href, _)| href.clone()).collect(); let fetched = client.multiget(&calendar, &hrefs).await.unwrap(); assert_eq!(fetched.len(), 2, "and a multiget brings back exactly those"); }) .await; } // --------------------------------------------------------------- rejections -- #[tokio::test] async fn bad_credentials_are_reported_as_such() { let Some((_, user)) = client() else { return; }; let url = std::env::var("RUNWAY_CALDAV_URL").unwrap(); let wrong = CalDavClient::new(&url, Credentials::new(&user, "not-the-password")).unwrap(); assert!( matches!( wrong.current_user_principal().await, Err(CalDavError::Unauthorized) ), "a rejected password has to be distinguishable from a server being down", ); } #[tokio::test] async fn the_golden_corpus_can_be_written_to_a_real_server() { // The strongest statement available about the iCalendar writer: what it // produces is accepted by a real CalDAV server, not merely by our own // parser. Every fixture goes up and comes back. with_calendar("corpus", |client, calendar| async move { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../runway-core/tests/golden/synthetic"); let mut checked = 0; for entry in std::fs::read_dir(&dir).unwrap().filter_map(Result::ok) { let path = entry.path(); if path.extension().is_none_or(|e| e != "ics") { continue; } let source = ical::parse(&std::fs::read_to_string(&path).unwrap()).unwrap(); let Some(uid) = source.events.first().map(|e| e.uid.clone()) else { continue; }; let href = href_for(&calendar, &uid); client .put_object(&href, &source, &Precondition::New) .await .unwrap_or_else(|e| panic!("{} was rejected by the server: {e}", path.display())); let returned = client.get_object(&calendar, &href).await.unwrap(); assert_eq!( returned.calendar.events.len(), source.events.len(), "{} lost events on the server", path.display(), ); checked += 1; } assert!(checked >= 6, "only {checked} fixtures were exercised"); }) .await; }