Add the events API

One path with an EditScope on the write verbs. v1 had a second parallel
tree at /api/calendar/events/series/* -- 1,165 lines mostly duplicating
the non-series handlers, dispatching on string literals in 53 places where
a typo was a runtime fallthrough.

What a scoped edit means to the stored .ics lives in runway-core::series,
pure and tested without a server, because that is the subtle part and v1
shipped it with no coverage at all. Editing one occurrence writes an
override and no EXDATE: an EXDATE says the occurrence does not happen, an
override says it happens differently, and writing both is contradictory.
Deleting one writes the EXDATE and removes any override that named it.

Splitting a series divides its bound rather than dropping it. Six weekly
occurrences split at the third become two plus four, not two plus
forever -- the count is what the person asked for and it should survive
being cut. Overrides after the split move to the new series; moving a
whole series shifts its overrides' RECURRENCE-IDs by the same amount
instead of leaving them pointing at occurrences that no longer exist.

Every write states a precondition. There is no unconditional path: an
update without an ETag is refused, and a stale one is a conflict rather
than a silent overwrite. UIDs are minted server-side, because a
client-supplied one could collide with and replace an unrelated event.

Reads use time-range and fan out across calendars concurrently. Zones that
cannot be resolved are reported in the response instead of being rendered
as though they were fine.

Two tests found real bugs: sub-second timestamps cannot survive
iCalendar's one-second resolution, and splitting at the first occurrence
was dropping the recurrence rule and quietly turning a series into a
single event.
This commit is contained in:
2026-08-26 17:07:38 -04:00
parent c7e22f4431
commit c4e8ede28c
14 changed files with 2336 additions and 4 deletions
+1
View File
@@ -15,6 +15,7 @@ chacha20poly1305 = { workspace = true }
time = "0.3"
rand = { workspace = true }
chrono = { workspace = true }
chrono-tz = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
+7 -1
View File
@@ -8,7 +8,7 @@
use crate::routes;
use crate::state::AppState;
use axum::Router;
use axum::routing::{get, post};
use axum::routing::{delete, get, post, put};
use tower_http::trace::TraceLayer;
pub fn router(state: AppState) -> Router {
@@ -17,6 +17,12 @@ pub fn router(state: AppState) -> Router {
.route("/api/auth/login", post(routes::auth::login))
.route("/api/auth/logout", post(routes::auth::logout))
.route("/api/auth/session", get(routes::auth::current_session))
// One path for events, with an EditScope on the write verbs. v1 had a
// second parallel tree at /api/calendar/events/series/*.
.route("/api/events", get(routes::events::list))
.route("/api/events", post(routes::events::create))
.route("/api/events", put(routes::events::update))
.route("/api/events", delete(routes::events::delete))
.layer(TraceLayer::new_for_http())
.with_state(state)
}
+395
View File
@@ -0,0 +1,395 @@
//! Reading and writing events.
//!
//! One path, `/api/events`, with an [`EditScope`] on the write verbs. v1 had a
//! second parallel tree at `/api/calendar/events/series/*` — 1,165 lines that
//! mostly duplicated the non-series handlers, dispatching on string literals
//! like `"this_and_future"` in 53 places where a typo was a runtime
//! fallthrough rather than a compile error.
//!
//! These handlers are deliberately thin. Deciding what a scoped edit means to
//! the stored `.ics` is [`runway_core::series`], which is pure and tested
//! without a server; expanding a rule is [`runway_core::recurrence`]. What is
//! left here is moving bytes and reporting failures.
use crate::auth::CurrentUser;
use crate::error::ApiError;
use crate::state::AppState;
use axum::Json;
use axum::extract::State;
// axum's own Query goes through serde_urlencoded, which cannot collect a
// repeated key into a Vec -- `?calendars=a&calendars=b` would fail to
// deserialise. This one can.
use axum_extra::extract::Query;
use chrono::{DateTime, NaiveDate, Utc};
use chrono_tz::Tz;
use runway_caldav::{CalDavClient, Precondition, href_for};
use runway_core::model::{
CalendarDateTime, CalendarObject, EditScope, Occurrence, VCalendar, VEvent,
};
use runway_core::recurrence::{Window, Zones, expand, instant_in, unresolved_zones};
use runway_core::series::{DeleteOutcome, EditOutcome, apply_delete, apply_edit};
use serde::{Deserialize, Serialize};
use tokio::task::JoinSet;
// -------------------------------------------------------------------- reading
#[derive(Deserialize)]
pub struct RangeQuery {
/// Inclusive start date.
pub from: NaiveDate,
/// Exclusive end date.
pub to: NaiveDate,
/// Restrict to these calendars. Repeat the parameter for several.
#[serde(default)]
pub calendars: Vec<String>,
/// The zone to interpret the range in and render occurrences for.
///
/// The browser knows this and the stored preference can override it; see
/// [`display_zone`].
pub tz: Option<String>,
}
/// One occurrence, with the CalDAV metadata needed to edit it.
#[derive(Serialize)]
pub struct PlacedOccurrence {
pub calendar_href: String,
/// The resource this came from. Sent back on an edit.
pub href: String,
/// The resource's `ETag`. Sent back on an edit, and the only thing standing
/// between two people saving at once and one of them losing their work.
pub etag: Option<String>,
#[serde(flatten)]
pub occurrence: Occurrence,
}
#[derive(Serialize)]
pub struct EventsResponse {
pub occurrences: Vec<PlacedOccurrence>,
/// Zones a calendar named that could not be identified.
///
/// Surfaced rather than swallowed: times from an unresolved zone may be
/// hours out, and the alternative is showing them as though they were fine.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
/// `GET /api/events?from&to&calendars&tz`
///
/// Asks the CalDAV server for the objects touching the range — a `time-range`
/// filter, which v1 never used — then expands them here.
pub async fn list(
State(state): State<AppState>,
CurrentUser { user, .. }: CurrentUser,
Query(query): Query<RangeQuery>,
) -> Result<Json<EventsResponse>, ApiError> {
if query.to <= query.from {
return Err(ApiError::BadRequest(
"the end of the range must be after its start".to_owned(),
));
}
let tz = display_zone(&state, &user, query.tz.as_deref()).await?;
let zones = Zones::new(tz);
let window = Window::new(
instant_in(tz, midnight(query.from)),
instant_in(tz, midnight(query.to)),
);
let client = state.auth.caldav_for(&user).await?;
let calendars: Vec<String> = client
.discover()
.await?
.into_iter()
.filter(|c| c.supports_events())
.filter(|c| query.calendars.is_empty() || query.calendars.contains(&c.href))
.map(|c| c.href)
.collect();
if calendars.is_empty() {
return Err(ApiError::NotFound("no matching calendar".to_owned()));
}
// Concurrently, because a week view over six calendars is six round trips
// and doing them in turn is the difference between fast and sluggish.
let mut fetches = JoinSet::new();
for href in calendars {
let client: CalDavClient = client.clone();
fetches.spawn(async move {
let objects = client.events_in_range(&href, window.from, window.to).await;
(href, objects)
});
}
let mut occurrences = Vec::new();
let mut warnings = Vec::new();
while let Some(joined) = fetches.join_next().await {
let (href, objects) =
joined.map_err(|error| ApiError::Internal(format!("fetch task failed: {error}")))?;
for object in objects? {
for zone in unresolved_zones(&object.calendar, zones) {
warnings.push(format!("{} names an unknown time zone {zone}", object.href));
}
for occurrence in expand(&object.calendar, window, zones)
.map_err(|error| ApiError::Upstream(error.to_string()))?
{
occurrences.push(PlacedOccurrence {
calendar_href: href.clone(),
href: object.href.clone(),
etag: object.etag.clone(),
occurrence,
});
}
}
}
// Deterministic order, so a re-fetch does not reshuffle the view.
occurrences.sort_by(|a, b| {
a.occurrence
.start_utc
.cmp(&b.occurrence.start_utc)
.then_with(|| a.href.cmp(&b.href))
});
warnings.sort();
warnings.dedup();
Ok(Json(EventsResponse {
occurrences,
warnings,
}))
}
// -------------------------------------------------------------------- writing
#[derive(Deserialize)]
pub struct CreateRequest {
pub calendar_href: String,
pub event: VEvent,
}
#[derive(Serialize)]
pub struct WriteResponse {
pub href: String,
pub etag: Option<String>,
/// Present when a "this and future" edit started a second series.
#[serde(skip_serializing_if = "Option::is_none")]
pub new_series_href: Option<String>,
}
/// `POST /api/events`
pub async fn create(
State(state): State<AppState>,
CurrentUser { user, .. }: CurrentUser,
Json(request): Json<CreateRequest>,
) -> Result<Json<WriteResponse>, ApiError> {
let client = state.auth.caldav_for(&user).await?;
// The UID is minted here. A client-supplied one could collide with, and
// therefore overwrite, an unrelated event.
let mut event = request.event;
event.uid = uuid::Uuid::new_v4().to_string();
event.recurrence_id = None;
event.sequence = 0;
let href = href_for(&request.calendar_href, &event.uid);
let calendar = VCalendar::with_events(vec![event]);
// If-None-Match: *, so a collision is refused rather than silently
// replacing whatever was there.
let etag = client
.put_object(&href, &calendar, &Precondition::New)
.await?;
Ok(Json(WriteResponse {
href,
etag,
new_series_href: None,
}))
}
#[derive(Deserialize)]
pub struct UpdateRequest {
pub calendar_href: String,
pub href: String,
/// The `ETag` the client last saw. Required.
pub etag: String,
#[serde(default)]
pub scope: EditScope,
/// Which occurrence, for a scoped edit.
pub recurrence_id: Option<CalendarDateTime>,
pub event: VEvent,
}
/// `PUT /api/events`
pub async fn update(
State(state): State<AppState>,
CurrentUser { user, .. }: CurrentUser,
Json(request): Json<UpdateRequest>,
) -> Result<Json<WriteResponse>, ApiError> {
let client = state.auth.caldav_for(&user).await?;
let zones = Zones::new(display_zone(&state, &user, None).await?);
let current = fetch(&client, &request.calendar_href, &request.href).await?;
let outcome = apply_edit(
&current.calendar,
request.scope,
request.recurrence_id.as_ref(),
&request.event,
zones,
)
.map_err(|error| ApiError::BadRequest(error.to_string()))?;
match outcome {
EditOutcome::Replace(calendar) => {
let etag = client
.put_object(
&request.href,
&calendar,
&Precondition::Unchanged(request.etag),
)
.await?;
Ok(Json(WriteResponse {
href: request.href,
etag,
new_series_href: None,
}))
}
EditOutcome::Split {
existing,
new_series,
} => {
// Two writes. The truncation goes first: if the new series were
// written first and the truncation then failed on a stale ETag, the
// calendar would show both halves at once.
let etag = client
.put_object(
&request.href,
&existing,
&Precondition::Unchanged(request.etag),
)
.await?;
let uid = new_series
.master()
.map(|master| master.uid.clone())
.ok_or_else(|| ApiError::Internal("split produced no master".to_owned()))?;
let new_href = href_for(&request.calendar_href, &uid);
client
.put_object(&new_href, &new_series, &Precondition::New)
.await?;
Ok(Json(WriteResponse {
href: request.href,
etag,
new_series_href: Some(new_href),
}))
}
}
}
#[derive(Deserialize)]
pub struct DeleteRequest {
pub calendar_href: String,
pub href: String,
pub etag: String,
#[serde(default)]
pub scope: EditScope,
pub recurrence_id: Option<CalendarDateTime>,
}
#[derive(Serialize)]
pub struct DeleteResponse {
/// Whether the whole resource went, or only part of the series.
pub removed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub etag: Option<String>,
}
/// `DELETE /api/events`
///
/// Takes a body, because a scoped delete needs a scope and a `RECURRENCE-ID`
/// and neither belongs in a URL. RFC 9110 permits it.
pub async fn delete(
State(state): State<AppState>,
CurrentUser { user, .. }: CurrentUser,
Json(request): Json<DeleteRequest>,
) -> Result<Json<DeleteResponse>, ApiError> {
let client = state.auth.caldav_for(&user).await?;
let zones = Zones::new(display_zone(&state, &user, None).await?);
let current = fetch(&client, &request.calendar_href, &request.href).await?;
let outcome = apply_delete(
&current.calendar,
request.scope,
request.recurrence_id.as_ref(),
zones,
)
.map_err(|error| ApiError::BadRequest(error.to_string()))?;
match outcome {
DeleteOutcome::Remove => {
client
.delete_object(&request.href, &Precondition::Unchanged(request.etag))
.await?;
Ok(Json(DeleteResponse {
removed: true,
etag: None,
}))
}
DeleteOutcome::Replace(calendar) => {
let etag = client
.put_object(
&request.href,
&calendar,
&Precondition::Unchanged(request.etag),
)
.await?;
Ok(Json(DeleteResponse {
removed: false,
etag,
}))
}
}
}
// -------------------------------------------------------------------- helpers
async fn fetch(
client: &CalDavClient,
calendar_href: &str,
href: &str,
) -> Result<CalendarObject, ApiError> {
client
.get_object(calendar_href, href)
.await
.map_err(ApiError::from)
}
/// The zone to render in.
///
/// The request wins, then the stored preference, then UTC. The request wins
/// because the browser knows where it is right now and a stored preference can
/// be months out of date — somebody who moves should not have to go and change
/// a setting before their calendar makes sense.
async fn display_zone(
state: &AppState,
user: &crate::db::User,
requested: Option<&str>,
) -> Result<Tz, ApiError> {
if let Some(name) = requested {
return name
.parse()
.map_err(|_| ApiError::BadRequest(format!("unknown time zone {name:?}")));
}
let preferences = state.db.preferences().load(&user.id).await?;
Ok(preferences
.display_timezone
.and_then(|name| name.parse().ok())
.unwrap_or(Tz::UTC))
}
fn midnight(date: NaiveDate) -> chrono::NaiveDateTime {
date.and_hms_opt(0, 0, 0)
.unwrap_or_else(|| DateTime::<Utc>::UNIX_EPOCH.naive_utc())
}
+1
View File
@@ -1,6 +1,7 @@
//! HTTP handlers.
pub mod auth;
pub mod events;
use axum::Json;
use serde_json::{Value, json};
+596
View File
@@ -0,0 +1,596 @@
//! 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<Self> {
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<Value>) -> 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<Value> {
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<String> {
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<Value>,
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);
});