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
Generated
+17
View File
@@ -244,6 +244,7 @@ dependencies = [
"axum-core", "axum-core",
"bytes", "bytes",
"cookie", "cookie",
"form_urlencoded",
"futures-util", "futures-util",
"http", "http",
"http-body", "http-body",
@@ -252,6 +253,8 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
"rustversion", "rustversion",
"serde_core", "serde_core",
"serde_html_form",
"serde_path_to_error",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing", "tracing",
@@ -2399,6 +2402,7 @@ dependencies = [
"base64", "base64",
"chacha20poly1305", "chacha20poly1305",
"chrono", "chrono",
"chrono-tz",
"pretty_assertions", "pretty_assertions",
"rand 0.9.5", "rand 0.9.5",
"reqwest", "reqwest",
@@ -2552,6 +2556,19 @@ dependencies = [
"syn 3.0.4", "syn 3.0.4",
] ]
[[package]]
name = "serde_html_form"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f"
dependencies = [
"form_urlencoded",
"indexmap",
"itoa",
"ryu",
"serde_core",
]
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.151" version = "1.0.151"
+1 -1
View File
@@ -53,7 +53,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Backend # Backend
axum = "0.8" axum = "0.8"
axum-extra = { version = "0.10", features = ["cookie"] } axum-extra = { version = "0.10", features = ["cookie", "query"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal"] }
tower = "0.5" tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace"] } tower-http = { version = "0.6", features = ["cors", "trace"] }
+1
View File
@@ -57,3 +57,4 @@ export RUNWAY_REQUIRE_CALDAV=1
# they ran is to run them here. # they ran is to run them here.
cargo test -p runway-caldav --test live -- --test-threads=1 "$@" cargo test -p runway-caldav --test live -- --test-threads=1 "$@"
cargo test -p runway-server --test auth -- --test-threads=1 "$@" cargo test -p runway-server --test auth -- --test-threads=1 "$@"
cargo test -p runway-server --test events -- --test-threads=1 "$@"
+9 -1
View File
@@ -9,7 +9,8 @@
//! //!
//! - `model` (default): the types below. No heavy dependencies. //! - `model` (default): the types below. No heavy dependencies.
//! - `ical`: parsing and serialising `VCALENDAR` data. //! - `ical`: parsing and serialising `VCALENDAR` data.
//! - `recurrence`: expanding `RRULE`s into concrete occurrences. //! - `recurrence`: expanding `RRULE`s into concrete occurrences, and the
//! series-editing rules built on them.
pub mod model; pub mod model;
@@ -19,4 +20,11 @@ pub mod ical;
#[cfg(feature = "recurrence")] #[cfg(feature = "recurrence")]
pub mod recurrence; pub mod recurrence;
/// Editing and deleting parts of a recurring series.
///
/// Needs `recurrence` because bounding a rule means resolving occurrences to
/// instants, which needs the zone database.
#[cfg(feature = "recurrence")]
pub mod series;
pub use model::*; pub use model::*;
+1 -1
View File
@@ -114,7 +114,7 @@ impl VEvent {
/// A new event with a caller-supplied UID. /// A new event with a caller-supplied UID.
pub fn with_uid(uid: impl Into<String>, dtstart: CalendarDateTime) -> Self { pub fn with_uid(uid: impl Into<String>, dtstart: CalendarDateTime) -> Self {
let now = Utc::now(); let now = super::now_to_second();
Self { Self {
uid: uid.into(), uid: uid.into(),
dtstamp: now, dtstamp: now,
+12
View File
@@ -16,6 +16,18 @@ mod property;
mod timezone; mod timezone;
pub use alarm::{AlarmAction, AlarmTrigger, TriggerRelation, VAlarm}; pub use alarm::{AlarmAction, AlarmTrigger, TriggerRelation, VAlarm};
/// The current instant, truncated to the second.
///
/// RFC 5545 date-times have one-second resolution. A timestamp carrying
/// fractions of a second is a value the wire format cannot hold, so it changes
/// the first time it is written and then never compares equal to itself again —
/// the same trap that made a stored session differ from the one just created.
pub fn now_to_second() -> chrono::DateTime<chrono::Utc> {
use chrono::SubsecRound;
chrono::Utc::now().trunc_subsecs(0)
}
pub use calendar::{RUNWAY_PRODID, VCalendar}; pub use calendar::{RUNWAY_PRODID, VCalendar};
pub use datetime::{CalendarDateTime, EventEnd, IcalDuration, InvalidTzId, TzId}; pub use datetime::{CalendarDateTime, EventEnd, IcalDuration, InvalidTzId, TzId};
pub use event::{ pub use event::{
+379
View File
@@ -0,0 +1,379 @@
//! Editing and deleting parts of a recurring series.
//!
//! This is the subtle part of a calendar, and the previous iteration shipped it
//! with zero automated coverage across 1,165 lines of duplicated
//! `/api/calendar/events/series/*` handlers. It is pure here — a resource and
//! an instruction in, a resource out — so every case can be tested without a
//! server, and the handler above it does nothing but move bytes.
//!
//! The three scopes mean three different things to the stored `.ics`:
//!
//! | Scope | Editing | Deleting |
//! |---|---|---|
//! | this only | add an override with a `RECURRENCE-ID` | add an `EXDATE` |
//! | this and future | truncate with `UNTIL`, start a new series | truncate with `UNTIL` |
//! | entire series | edit the master in place | remove the resource |
//!
//! Note that editing one occurrence writes **only** an override, with no
//! `EXDATE`. An `EXDATE` says the occurrence does not happen; an override says
//! it happens differently. Writing both would be contradictory, and every
//! producer in the corpus — DAVx5, Thunderbird, Evolution, Exchange — writes
//! the override alone.
mod rule;
pub use rule::{RuleError, count_of, set_count, set_until, strip_bounds};
use crate::model::{CalendarDateTime, EditScope, VCalendar, VEvent};
use crate::recurrence::Zones;
use chrono::TimeDelta;
use thiserror::Error;
/// What to write back after an edit.
#[derive(Debug, Clone, PartialEq)]
pub enum EditOutcome {
/// Replace the resource in place.
Replace(VCalendar),
/// Replace the resource *and* create a second one.
///
/// "This and future" is two writes, not one: the original series is
/// truncated and a new series takes over from the split point. Both have to
/// land, and the caller has to know it is doing two things.
Split {
existing: VCalendar,
new_series: VCalendar,
},
}
/// What to do to the resource after a deletion.
#[derive(Debug, Clone, PartialEq)]
pub enum DeleteOutcome {
/// Write the resource back with the occurrence removed.
Replace(VCalendar),
/// Remove the resource entirely.
Remove,
}
/// Applies an edit to one resource.
///
/// `updated` is the event as the person edited it. For a scoped edit it
/// describes the *occurrence*, and `recurrence_id` says which one.
///
/// Under [`EditScope::EntireSeries`] the submitted event **is** the master, so
/// an absent `RRULE` means the series should stop recurring. That is a real
/// edit rather than an omission, and it is the one place the difference
/// matters.
pub fn apply_edit(
resource: &VCalendar,
scope: EditScope,
recurrence_id: Option<&CalendarDateTime>,
updated: &VEvent,
zones: Zones,
) -> Result<EditOutcome, SeriesError> {
let master = resource.master().ok_or(SeriesError::NoMaster)?;
// A one-off event has no scopes to speak of: every scope means the same
// thing, and pretending otherwise would just be a way to get it wrong.
if !master.is_recurring() {
return Ok(EditOutcome::Replace(replace_master(resource, updated)));
}
match scope {
EditScope::EntireSeries => Ok(EditOutcome::Replace(edit_whole_series(
resource, master, updated,
))),
EditScope::ThisOnly => {
let at = recurrence_id.ok_or(SeriesError::MissingRecurrenceId)?;
Ok(EditOutcome::Replace(edit_one_occurrence(
resource, updated, at,
)))
}
EditScope::ThisAndFuture => {
let at = recurrence_id.ok_or(SeriesError::MissingRecurrenceId)?;
split_series(resource, master, updated, at, zones)
}
}
}
/// Applies a deletion to one resource.
pub fn apply_delete(
resource: &VCalendar,
scope: EditScope,
recurrence_id: Option<&CalendarDateTime>,
zones: Zones,
) -> Result<DeleteOutcome, SeriesError> {
let master = resource.master().ok_or(SeriesError::NoMaster)?;
if !master.is_recurring() || scope == EditScope::EntireSeries {
return Ok(DeleteOutcome::Remove);
}
let at = recurrence_id.ok_or(SeriesError::MissingRecurrenceId)?;
let target = zones.instant(at);
let mut out = resource.clone();
match scope {
EditScope::ThisOnly => {
for event in &mut out.events {
if event.is_override() {
continue;
}
// An EXDATE in the same form as DTSTART. A date-valued series
// excludes by date; a zoned one excludes by zoned time.
event.exdate.push(at.clone());
event.exdate.sort_by_key(|d| zones.instant(d));
event.exdate.dedup();
}
// An override for the occurrence being deleted is now meaningless.
out.events.retain(|e| !is_override_at(e, target, zones));
}
EditScope::ThisAndFuture => {
truncate_before(&mut out, target, zones)?;
}
EditScope::EntireSeries => unreachable!("handled above"),
}
// Truncating from the very first occurrence leaves a series with nothing
// in it, which is a resource that should not exist rather than an empty one.
if out
.master()
.is_none_or(|m| m.rrule.is_none() && m.rdate.is_empty())
&& out.events.len() == 1
&& scope == EditScope::ThisAndFuture
&& zones.instant(&out.events[0].dtstart) >= target
{
return Ok(DeleteOutcome::Remove);
}
Ok(DeleteOutcome::Replace(out))
}
// ------------------------------------------------------------------ editing --
/// Replaces a non-recurring event's content, keeping its identity.
fn replace_master(resource: &VCalendar, updated: &VEvent) -> VCalendar {
let mut out = resource.clone();
let uid = out.master().map(|m| m.uid.clone());
out.events = vec![with_identity(updated, uid, None)];
out
}
/// Edits the master, carrying its overrides along.
///
/// When the edit moves `DTSTART`, every occurrence moves with it — and the
/// `RECURRENCE-ID` on each override, which names an occurrence of the *old*
/// pattern, would no longer match anything. They are shifted by the same
/// amount rather than dropped: those overrides are somebody's edits, and
/// silently discarding them is precisely the behaviour this rewrite exists to
/// avoid.
fn edit_whole_series(resource: &VCalendar, master: &VEvent, updated: &VEvent) -> VCalendar {
let shift = updated.dtstart.naive_local() - master.dtstart.naive_local();
let mut out = resource.clone();
out.events = std::iter::once(with_identity(updated, Some(master.uid.clone()), None))
.chain(resource.overrides().map(|override_event| {
let mut moved = override_event.clone();
if shift != TimeDelta::zero() {
moved.recurrence_id = moved.recurrence_id.as_ref().map(|id| id.shifted(shift));
}
moved
}))
.collect();
out
}
/// Adds or replaces the override for one occurrence.
fn edit_one_occurrence(resource: &VCalendar, updated: &VEvent, at: &CalendarDateTime) -> VCalendar {
let uid = resource.master().map(|m| m.uid.clone());
let mut override_event = with_identity(updated, uid, Some(at.clone()));
// An override describes one occurrence and must not carry the series'
// recurrence rule; a resource with two RRULEs is two series.
override_event.rrule = None;
override_event.rdate.clear();
override_event.exdate.clear();
let mut out = resource.clone();
match out.events.iter().position(|e| same_recurrence_id(e, at)) {
Some(index) => out.events[index] = override_event,
None => out.events.push(override_event),
}
out
}
/// Truncates the series before `at` and starts a new one from it.
fn split_series(
resource: &VCalendar,
master: &VEvent,
updated: &VEvent,
at: &CalendarDateTime,
zones: Zones,
) -> Result<EditOutcome, SeriesError> {
let target = zones.instant(at);
// Splitting at the first occurrence leaves nothing behind, so it is an
// edit of the whole series wearing a different hat. Producing an empty
// truncated series plus a new one would be two resources where one belongs.
if zones.instant(&master.dtstart) >= target {
// The submitted event describes an *occurrence*, so it carries no
// recurrence rule. Applied as-is it would quietly turn the series into
// a single event -- the person asked to change every occurrence from
// here on, not to stop it recurring.
let mut whole = updated.clone();
whole.rrule = master.rrule.clone();
whole.rdate = master.rdate.clone();
whole.exdate = master.exdate.clone();
return Ok(EditOutcome::Replace(edit_whole_series(
resource, master, &whole,
)));
}
let mut existing = resource.clone();
truncate_before(&mut existing, target, zones)?;
// Overrides from the split point onwards belong to the new series.
existing
.events
.retain(|e| !e.is_override() || zones.instant(&recurrence_point(e)) < target);
let mut new_master = with_identity(updated, Some(uuid::Uuid::new_v4().to_string()), None);
// The new series inherits the pattern, and whatever is left of the bound.
//
// A COUNT counted from the original start, so it has to be reduced by the
// occurrences the first half kept; carrying it over unchanged would add
// occurrences, and dropping it would turn "six times" into forever. An
// UNTIL is an absolute instant and still means what it said, so it is
// simply removed here and re-added below.
new_master.rrule = match master.rrule.as_deref() {
None => None,
Some(rule) => Some(match count_of(rule) {
Some(total) => {
let consumed = occurrences_before(master, target, zones);
set_count(rule, total.saturating_sub(consumed).max(1))?
}
None => strip_bounds(rule),
}),
};
new_master.exdate = master
.exdate
.iter()
.filter(|d| zones.instant(d) >= target)
.cloned()
.collect();
new_master.rdate = master
.rdate
.iter()
.filter(|d| zones.instant(d) >= target)
.cloned()
.collect();
let mut new_series = VCalendar::with_events(vec![new_master]);
new_series.timezones = resource.timezones.clone();
Ok(EditOutcome::Split {
existing,
new_series,
})
}
// ---------------------------------------------------------------- mechanics --
/// How many of a master's occurrences fall before an instant.
///
/// Counted by expansion rather than arithmetic: a rule with `BYDAY` or
/// `BYMONTHDAY` does not have a closed form, and guessing is how v1's
/// hand-rolled expander went wrong.
fn occurrences_before(master: &VEvent, target: chrono::DateTime<chrono::Utc>, zones: Zones) -> u32 {
let mut only_master = master.clone();
only_master.recurrence_id = None;
let calendar = VCalendar::with_events(vec![only_master]);
let window = crate::recurrence::Window::new(zones.instant(&master.dtstart), target);
crate::recurrence::expand(&calendar, window, zones)
.map(|occurrences| {
u32::try_from(occurrences.iter().filter(|o| o.start_utc < target).count())
.unwrap_or(u32::MAX)
})
.unwrap_or(0)
}
/// Ends the master's rule at the last occurrence before `target`.
fn truncate_before(
calendar: &mut VCalendar,
target: chrono::DateTime<chrono::Utc>,
zones: Zones,
) -> Result<(), SeriesError> {
// UNTIL is inclusive, so it has to name an instant strictly before the
// occurrence being cut. One second is the smallest step the format can
// express.
let until = target - TimeDelta::seconds(1);
for event in &mut calendar.events {
if event.is_override() {
continue;
}
if let Some(rule) = &event.rrule {
event.rrule = Some(set_until(rule, until)?);
}
event.rdate.retain(|d| zones.instant(d) < target);
event.exdate.retain(|d| zones.instant(d) < target);
}
Ok(())
}
/// Copies an edited event's content onto a fixed identity.
///
/// The `UID` and `RECURRENCE-ID` are the server's business, not the client's:
/// taking them from the request would let a mistyped edit overwrite a different
/// event, and letting the client invent them is how v1 ended up reconstructing
/// identity by splitting `"{uid}-{timestamp}"` strings.
fn with_identity(
event: &VEvent,
uid: Option<String>,
recurrence_id: Option<CalendarDateTime>,
) -> VEvent {
let mut out = event.clone();
if let Some(uid) = uid {
out.uid = uid;
}
out.recurrence_id = recurrence_id;
out.sequence = out.sequence.saturating_add(1);
// Truncated to the second, because that is all iCalendar can express.
// Holding a precision the wire format cannot carry means the value changes
// the first time it is written out, and nothing downstream compares equal
// to itself.
let now = crate::model::now_to_second();
out.last_modified = Some(now);
out.dtstamp = now;
out
}
fn same_recurrence_id(event: &VEvent, at: &CalendarDateTime) -> bool {
event.recurrence_id.as_ref() == Some(at)
}
fn is_override_at(event: &VEvent, target: chrono::DateTime<chrono::Utc>, zones: Zones) -> bool {
event
.recurrence_id
.as_ref()
.is_some_and(|id| zones.instant(id) == target)
}
fn recurrence_point(event: &VEvent) -> CalendarDateTime {
event
.recurrence_id
.clone()
.unwrap_or_else(|| event.dtstart.clone())
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum SeriesError {
/// The resource holds only overrides, so there is no series to edit.
#[error("this resource has no series master")]
NoMaster,
/// A scoped edit needs to know which occurrence it applies to.
#[error("editing part of a series requires the occurrence's RECURRENCE-ID")]
MissingRecurrenceId,
#[error(transparent)]
Rule(#[from] RuleError),
}
+92
View File
@@ -0,0 +1,92 @@
//! Changing the bounds of an `RRULE` without rewriting the rest of it.
//!
//! Done as a string operation on purpose. Parsing the rule into `rrule`'s type
//! and rendering it back would work, but it normalises: part order changes,
//! defaulted parts appear, and `WKST=SU` written by Exchange may or may not
//! survive. Since the model stores the rule exactly as the producer wrote it,
//! the edit should touch exactly the part being edited and leave every other
//! byte alone.
use chrono::{DateTime, Utc};
use thiserror::Error;
/// Replaces a rule's bound with `UNTIL`.
///
/// `COUNT` and `UNTIL` are mutually exclusive (RFC 5545 §3.3.10), so any
/// existing bound of either kind is removed first. A rule that said "ten times"
/// cannot also say "until March".
pub fn set_until(rrule: &str, until: DateTime<Utc>) -> Result<String, RuleError> {
let mut parts = keep_unbounded_parts(rrule)?;
parts.push(format!("UNTIL={}", until.format("%Y%m%dT%H%M%SZ")));
Ok(parts.join(";"))
}
/// Removes `COUNT` and `UNTIL`, leaving an unbounded rule.
///
/// Used when a series is split: the second half inherits the pattern but not
/// the bound, because "ten occurrences" counted from the original start and
/// means nothing measured from half way along.
pub fn strip_bounds(rrule: &str) -> String {
keep_unbounded_parts(rrule)
.map(|parts| parts.join(";"))
.unwrap_or_else(|_| rrule.to_owned())
}
/// The `COUNT` a rule carries, if it is bounded that way.
pub fn count_of(rrule: &str) -> Option<u32> {
rrule
.split(';')
.filter_map(|part| part.trim().split_once('='))
.find(|(name, _)| name.eq_ignore_ascii_case("COUNT"))
.and_then(|(_, value)| value.trim().parse().ok())
}
/// Replaces a rule's bound with `COUNT`.
///
/// Used when a counted series is split: "six times" was counted from the
/// original start, so the second half carries however many are left rather
/// than becoming unbounded. Dropping the bound would quietly turn a finite
/// commitment into a permanent one.
pub fn set_count(rrule: &str, count: u32) -> Result<String, RuleError> {
let mut parts = keep_unbounded_parts(rrule)?;
parts.push(format!("COUNT={count}"));
Ok(parts.join(";"))
}
fn keep_unbounded_parts(rrule: &str) -> Result<Vec<String>, RuleError> {
let trimmed = rrule.trim();
if trimmed.is_empty() {
return Err(RuleError::Empty);
}
let parts: Vec<String> = trimmed
.split(';')
.map(str::trim)
.filter(|part| !part.is_empty())
.filter(|part| {
let name = part.split('=').next().unwrap_or_default();
!name.eq_ignore_ascii_case("UNTIL") && !name.eq_ignore_ascii_case("COUNT")
})
.map(str::to_owned)
.collect();
if !parts.iter().any(|part| {
part.split('=')
.next()
.unwrap_or_default()
.eq_ignore_ascii_case("FREQ")
}) {
return Err(RuleError::NoFrequency(trimmed.to_owned()));
}
Ok(parts)
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum RuleError {
#[error("the recurrence rule is empty")]
Empty,
/// Every `RRULE` must have a `FREQ`; without one there is no pattern and
/// bounding it would produce something meaningless.
#[error("the recurrence rule {0:?} has no FREQ")]
NoFrequency(String),
}
+824
View File
@@ -0,0 +1,824 @@
//! Editing and deleting parts of a series.
//!
//! The three scopes are the subtlest thing a calendar does, and v1 shipped them
//! across 1,165 lines of duplicated handlers with no automated coverage at all.
//! Each case here states what should end up in the stored `.ics`, then checks
//! it by expanding the result — because the question that matters is not "what
//! did we write" but "what does a calendar client see afterwards".
#![allow(clippy::unwrap_used, clippy::expect_used)]
use chrono::{DateTime, TimeDelta, Utc};
use chrono_tz::Tz;
use pretty_assertions::assert_eq;
use runway_core::ical;
use runway_core::model::*;
use runway_core::recurrence::{Window, Zones, expand};
use runway_core::series::{
DeleteOutcome, EditOutcome, SeriesError, apply_delete, apply_edit, set_until, strip_bounds,
};
fn denver() -> Tz {
Tz::America__Denver
}
fn zones() -> Zones {
Zones::new(denver())
}
fn at(day: u32, hour: u32) -> CalendarDateTime {
CalendarDateTime::Zoned {
local: chrono::NaiveDate::from_ymd_opt(2026, 1, day)
.unwrap()
.and_hms_opt(hour, 0, 0)
.unwrap(),
tzid: TzId::new("America/Denver").unwrap(),
}
}
fn instant(text: &str) -> DateTime<Utc> {
text.parse().unwrap()
}
/// A weekly Monday 09:00 series starting 2026-01-05, six occurrences.
fn weekly_series() -> VCalendar {
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n\
BEGIN:VEVENT\r\nUID:standup@test\r\nDTSTAMP:20260101T000000Z\r\n\
DTSTART;TZID=America/Denver:20260105T090000\r\n\
DTEND;TZID=America/Denver:20260105T093000\r\n\
SUMMARY:Standup\r\nRRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=6\r\n\
END:VEVENT\r\nEND:VCALENDAR\r\n";
ical::parse(ics).unwrap()
}
fn one_off() -> VCalendar {
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n\
BEGIN:VEVENT\r\nUID:dentist@test\r\nDTSTAMP:20260101T000000Z\r\n\
DTSTART;TZID=America/Denver:20260105T090000\r\n\
DTEND;TZID=America/Denver:20260105T093000\r\n\
SUMMARY:Dentist\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
ical::parse(ics).unwrap()
}
/// The edited form of an occurrence: moved to 14:00 and renamed.
fn moved_to_afternoon(day: u32) -> VEvent {
let mut event =
VEvent::with_uid("ignored-by-the-server", at(day, 14)).titled("Standup (moved)");
event.end = Some(EventEnd::DateTime { dtend: at(day, 15) });
event
}
/// Local start times an outcome produces over January, as a calendar would
/// show them.
fn shown(calendars: &[&VCalendar]) -> Vec<String> {
let window = Window::new(
instant("2026-01-01T00:00:00Z"),
instant("2026-03-01T00:00:00Z"),
);
let mut out: Vec<(DateTime<Utc>, String)> = Vec::new();
for calendar in calendars {
for occurrence in expand(calendar, window, zones()).unwrap() {
out.push((
occurrence.start_utc,
format!(
"{} {}",
occurrence.start.naive_local().format("%Y-%m-%d %H:%M"),
occurrence.title().unwrap_or("(untitled)"),
),
));
}
}
out.sort_by_key(|(instant, _)| *instant);
out.into_iter().map(|(_, label)| label).collect()
}
fn replaced(outcome: EditOutcome) -> VCalendar {
match outcome {
EditOutcome::Replace(calendar) => calendar,
EditOutcome::Split { .. } => panic!("expected a single resource, got a split"),
}
}
// ------------------------------------------------------------- entire series --
#[test]
fn editing_the_whole_series_edits_the_master_in_place() {
let series = weekly_series();
let mut renamed = series.master().unwrap().clone();
renamed.summary = Some("Standup (renamed)".to_owned());
let result =
replaced(apply_edit(&series, EditScope::EntireSeries, None, &renamed, zones()).unwrap());
assert_eq!(result.events.len(), 1, "one master, no override");
assert_eq!(
result.master().unwrap().rrule.as_deref(),
Some("FREQ=WEEKLY;BYDAY=MO;COUNT=6"),
"the rule is untouched",
);
assert!(shown(&[&result]).iter().all(|s| s.contains("renamed")));
assert_eq!(shown(&[&result]).len(), 6);
}
#[test]
fn editing_the_whole_series_keeps_its_identity() {
let series = weekly_series();
let mut renamed = series.master().unwrap().clone();
renamed.uid = "a-uid-the-client-made-up".to_owned();
let result =
replaced(apply_edit(&series, EditScope::EntireSeries, None, &renamed, zones()).unwrap());
assert_eq!(
result.master().unwrap().uid,
"standup@test",
"the UID is the server's business; taking it from the request would let \
a mistyped edit overwrite a different event",
);
}
#[test]
fn moving_a_series_carries_its_overrides_with_it() {
// The case that would otherwise lose data: an override names an occurrence
// of the *old* pattern, so moving the master leaves it pointing at nothing.
let mut series = weekly_series();
let mut exception = moved_to_afternoon(12);
exception.uid = "standup@test".to_owned();
exception.recurrence_id = Some(at(12, 9));
series.events.push(exception);
let mut moved = series.master().unwrap().clone();
moved.dtstart = at(5, 11);
moved.end = Some(EventEnd::DateTime { dtend: at(5, 12) });
let result =
replaced(apply_edit(&series, EditScope::EntireSeries, None, &moved, zones()).unwrap());
assert_eq!(result.overrides().count(), 1, "the override survived");
assert_eq!(
result.overrides().next().unwrap().recurrence_id,
Some(at(12, 11)),
"and its RECURRENCE-ID moved by the same two hours, so it still names \
a real occurrence instead of being silently discarded",
);
}
// ----------------------------------------------------------------- this only --
#[test]
fn editing_one_occurrence_writes_an_override_and_nothing_else() {
let series = weekly_series();
let result = replaced(
apply_edit(
&series,
EditScope::ThisOnly,
Some(&at(12, 9)),
&moved_to_afternoon(12),
zones(),
)
.unwrap(),
);
assert_eq!(result.events.len(), 2, "master plus one override");
let exception = result.overrides().next().unwrap();
assert_eq!(exception.uid, "standup@test", "one resource, one UID");
assert_eq!(exception.recurrence_id, Some(at(12, 9)));
assert_eq!(exception.dtstart, at(12, 14));
assert!(
exception.rrule.is_none(),
"an override describes one occurrence; a second RRULE would make this \
two series in one resource",
);
assert!(
result.master().unwrap().exdate.is_empty(),
"no EXDATE: an EXDATE says the occurrence does not happen, an override \
says it happens differently, and writing both is contradictory",
);
}
#[test]
fn an_edited_occurrence_appears_once_at_its_new_time() {
let series = weekly_series();
let result = replaced(
apply_edit(
&series,
EditScope::ThisOnly,
Some(&at(12, 9)),
&moved_to_afternoon(12),
zones(),
)
.unwrap(),
);
assert_eq!(
shown(&[&result]),
vec![
"2026-01-05 09:00 Standup",
"2026-01-12 14:00 Standup (moved)",
"2026-01-19 09:00 Standup",
"2026-01-26 09:00 Standup",
"2026-02-02 09:00 Standup",
"2026-02-09 09:00 Standup",
],
"still six occurrences, one of them moved -- not seven, and not five",
);
}
#[test]
fn editing_the_same_occurrence_twice_replaces_its_override() {
let series = weekly_series();
let once = replaced(
apply_edit(
&series,
EditScope::ThisOnly,
Some(&at(12, 9)),
&moved_to_afternoon(12),
zones(),
)
.unwrap(),
);
let mut again = moved_to_afternoon(12);
again.dtstart = at(12, 16);
again.end = Some(EventEnd::DateTime { dtend: at(12, 17) });
let twice = replaced(
apply_edit(
&once,
EditScope::ThisOnly,
Some(&at(12, 9)),
&again,
zones(),
)
.unwrap(),
);
assert_eq!(
twice.overrides().count(),
1,
"a second edit of the same occurrence must replace the override, not \
add a duplicate",
);
assert_eq!(twice.overrides().next().unwrap().dtstart, at(12, 16));
}
#[test]
fn editing_one_occurrence_without_saying_which_is_refused() {
let series = weekly_series();
assert_eq!(
apply_edit(
&series,
EditScope::ThisOnly,
None,
&moved_to_afternoon(12),
zones()
)
.unwrap_err(),
SeriesError::MissingRecurrenceId,
);
}
// ----------------------------------------------------------- this and future --
#[test]
fn splitting_a_series_truncates_the_first_and_starts_a_second() {
let series = weekly_series();
let outcome = apply_edit(
&series,
EditScope::ThisAndFuture,
Some(&at(19, 9)),
&moved_to_afternoon(19),
zones(),
)
.unwrap();
let EditOutcome::Split {
existing,
new_series,
} = outcome
else {
panic!("this-and-future is two resources, not one");
};
let rule = existing.master().unwrap().rrule.clone().unwrap();
assert!(
rule.contains("UNTIL=20260119T155959Z"),
"the original ends one second before the split point: {rule}",
);
assert!(
!rule.contains("COUNT"),
"COUNT and UNTIL are mutually exclusive: {rule}",
);
assert_ne!(
new_series.master().unwrap().uid,
"standup@test",
"a new series is a new resource with its own UID",
);
assert_eq!(
new_series.master().unwrap().rrule.as_deref(),
Some("FREQ=WEEKLY;BYDAY=MO;COUNT=4"),
"it inherits the pattern and what is left of the bound: two of the six \
stayed with the original, so four remain. Carrying COUNT=6 over would \
add occurrences; dropping it would turn \"six times\" into forever",
);
}
#[test]
fn a_split_series_shows_the_right_occurrences_on_both_sides() {
let series = weekly_series();
let EditOutcome::Split {
existing,
new_series,
} = apply_edit(
&series,
EditScope::ThisAndFuture,
Some(&at(19, 9)),
&moved_to_afternoon(19),
zones(),
)
.unwrap()
else {
panic!("expected a split");
};
assert_eq!(
shown(&[&existing]),
vec!["2026-01-05 09:00 Standup", "2026-01-12 09:00 Standup"],
"the old series stops before the split",
);
assert_eq!(
shown(&[&new_series]),
vec![
"2026-01-19 14:00 Standup (moved)",
"2026-01-26 14:00 Standup (moved)",
"2026-02-02 14:00 Standup (moved)",
"2026-02-09 14:00 Standup (moved)",
],
"and the new one takes over at the new time, for the four that were \
left -- six in total across both halves, as originally asked for",
);
assert!(
!shown(&[&existing, &new_series]).contains(&"2026-01-19 09:00 Standup".to_owned()),
"nothing may appear at the old time on the split day",
);
}
#[test]
fn splitting_at_the_first_occurrence_is_just_editing_the_series() {
let series = weekly_series();
let outcome = apply_edit(
&series,
EditScope::ThisAndFuture,
Some(&at(5, 9)),
&moved_to_afternoon(5),
zones(),
)
.unwrap();
let result = replaced(outcome);
assert_eq!(
result.master().unwrap().uid,
"standup@test",
"splitting at the very start would leave an empty truncated series and \
a new one, which is two resources where one belongs",
);
assert_eq!(
result.master().unwrap().rrule.as_deref(),
Some("FREQ=WEEKLY;BYDAY=MO;COUNT=6"),
"and the rule survives: the person asked to change every occurrence \
from the first one onwards, not to stop the series recurring",
);
assert_eq!(shown(&[&result]).len(), 6);
assert!(shown(&[&result]).iter().all(|s| s.contains("14:00")));
}
#[test]
fn clearing_the_rule_on_the_whole_series_makes_it_a_single_event() {
// Under EntireSeries the submitted event *is* the master, so dropping the
// rule is an instruction, not an omission. The split path is the opposite
// case and carries the rule forward; see the test above.
let series = weekly_series();
let mut once_only = series.master().unwrap().clone();
once_only.rrule = None;
let result =
replaced(apply_edit(&series, EditScope::EntireSeries, None, &once_only, zones()).unwrap());
assert_eq!(shown(&[&result]), vec!["2026-01-05 09:00 Standup"]);
}
#[test]
fn a_split_hands_later_overrides_to_the_new_series() {
let mut series = weekly_series();
for day in [12, 26] {
let mut exception = moved_to_afternoon(day);
exception.uid = "standup@test".to_owned();
exception.recurrence_id = Some(at(day, 9));
series.events.push(exception);
}
let EditOutcome::Split { existing, .. } = apply_edit(
&series,
EditScope::ThisAndFuture,
Some(&at(19, 9)),
&moved_to_afternoon(19),
zones(),
)
.unwrap() else {
panic!("expected a split");
};
let kept: Vec<_> = existing.overrides().collect();
assert_eq!(kept.len(), 1, "only the override before the split stays");
assert_eq!(kept[0].recurrence_id, Some(at(12, 9)));
}
// ------------------------------------------------------------------ deleting --
#[test]
fn deleting_one_occurrence_adds_an_exdate() {
let series = weekly_series();
let DeleteOutcome::Replace(result) =
apply_delete(&series, EditScope::ThisOnly, Some(&at(12, 9)), zones()).unwrap()
else {
panic!("deleting one occurrence keeps the resource");
};
assert_eq!(result.master().unwrap().exdate, vec![at(12, 9)]);
assert_eq!(
shown(&[&result]),
vec![
"2026-01-05 09:00 Standup",
"2026-01-19 09:00 Standup",
"2026-01-26 09:00 Standup",
"2026-02-02 09:00 Standup",
"2026-02-09 09:00 Standup",
],
"the 12th is gone and the rest are untouched",
);
}
#[test]
fn deleting_an_occurrence_that_was_edited_removes_its_override_too() {
let series = weekly_series();
let edited = replaced(
apply_edit(
&series,
EditScope::ThisOnly,
Some(&at(12, 9)),
&moved_to_afternoon(12),
zones(),
)
.unwrap(),
);
let DeleteOutcome::Replace(result) =
apply_delete(&edited, EditScope::ThisOnly, Some(&at(12, 9)), zones()).unwrap()
else {
panic!("expected the resource to survive");
};
assert_eq!(
result.overrides().count(),
0,
"an EXDATE without removing the override would leave the moved copy \
showing on a day the person just deleted",
);
assert!(!shown(&[&result]).iter().any(|s| s.contains("2026-01-12")));
}
#[test]
fn deleting_this_and_future_truncates_the_series() {
let series = weekly_series();
let DeleteOutcome::Replace(result) =
apply_delete(&series, EditScope::ThisAndFuture, Some(&at(19, 9)), zones()).unwrap()
else {
panic!("expected the resource to survive");
};
assert_eq!(
shown(&[&result]),
vec!["2026-01-05 09:00 Standup", "2026-01-12 09:00 Standup"],
);
}
#[test]
fn deleting_the_entire_series_removes_the_resource() {
let series = weekly_series();
assert_eq!(
apply_delete(&series, EditScope::EntireSeries, None, zones()).unwrap(),
DeleteOutcome::Remove,
"there is nothing left to write back",
);
}
// -------------------------------------------------------------- one-off events
#[test]
fn every_scope_means_the_same_thing_for_a_one_off_event() {
let event = one_off();
let mut renamed = event.master().unwrap().clone();
renamed.summary = Some("Dentist (rescheduled)".to_owned());
for scope in [
EditScope::ThisOnly,
EditScope::ThisAndFuture,
EditScope::EntireSeries,
] {
let result =
replaced(apply_edit(&event, scope, Some(&at(5, 9)), &renamed, zones()).unwrap());
assert_eq!(result.events.len(), 1, "{scope:?}");
assert_eq!(result.master().unwrap().uid, "dentist@test", "{scope:?}");
assert_eq!(shown(&[&result]).len(), 1, "{scope:?}");
}
}
#[test]
fn deleting_a_one_off_event_removes_it_whatever_the_scope() {
let event = one_off();
for scope in [
EditScope::ThisOnly,
EditScope::ThisAndFuture,
EditScope::EntireSeries,
] {
assert_eq!(
apply_delete(&event, scope, Some(&at(5, 9)), zones()).unwrap(),
DeleteOutcome::Remove,
"{scope:?}",
);
}
}
// ---------------------------------------------------------------- rule edits --
#[test]
fn setting_until_replaces_any_existing_bound() {
let until = instant("2026-01-19T15:59:59Z");
assert_eq!(
set_until("FREQ=WEEKLY;BYDAY=MO;COUNT=6", until).unwrap(),
"FREQ=WEEKLY;BYDAY=MO;UNTIL=20260119T155959Z",
);
assert_eq!(
set_until("FREQ=WEEKLY;UNTIL=20270101T000000Z", until).unwrap(),
"FREQ=WEEKLY;UNTIL=20260119T155959Z",
);
}
#[test]
fn setting_until_leaves_every_other_part_exactly_as_written() {
// Exchange writes WKST=SU and orders its parts its own way. Parsing the
// rule and rendering it back would normalise both; this touches only the
// bound.
let rule = "FREQ=WEEKLY;INTERVAL=1;BYDAY=WE;WKST=SU";
let bounded = set_until(rule, instant("2026-01-19T15:59:59Z")).unwrap();
assert!(bounded.starts_with(rule), "{bounded}");
}
#[test]
fn an_unbounded_series_stays_unbounded_when_split() {
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n\
BEGIN:VEVENT\r\nUID:forever@test\r\nDTSTAMP:20260101T000000Z\r\n\
DTSTART;TZID=America/Denver:20260105T090000\r\n\
SUMMARY:Standup\r\nRRULE:FREQ=WEEKLY;BYDAY=MO\r\n\
END:VEVENT\r\nEND:VCALENDAR\r\n";
let series = ical::parse(ics).unwrap();
let EditOutcome::Split { new_series, .. } = apply_edit(
&series,
EditScope::ThisAndFuture,
Some(&at(19, 9)),
&moved_to_afternoon(19),
zones(),
)
.unwrap() else {
panic!("expected a split");
};
assert_eq!(
new_series.master().unwrap().rrule.as_deref(),
Some("FREQ=WEEKLY;BYDAY=MO"),
"there was no bound to divide up",
);
}
#[test]
fn a_counted_series_keeps_its_total_across_a_split() {
// The property that matters: however the series is cut, the number of
// occurrences the person asked for is the number they end up with.
let series = weekly_series();
for split_day in [12, 19, 26] {
let EditOutcome::Split {
existing,
new_series,
} = apply_edit(
&series,
EditScope::ThisAndFuture,
Some(&at(split_day, 9)),
&moved_to_afternoon(split_day),
zones(),
)
.unwrap()
else {
panic!("expected a split at day {split_day}");
};
assert_eq!(
shown(&[&existing, &new_series]).len(),
6,
"splitting at the {split_day}th changed the total",
);
}
}
#[test]
fn stripping_bounds_leaves_the_pattern() {
assert_eq!(
strip_bounds("FREQ=MONTHLY;BYDAY=3TU;COUNT=12"),
"FREQ=MONTHLY;BYDAY=3TU",
);
assert_eq!(strip_bounds("FREQ=DAILY"), "FREQ=DAILY");
}
#[test]
fn a_rule_with_no_frequency_is_refused() {
assert!(set_until("COUNT=5", instant("2026-01-19T15:59:59Z")).is_err());
assert!(set_until("", instant("2026-01-19T15:59:59Z")).is_err());
}
// ------------------------------------------------------------ written output --
#[test]
fn every_outcome_is_still_valid_icalendar() {
// The results are written to a real server, so they have to survive the
// round trip the whole of M3 exists to guarantee.
let series = weekly_series();
let mut outcomes = vec![replaced(
apply_edit(
&series,
EditScope::ThisOnly,
Some(&at(12, 9)),
&moved_to_afternoon(12),
zones(),
)
.unwrap(),
)];
if let EditOutcome::Split {
existing,
new_series,
} = apply_edit(
&series,
EditScope::ThisAndFuture,
Some(&at(19, 9)),
&moved_to_afternoon(19),
zones(),
)
.unwrap()
{
outcomes.push(existing);
outcomes.push(new_series);
}
for calendar in &outcomes {
let written = ical::write(calendar);
let reparsed = ical::parse(&written).expect("outcomes must be readable");
assert_eq!(&reparsed, calendar, "changed meaning on the way out");
for line in written.split("\r\n") {
assert!(line.len() <= 75, "unfolded line: {line}");
}
}
}
#[test]
fn an_edit_bumps_the_sequence_number() {
let series = weekly_series();
let before = series.master().unwrap().sequence;
let mut renamed = series.master().unwrap().clone();
renamed.summary = Some("Renamed".to_owned());
let result =
replaced(apply_edit(&series, EditScope::EntireSeries, None, &renamed, zones()).unwrap());
assert_eq!(
result.master().unwrap().sequence,
before + 1,
"SEQUENCE is how other clients know a revision happened; leaving it \
still makes an edited invitation look unchanged",
);
assert!(result.master().unwrap().last_modified.is_some());
}
#[test]
fn a_resource_with_only_overrides_cannot_be_edited() {
let mut orphan = weekly_series();
orphan.events.retain(VEvent::is_override);
orphan.events.push({
let mut exception = moved_to_afternoon(12);
exception.recurrence_id = Some(at(12, 9));
exception
});
assert_eq!(
apply_edit(
&orphan,
EditScope::EntireSeries,
None,
&moved_to_afternoon(12),
zones()
)
.unwrap_err(),
SeriesError::NoMaster,
"there is no series to apply a scope to, and guessing would be worse \
than saying so",
);
}
#[test]
fn an_edit_across_a_dst_boundary_keeps_the_wall_clock_time() {
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n\
BEGIN:VEVENT\r\nUID:dst@test\r\nDTSTAMP:20260101T000000Z\r\n\
DTSTART;TZID=America/Denver:20260302T090000\r\n\
DTEND;TZID=America/Denver:20260302T093000\r\n\
SUMMARY:Weekly\r\nRRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=4\r\n\
END:VEVENT\r\nEND:VCALENDAR\r\n";
let series = ical::parse(ics).unwrap();
// Denver springs forward on 2026-03-08, so the split point is on the far
// side of the change and UNTIL has to be computed from the right offset.
let split_at = CalendarDateTime::Zoned {
local: chrono::NaiveDate::from_ymd_opt(2026, 3, 16)
.unwrap()
.and_hms_opt(9, 0, 0)
.unwrap(),
tzid: TzId::new("America/Denver").unwrap(),
};
let DeleteOutcome::Replace(result) =
apply_delete(&series, EditScope::ThisAndFuture, Some(&split_at), zones()).unwrap()
else {
panic!("expected the resource to survive");
};
let rule = result.master().unwrap().rrule.clone().unwrap();
assert!(
rule.contains("UNTIL=20260316T145959Z"),
"09:00 Denver on 16 March is 15:00 UTC because daylight time has begun; \
computing it from the winter offset would delete a week too many or \
too few: {rule}",
);
let window = Window::new(
instant("2026-03-01T00:00:00Z"),
instant("2026-04-01T00:00:00Z"),
);
let remaining = expand(&result, window, zones()).unwrap();
assert_eq!(
remaining.len(),
2,
"2 and 9 March survive, 16 and 23 do not"
);
}
#[test]
fn scopes_round_trip_through_json() {
for scope in [
EditScope::ThisOnly,
EditScope::ThisAndFuture,
EditScope::EntireSeries,
] {
let encoded = serde_json::to_string(&scope).unwrap();
assert_eq!(serde_json::from_str::<EditScope>(&encoded).unwrap(), scope);
}
assert_eq!(
serde_json::to_string(&EditScope::ThisAndFuture).unwrap(),
"\"this_and_future\"",
"the wire form is stable; v1 dispatched on 53 hand-written string \
literals and a typo was a runtime fallthrough",
);
}
#[test]
fn a_shifted_series_keeps_its_duration() {
let series = weekly_series();
let mut moved = series.master().unwrap().clone();
moved.dtstart = at(5, 11);
moved.end = Some(EventEnd::DateTime { dtend: at(5, 12) });
let result =
replaced(apply_edit(&series, EditScope::EntireSeries, None, &moved, zones()).unwrap());
assert_eq!(result.master().unwrap().duration(), TimeDelta::hours(1));
}
+1
View File
@@ -15,6 +15,7 @@ chacha20poly1305 = { workspace = true }
time = "0.3" time = "0.3"
rand = { workspace = true } rand = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
chrono-tz = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
sha2 = { workspace = true } sha2 = { workspace = true }
+7 -1
View File
@@ -8,7 +8,7 @@
use crate::routes; use crate::routes;
use crate::state::AppState; use crate::state::AppState;
use axum::Router; use axum::Router;
use axum::routing::{get, post}; use axum::routing::{delete, get, post, put};
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
pub fn router(state: AppState) -> Router { 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/login", post(routes::auth::login))
.route("/api/auth/logout", post(routes::auth::logout)) .route("/api/auth/logout", post(routes::auth::logout))
.route("/api/auth/session", get(routes::auth::current_session)) .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()) .layer(TraceLayer::new_for_http())
.with_state(state) .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. //! HTTP handlers.
pub mod auth; pub mod auth;
pub mod events;
use axum::Json; use axum::Json;
use serde_json::{Value, 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);
});