An aggregate with no default_sink already left events created in it alone, but treated doing so as a failure: it reported a skip per event per cycle saying no sink was configured, as though something had gone wrong. Nothing had. An aggregate is also a calendar, and holding events of its own is a legitimate way to use one. Skipped::NoSink is replaced by a kept_local count, reported plainly. `@local` joins the routing markers, so the mode also works per-event where a default_sink is configured — which was not previously expressible. Unlike every other marker it is deliberately not stripped: the others have done their job once the event reaches its source, whereas this one never leaves, so it has to stay legible for the next cycle to reach the same decision. `local` is therefore a reserved endpoint id, and configuring one is refused. This also fixes a real defect. `retarget` rebuilds the new target from the recorded links, which cover derived events only, so an event belonging to the aggregate itself did not follow the move — it stayed on the calendar being left behind while everything around it moved on, quietly. It is now carried across, since there is nothing to re-derive it from, and the new target's scheduling rule is applied on the way: this is a write to an aggregate like any other, and a guest list carried live onto a server that schedules would mail everyone on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
385 lines
13 KiB
Rust
385 lines
13 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";
|
|
|
|
/// The same event, asking to be left where it was written.
|
|
const KEPT_LOCAL: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//phone//EN\r\n\
|
|
BEGIN:VEVENT\r\nUID:kept@phone\r\nDTSTAMP:20260101T000000Z\r\n\
|
|
DTSTART:20260912T140000Z\r\nDTEND:20260912T150000Z\r\nSUMMARY:Haircut\r\n\
|
|
DESCRIPTION:Around the corner\\n\\n@local\r\nEND: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"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// `@local` opts one event out of a default sink that would otherwise take it.
|
|
///
|
|
/// The marker has to survive, unlike every other one: it is stripped markers
|
|
/// that have done their job on arrival, whereas this event never leaves, so the
|
|
/// next cycle has to be able to reach the same decision.
|
|
#[test]
|
|
fn the_local_marker_keeps_an_event_out_of_a_configured_sink() {
|
|
let Some(fixture) = fixture() else { return };
|
|
|
|
assert!(fixture.calcalist.run(&["sync"]).succeeded());
|
|
fixture
|
|
.server
|
|
.put_event("unified", "haircut.ics", KEPT_LOCAL);
|
|
|
|
for cycle in 1..=3 {
|
|
let run = fixture.calcalist.run(&["sync"]);
|
|
assert!(
|
|
run.succeeded(),
|
|
"cycle {cycle} failed\n{}\n{}",
|
|
run.stdout,
|
|
run.stderr
|
|
);
|
|
assert!(
|
|
run.stdout.contains("1 event(s) kept local"),
|
|
"cycle {cycle} should report it as kept, not skipped:\n{}",
|
|
run.stdout
|
|
);
|
|
|
|
let kept = fixture
|
|
.server
|
|
.stored("unified")
|
|
.into_iter()
|
|
.find(|item| item.contains("Haircut"))
|
|
.expect("the event should still be in the aggregate");
|
|
// Stripping it would let the default sink claim the event next cycle.
|
|
assert!(kept.contains("@local"), "cycle {cycle}: {kept}");
|
|
|
|
assert!(
|
|
!fixture
|
|
.server
|
|
.stored("work")
|
|
.iter()
|
|
.any(|item| item.contains("Haircut")),
|
|
"cycle {cycle}: it reached the default sink anyway"
|
|
);
|
|
}
|
|
}
|