//! The events API, end to end. //! //! Through the real router, against a real CalDAV server. This is where M3 //! through M7 meet: the client fetches, the recurrence engine expands, the auth //! layer supplies the credential, and the series rules decide what a scoped //! edit writes. Each of those is unit-tested on its own; these check that the //! seams between them hold. //! //! Needs a server: `crates/runway-caldav/tests/baikal/run.sh`. #![allow(clippy::unwrap_used, clippy::expect_used)] use axum::body::Body; use axum::http::{Method, Request, StatusCode, header}; use runway_caldav::{CalDavClient, Credentials}; use runway_server::auth::SESSION_COOKIE; use runway_server::db::Database; use runway_server::{AppState, Config, router}; use serde_json::{Value, json}; use tower::ServiceExt; const TZ: &str = "America/Denver"; /// A signed-in client with a scratch calendar of its own. struct Api { state: AppState, token: String, calendar: String, } impl Api { /// `None` when no CalDAV server is configured. async fn start(name: &str) -> Option { let (url, user, password) = server()?; let db = Database::in_memory().await.unwrap(); let state = AppState::with_database(db, Config::for_tests()); let (_, token) = state .auth .login_with_caldav(&url, &user, &password, None) .await .expect("could not log in to the test server"); // A collection per test, so no test can see another's leftovers. let calendar = format!("/dav.php/calendars/{user}/api-{name}/"); let admin = CalDavClient::new(&url, Credentials::new(&user, password)).unwrap(); let _ = admin.delete_calendar(&calendar).await; admin .create_calendar(&calendar, &format!("API test {name}"), None) .await .unwrap(); Some(Self { state, token, calendar, }) } async fn cleanup(&self) { let (url, user, password) = server().unwrap(); let admin = CalDavClient::new(&url, Credentials::new(&user, password)).unwrap(); let _ = admin.delete_calendar(&self.calendar).await; } async fn send(&self, method: Method, uri: &str, body: Option) -> Reply { let mut builder = Request::builder() .method(method) .uri(uri) .header(header::COOKIE, format!("{SESSION_COOKIE}={}", self.token)); if body.is_some() { builder = builder.header(header::CONTENT_TYPE, "application/json"); } let request = builder .body(body.map_or_else(Body::empty, |value| Body::from(value.to_string()))) .unwrap(); let response = router(self.state.clone()).oneshot(request).await.unwrap(); let status = response.status(); let bytes = axum::body::to_bytes(response.into_body(), 1 << 22) .await .unwrap(); let text = String::from_utf8_lossy(&bytes).into_owned(); Reply { status, json: serde_json::from_str(&text).ok(), text, } } async fn events(&self, from: &str, to: &str) -> Vec { let reply = self .send( Method::GET, &format!("/api/events?from={from}&to={to}&tz={TZ}"), None, ) .await; assert_eq!(reply.status, StatusCode::OK, "{}", reply.text); reply.json.unwrap()["occurrences"] .as_array() .cloned() .unwrap_or_default() } /// Local start times and titles in January, as the API reports them. async fn january(&self) -> Vec { self.events("2026-01-01", "2026-03-01") .await .iter() .map(|o| { format!( "{} {}", o["start"]["local"].as_str().unwrap_or_default(), o["event"]["summary"].as_str().unwrap_or("(untitled)"), ) }) .collect() } async fn create(&self, event: Value) -> Value { let reply = self .send( Method::POST, "/api/events", Some(json!({ "calendar_href": self.calendar, "event": event })), ) .await; assert_eq!(reply.status, StatusCode::OK, "{}", reply.text); reply.json.unwrap() } } struct Reply { status: StatusCode, json: Option, text: String, } impl Reply { fn code(&self) -> Option<&str> { self.json.as_ref()?.get("code")?.as_str() } } fn server() -> Option<(String, String, 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()?, )) })(); assert!( details.is_some() || !std::env::var("RUNWAY_REQUIRE_CALDAV").is_ok_and(|v| v == "1"), "RUNWAY_REQUIRE_CALDAV is set but no server is configured", ); details } fn zoned(day: u32, hour: u32) -> Value { json!({ "kind": "zoned", "local": format!("2026-01-{day:02}T{hour:02}:00:00"), "tzid": TZ, }) } /// A weekly Monday 09:00 series, six occurrences. fn weekly() -> Value { json!({ "uid": "ignored", "dtstamp": "2026-01-01T00:00:00Z", "dtstart": zoned(5, 9), "end": { "kind": "date_time", "dtend": zoned(5, 10) }, "summary": "Standup", "rrule": "FREQ=WEEKLY;BYDAY=MO;COUNT=6", }) } /// The same occurrence moved to the afternoon. fn moved(day: u32) -> Value { json!({ "uid": "ignored", "dtstamp": "2026-01-01T00:00:00Z", "dtstart": zoned(day, 14), "end": { "kind": "date_time", "dtend": zoned(day, 15) }, "summary": "Standup (moved)", }) } macro_rules! api_test { ($name:ident, |$api:ident| $body:block) => { #[tokio::test] async fn $name() { let Some($api) = Api::start(stringify!($name)).await else { eprintln!("SKIPPED: no CalDAV server configured"); return; }; $body $api.cleanup().await; } }; } // ------------------------------------------------------------------- reading -- api_test!(an_event_written_through_the_api_comes_back_from_it, |api| { api.create(json!({ "uid": "ignored", "dtstamp": "2026-01-01T00:00:00Z", "dtstart": zoned(5, 9), "end": { "kind": "date_time", "dtend": zoned(5, 10) }, "summary": "Dentist", })) .await; let events = api.events("2026-01-01", "2026-02-01").await; assert_eq!(events.len(), 1); assert_eq!(events[0]["event"]["summary"], json!("Dentist")); assert_eq!( events[0]["start"]["tzid"], json!(TZ), "the zone survives the whole round trip, which is what the phone reads", ); assert!( events[0]["etag"].is_string(), "every occurrence carries the ETag needed to edit it safely", ); assert!(events[0]["href"].is_string()); }); api_test!(a_series_is_expanded_into_its_occurrences, |api| { api.create(weekly()).await; assert_eq!( api.january().await, vec![ "2026-01-05T09:00:00 Standup", "2026-01-12T09:00:00 Standup", "2026-01-19T09:00:00 Standup", "2026-01-26T09:00:00 Standup", "2026-02-02T09:00:00 Standup", "2026-02-09T09:00:00 Standup", ], "the client receives discrete occurrences; v1 shipped 650 lines of \ hand-rolled expansion into the browser instead", ); }); api_test!(a_range_returns_only_what_falls_inside_it, |api| { api.create(weekly()).await; let january = api.events("2026-01-05", "2026-01-13").await; assert_eq!(january.len(), 2, "the 5th and the 12th"); }); api_test!(a_backwards_range_is_refused, |api| { let reply = api .send( Method::GET, "/api/events?from=2026-02-01&to=2026-01-01", None, ) .await; assert_eq!(reply.status, StatusCode::BAD_REQUEST); assert_eq!(reply.code(), Some("bad_request")); }); api_test!(an_unknown_time_zone_is_refused_rather_than_guessed, |api| { let reply = api .send( Method::GET, "/api/events?from=2026-01-01&to=2026-02-01&tz=Mars/Olympus_Mons", None, ) .await; assert_eq!(reply.status, StatusCode::BAD_REQUEST); }); // ------------------------------------------------------------------- writing -- api_test!(creating_an_event_mints_its_uid_on_the_server, |api| { let created = api .create(json!({ "uid": "a-uid-the-client-chose", "dtstamp": "2026-01-01T00:00:00Z", "dtstart": zoned(5, 9), "summary": "Whatever", })) .await; let href = created["href"].as_str().unwrap(); assert!( !href.contains("a-uid-the-client-chose"), "a client-supplied UID could collide with, and overwrite, an unrelated \ event: {href}", ); assert!(created["etag"].is_string()); }); api_test!(editing_the_whole_series_changes_every_occurrence, |api| { api.create(weekly()).await; let first = api.events("2026-01-01", "2026-03-01").await[0].clone(); let mut renamed = weekly(); renamed["summary"] = json!("Standup (renamed)"); let reply = api .send( Method::PUT, "/api/events", Some(json!({ "calendar_href": api.calendar, "href": first["href"], "etag": first["etag"], "scope": "entire_series", "event": renamed, })), ) .await; assert_eq!(reply.status, StatusCode::OK, "{}", reply.text); let after = api.january().await; assert_eq!(after.len(), 6); assert!(after.iter().all(|s| s.contains("renamed")), "{after:?}"); }); api_test!(editing_one_occurrence_leaves_the_others_alone, |api| { api.create(weekly()).await; let occurrences = api.events("2026-01-01", "2026-03-01").await; let second = occurrences[1].clone(); let reply = api .send( Method::PUT, "/api/events", Some(json!({ "calendar_href": api.calendar, "href": second["href"], "etag": second["etag"], "scope": "this_only", "recurrence_id": second["recurrence_id"], "event": moved(12), })), ) .await; assert_eq!(reply.status, StatusCode::OK, "{}", reply.text); assert_eq!( api.january().await, vec![ "2026-01-05T09:00:00 Standup", "2026-01-12T14:00:00 Standup (moved)", "2026-01-19T09:00:00 Standup", "2026-01-26T09:00:00 Standup", "2026-02-02T09:00:00 Standup", "2026-02-09T09:00:00 Standup", ], "six occurrences with one moved -- not seven, which is what reading the \ master and its override as unrelated events would produce", ); }); api_test!( this_and_future_splits_the_series_into_two_resources, |api| { api.create(weekly()).await; let occurrences = api.events("2026-01-01", "2026-03-01").await; let third = occurrences[2].clone(); let reply = api .send( Method::PUT, "/api/events", Some(json!({ "calendar_href": api.calendar, "href": third["href"], "etag": third["etag"], "scope": "this_and_future", "recurrence_id": third["recurrence_id"], "event": moved(19), })), ) .await; assert_eq!(reply.status, StatusCode::OK, "{}", reply.text); let body = reply.json.unwrap(); let new_href = body["new_series_href"].as_str().expect("a second resource"); assert_ne!(new_href, body["href"].as_str().unwrap()); assert_eq!( api.january().await, vec![ "2026-01-05T09:00:00 Standup", "2026-01-12T09:00:00 Standup", "2026-01-19T14:00:00 Standup (moved)", "2026-01-26T14:00:00 Standup (moved)", "2026-02-02T14:00:00 Standup (moved)", "2026-02-09T14:00:00 Standup (moved)", ], "the old series stops at the split and the new one takes over -- six in \ total across both halves, as originally asked for, and nothing \ appearing twice on the 19th", ); } ); // ------------------------------------------------------------------ deleting -- api_test!(deleting_one_occurrence_leaves_the_rest, |api| { api.create(weekly()).await; let occurrences = api.events("2026-01-01", "2026-03-01").await; let second = occurrences[1].clone(); let reply = api .send( Method::DELETE, "/api/events", Some(json!({ "calendar_href": api.calendar, "href": second["href"], "etag": second["etag"], "scope": "this_only", "recurrence_id": second["recurrence_id"], })), ) .await; assert_eq!(reply.status, StatusCode::OK, "{}", reply.text); assert_eq!( reply.json.unwrap()["removed"], json!(false), "the resource stays; only one occurrence went", ); let after = api.january().await; assert_eq!(after.len(), 5); assert!(!after.iter().any(|s| s.contains("2026-01-12")), "{after:?}"); }); api_test!(deleting_the_entire_series_removes_the_resource, |api| { api.create(weekly()).await; let first = api.events("2026-01-01", "2026-03-01").await[0].clone(); let reply = api .send( Method::DELETE, "/api/events", Some(json!({ "calendar_href": api.calendar, "href": first["href"], "etag": first["etag"], "scope": "entire_series", })), ) .await; assert_eq!(reply.status, StatusCode::OK, "{}", reply.text); assert_eq!(reply.json.unwrap()["removed"], json!(true)); assert!(api.january().await.is_empty()); }); api_test!(deleting_this_and_future_truncates_the_series, |api| { api.create(weekly()).await; let occurrences = api.events("2026-01-01", "2026-03-01").await; let third = occurrences[2].clone(); api.send( Method::DELETE, "/api/events", Some(json!({ "calendar_href": api.calendar, "href": third["href"], "etag": third["etag"], "scope": "this_and_future", "recurrence_id": third["recurrence_id"], })), ) .await; assert_eq!( api.january().await, vec!["2026-01-05T09:00:00 Standup", "2026-01-12T09:00:00 Standup"], ); }); // ----------------------------------------------------------------- conflicts -- api_test!(a_stale_etag_is_refused_rather_than_overwriting, |api| { api.create(weekly()).await; let stale = api.events("2026-01-01", "2026-03-01").await[0].clone(); // Somebody else saves first. let mut renamed = weekly(); renamed["summary"] = json!("Someone else's edit"); let first = api .send( Method::PUT, "/api/events", Some(json!({ "calendar_href": api.calendar, "href": stale["href"], "etag": stale["etag"], "scope": "entire_series", "event": renamed, })), ) .await; assert_eq!(first.status, StatusCode::OK, "{}", first.text); // Then the edit built on the older read arrives. let mut mine = weekly(); mine["summary"] = json!("My edit"); let second = api .send( Method::PUT, "/api/events", Some(json!({ "calendar_href": api.calendar, "href": stale["href"], "etag": stale["etag"], "scope": "entire_series", "event": mine, })), ) .await; assert_eq!(second.status, StatusCode::CONFLICT, "{}", second.text); assert_eq!(second.code(), Some("conflict")); assert!( api.january().await[0].contains("Someone else's edit"), "and the refused write must not have landed", ); }); api_test!(an_edit_without_an_etag_is_refused, |api| { api.create(weekly()).await; let first = api.events("2026-01-01", "2026-03-01").await[0].clone(); let reply = api .send( Method::PUT, "/api/events", Some(json!({ "calendar_href": api.calendar, "href": first["href"], "scope": "entire_series", "event": weekly(), })), ) .await; assert_eq!( reply.status, StatusCode::UNPROCESSABLE_ENTITY, "every write states a precondition; there is no unconditional path \ through this API: {}", reply.text, ); }); api_test!(a_scoped_edit_without_a_recurrence_id_is_refused, |api| { api.create(weekly()).await; let first = api.events("2026-01-01", "2026-03-01").await[0].clone(); let reply = api .send( Method::PUT, "/api/events", Some(json!({ "calendar_href": api.calendar, "href": first["href"], "etag": first["etag"], "scope": "this_only", "event": moved(12), })), ) .await; assert_eq!(reply.status, StatusCode::BAD_REQUEST); assert!(reply.text.contains("RECURRENCE-ID"), "{}", reply.text); }); // ------------------------------------------------------------------- access -- api_test!(the_events_api_needs_a_session, |api| { let request = Request::get("/api/events?from=2026-01-01&to=2026-02-01") .body(Body::empty()) .unwrap(); let response = router(api.state.clone()).oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); });