An aggregate without a default_sink leaves events created in it alone, which is how a target calendar keeps events of its own. The reconciler unit tests cover the decision; this covers it surviving the round trip, and specifically that it holds on every subsequent cycle — an event that survived the first cycle and was swept up by the second would be worse than never having worked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
331 lines
11 KiB
Rust
331 lines
11 KiB
Rust
//! End-to-end tests against a real CalDAV server and a real iCal feed.
|
|
//!
|
|
//! The unit tests establish that the reconciler decides correctly. These
|
|
//! establish that the decisions survive the round trip through pimsync and a
|
|
//! server that rewrites what it stores — which is where every bug found by hand
|
|
//! during M1 actually lived.
|
|
//!
|
|
//! Radicale and pimsync both come from devbox, so `devbox run check` has them.
|
|
//! Outside that shell the tests report what is missing and pass, rather than
|
|
//! failing for a reason that has nothing to do with the code.
|
|
|
|
mod support;
|
|
|
|
use support::{Calcalist, Feed, Radicale};
|
|
|
|
/// An event with guests, an organiser and an alarm — the combination the
|
|
/// scheduling rules are about.
|
|
const MEETING: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n\
|
|
BEGIN:VEVENT\r\nUID:meeting@work\r\nDTSTAMP:20260101T000000Z\r\n\
|
|
DTSTART:20260910T090000Z\r\nDTEND:20260910T100000Z\r\nSUMMARY:Planning\r\n\
|
|
ORGANIZER;CN=Chair:mailto:chair@example.com\r\n\
|
|
ATTENDEE;CN=Guest;PARTSTAT=ACCEPTED:mailto:guest@example.com\r\n\
|
|
BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nDESCRIPTION:Soon\r\n\
|
|
END:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
|
|
|
|
/// An event written straight into the aggregate, belonging to no source.
|
|
const HAND_WRITTEN: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//phone//EN\r\n\
|
|
BEGIN:VEVENT\r\nUID:hand-written@phone\r\nDTSTAMP:20260101T000000Z\r\n\
|
|
DTSTART:20260911T140000Z\r\nDTEND:20260911T150000Z\r\nSUMMARY:Dentist\r\n\
|
|
END:VEVENT\r\nEND:VCALENDAR\r\n";
|
|
|
|
const HOLIDAY_FEED: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//feed//EN\r\n\
|
|
BEGIN:VEVENT\r\nUID:newyear@feed\r\nDTSTAMP:20260101T000000Z\r\n\
|
|
DTSTART;VALUE=DATE:20260101\r\nDTEND;VALUE=DATE:20260102\r\n\
|
|
SUMMARY:New Year\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
|
|
|
|
struct Fixture {
|
|
_root: tempfile::TempDir,
|
|
server: Radicale,
|
|
_feed: Feed,
|
|
calcalist: Calcalist,
|
|
}
|
|
|
|
/// A CalDAV source, a read-only feed, and a CalDAV target to aggregate into.
|
|
fn fixture() -> Option<Fixture> {
|
|
let missing = support::missing_binaries();
|
|
if !missing.is_empty() {
|
|
eprintln!(
|
|
"skipped: {} not on PATH; run under devbox",
|
|
missing.join(", ")
|
|
);
|
|
return None;
|
|
}
|
|
|
|
let root = tempfile::tempdir().expect("temp");
|
|
let server = Radicale::start(root.path());
|
|
server.create_calendar("work");
|
|
server.create_calendar("unified");
|
|
server.put_event("work", "meeting.ics", MEETING);
|
|
let feed = Feed::start(HOLIDAY_FEED.to_string());
|
|
|
|
let config = format!(
|
|
r#"
|
|
version = 1
|
|
|
|
[[endpoint]]
|
|
id = "work"
|
|
type = "caldav"
|
|
url = "{work}"
|
|
username = "{user}"
|
|
secret_command = "printf password"
|
|
|
|
[[endpoint]]
|
|
id = "published"
|
|
type = "caldav"
|
|
url = "{unified}"
|
|
username = "{user}"
|
|
secret_command = "printf password"
|
|
|
|
[[endpoint]]
|
|
id = "holidays"
|
|
type = "webcal"
|
|
url = "{feed}"
|
|
|
|
[[aggregate]]
|
|
id = "everything"
|
|
target = "published"
|
|
sources = ["work", "holidays"]
|
|
default_sink = "work"
|
|
"#,
|
|
work = server.url("work"),
|
|
unified = server.url("unified"),
|
|
user = support::USER,
|
|
feed = feed.url(),
|
|
);
|
|
|
|
let calcalist = Calcalist::new(root.path(), &config);
|
|
Some(Fixture {
|
|
_root: root,
|
|
server,
|
|
_feed: feed,
|
|
calcalist,
|
|
})
|
|
}
|
|
|
|
/// Both sources reach the target, and running again changes nothing.
|
|
///
|
|
/// Idempotence is the property that matters most: a cycle that is not a no-op
|
|
/// against unchanged input would rewrite every event on every run, and each
|
|
/// rewrite is a chance for the server to hand back something slightly different.
|
|
#[test]
|
|
fn a_cycle_converges_and_the_next_one_does_nothing() {
|
|
let Some(fixture) = fixture() else { return };
|
|
|
|
let first = fixture.calcalist.run(&["sync"]);
|
|
assert!(
|
|
first.succeeded(),
|
|
"first sync failed\n{}\n{}",
|
|
first.stdout,
|
|
first.stderr
|
|
);
|
|
|
|
let published = fixture.server.stored("unified");
|
|
assert_eq!(
|
|
published.len(),
|
|
2,
|
|
"both sources should arrive: {published:?}"
|
|
);
|
|
let all = published.join("\n");
|
|
assert!(all.contains("Planning"), "{all}");
|
|
assert!(all.contains("New Year"), "{all}");
|
|
// Provenance travels with each mirror, so the aggregate knows where its
|
|
// events came from without consulting the state file.
|
|
assert!(all.contains("X-CALCALIST-SOURCE:work"), "{all}");
|
|
assert!(all.contains("X-CALCALIST-SOURCE:holidays"), "{all}");
|
|
|
|
let second = fixture.calcalist.run(&["sync"]);
|
|
assert!(
|
|
second.succeeded(),
|
|
"second sync failed\n{}\n{}",
|
|
second.stdout,
|
|
second.stderr
|
|
);
|
|
assert!(
|
|
second.stdout.contains("everything: 0 mirrored"),
|
|
"the second cycle should be a no-op:\n{}",
|
|
second.stdout
|
|
);
|
|
assert_eq!(
|
|
fixture.server.stored("unified"),
|
|
published,
|
|
"the second cycle rewrote the target"
|
|
);
|
|
}
|
|
|
|
/// The scheduling rule, checked against what actually reached the server.
|
|
///
|
|
/// A CalDAV server has no portable way to be told not to send invitations, so
|
|
/// inertness is structural: the mirror carries the guest list as data and not as
|
|
/// live scheduling properties. The alarm is the deliberate exception — stripping
|
|
/// it would destroy every reminder in the one calendar the user subscribes to.
|
|
///
|
|
/// Radicale implements no scheduling of its own, so this asserts on the bytes
|
|
/// stored rather than on a mail sink: with nothing to send mail, a quiet SMTP
|
|
/// port would prove nothing about the transform.
|
|
#[test]
|
|
fn a_mirror_carries_no_live_scheduling_properties() {
|
|
let Some(fixture) = fixture() else { return };
|
|
|
|
let run = fixture.calcalist.run(&["sync"]);
|
|
assert!(
|
|
run.succeeded(),
|
|
"sync failed\n{}\n{}",
|
|
run.stdout,
|
|
run.stderr
|
|
);
|
|
|
|
let mirror = fixture
|
|
.server
|
|
.stored("unified")
|
|
.into_iter()
|
|
.find(|item| item.contains("Planning"))
|
|
.expect("the meeting should have been mirrored");
|
|
|
|
for property in ["ATTENDEE;", "ATTENDEE:", "ORGANIZER;", "ORGANIZER:"] {
|
|
assert!(
|
|
!mirror
|
|
.lines()
|
|
.any(|line| line.trim_start().starts_with(property)),
|
|
"a live {property} reached the aggregate:\n{mirror}"
|
|
);
|
|
}
|
|
// The guests are still there, as something nothing will act on.
|
|
assert!(
|
|
mirror.contains("X-CALCALIST-ATTENDEES"),
|
|
"the guest list was lost:\n{mirror}"
|
|
);
|
|
assert!(
|
|
mirror.contains("guest@example.com"),
|
|
"the guest list was lost:\n{mirror}"
|
|
);
|
|
assert!(
|
|
mirror.contains("BEGIN:VALARM") && mirror.contains("TRIGGER:-PT15M"),
|
|
"the alarm did not survive:\n{mirror}"
|
|
);
|
|
|
|
// The source keeps its scheduling properties: only the aggregate is inert.
|
|
let source = fixture
|
|
.server
|
|
.stored("work")
|
|
.into_iter()
|
|
.find(|item| item.contains("Planning"))
|
|
.expect("the meeting should still be in its source");
|
|
assert!(source.contains("ATTENDEE"), "{source}");
|
|
assert!(source.contains("ORGANIZER"), "{source}");
|
|
}
|
|
|
|
/// A dry run must reach the servers to be worth anything, and change nothing.
|
|
#[test]
|
|
fn a_dry_run_reports_without_touching_anything() {
|
|
let Some(fixture) = fixture() else { return };
|
|
|
|
let dry = fixture.calcalist.run(&["sync", "--dry-run"]);
|
|
assert!(
|
|
dry.succeeded(),
|
|
"dry run failed\n{}\n{}",
|
|
dry.stdout,
|
|
dry.stderr
|
|
);
|
|
assert!(
|
|
dry.stdout.contains("everything: 2 mirrored"),
|
|
"the dry run should have seen both sources:\n{}",
|
|
dry.stdout
|
|
);
|
|
assert!(
|
|
fixture.server.stored("unified").is_empty(),
|
|
"the dry run published events"
|
|
);
|
|
|
|
// And the real cycle that follows is not confused by it.
|
|
let real = fixture.calcalist.run(&["sync"]);
|
|
assert!(
|
|
real.succeeded(),
|
|
"sync after a dry run failed\n{}\n{}",
|
|
real.stdout,
|
|
real.stderr
|
|
);
|
|
assert_eq!(fixture.server.stored("unified").len(), 2);
|
|
}
|
|
|
|
/// Removing an endpoint used to leave its events sitting in the state directory
|
|
/// with nothing managing them. They are now reported, and removed on request.
|
|
#[test]
|
|
fn a_retired_endpoints_mirror_is_reported_and_then_removed() {
|
|
let Some(fixture) = fixture() else { return };
|
|
|
|
assert!(fixture.calcalist.run(&["sync"]).succeeded());
|
|
let mirror = fixture.calcalist.state.join("calcalist/vdir/holidays");
|
|
assert!(mirror.is_dir(), "the feed should have been mirrored");
|
|
|
|
// The feed is dropped from the configuration, as a user would drop it.
|
|
std::fs::write(
|
|
&fixture.calcalist.config,
|
|
fixture.calcalist.config_without_feed(),
|
|
)
|
|
.expect("rewrite config");
|
|
|
|
let listed = fixture.calcalist.run(&["prune"]);
|
|
assert!(listed.succeeded(), "{}", listed.stderr);
|
|
assert!(
|
|
listed.stdout.contains("would remove") && listed.stdout.contains("holidays"),
|
|
"prune should say what it found:\n{}",
|
|
listed.stdout
|
|
);
|
|
assert!(mirror.is_dir(), "listing must not delete anything");
|
|
|
|
let removed = fixture.calcalist.run(&["prune", "--force"]);
|
|
assert!(removed.succeeded(), "{}", removed.stderr);
|
|
assert!(!mirror.exists(), "the orphaned mirror should be gone");
|
|
}
|
|
|
|
/// An aggregate with no `default_sink` leaves events created in it alone.
|
|
///
|
|
/// This is how a target calendar keeps events of its own: with nowhere
|
|
/// configured to file a new event, calcalist refuses to guess rather than
|
|
/// picking a source, so the event simply stays where it was written. Worth
|
|
/// pinning, because "left alone" has to hold on every subsequent cycle too —
|
|
/// an event that survived the first one and was swept up by the second would
|
|
/// be worse than never having worked.
|
|
#[test]
|
|
fn an_event_created_in_a_sinkless_aggregate_stays_where_it_is() {
|
|
let Some(fixture) = fixture() else { return };
|
|
|
|
// The fixture's aggregate has a default_sink; take it away.
|
|
let sinkless = std::fs::read_to_string(&fixture.calcalist.config)
|
|
.expect("read config")
|
|
.replace("default_sink = \"work\"\n", "");
|
|
std::fs::write(&fixture.calcalist.config, sinkless).expect("rewrite config");
|
|
|
|
assert!(fixture.calcalist.run(&["sync"]).succeeded());
|
|
|
|
// Now add an event by hand, as a calendar app would.
|
|
fixture
|
|
.server
|
|
.put_event("unified", "dentist.ics", HAND_WRITTEN);
|
|
|
|
for cycle in 1..=3 {
|
|
let run = fixture.calcalist.run(&["sync"]);
|
|
assert!(
|
|
run.succeeded(),
|
|
"cycle {cycle} failed\n{}\n{}",
|
|
run.stdout,
|
|
run.stderr
|
|
);
|
|
|
|
let published = fixture.server.stored("unified");
|
|
assert!(
|
|
published.iter().any(|item| item.contains("Dentist")),
|
|
"cycle {cycle}: the hand-written event was removed:\n{published:?}"
|
|
);
|
|
// It never reaches a source: there is nowhere it was told to go.
|
|
assert!(
|
|
!fixture
|
|
.server
|
|
.stored("work")
|
|
.iter()
|
|
.any(|item| item.contains("Dentist")),
|
|
"cycle {cycle}: the hand-written event leaked into the source"
|
|
);
|
|
}
|
|
}
|