//! 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 { 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" ); } } /// Discovery, against a server that really has two calendars. /// /// The command exists because the CalDAV `url` must name the collection exactly /// and providers rarely show it. What it prints therefore has to be usable /// as-is, which is what the parse-back below actually checks. #[test] fn discover_lists_the_servers_calendars_as_pasteable_config() { let Some(fixture) = fixture() else { return }; let run = fixture.calcalist.run(&[ "discover", &format!( "http://127.0.0.1:{}/{}/", fixture.server.port, support::USER ), "--username", support::USER, "--secret-command", "printf password", ]); assert!( run.succeeded(), "discover failed\n{}\n{}", run.stdout, run.stderr ); assert!(run.stdout.contains("id = \"work\""), "{}", run.stdout); assert!(run.stdout.contains("id = \"unified\""), "{}", run.stdout); // The URL has to come back absolute, not as the bare path pimsync reports. assert!( run.stdout .contains(&format!("http://127.0.0.1:{}/", fixture.server.port)), "{}", run.stdout ); // The point of the command: what it prints is valid configuration. let config = format!("version = 1\n{}", run.stdout); let parsed: toml::Value = toml::from_str(&config).expect("the output should be valid TOML"); let endpoints = parsed["endpoint"].as_array().expect("endpoints"); assert_eq!(endpoints.len(), 2, "{}", run.stdout); assert_eq!(endpoints[0]["type"].as_str(), Some("caldav")); assert_eq!( endpoints[0]["secret_command"].as_str(), Some("printf password") ); } /// An unreachable server has to say so rather than print an empty result that /// looks like "this server has no calendars". #[test] fn discover_reports_a_server_it_cannot_reach() { if !support::missing_binaries().is_empty() { eprintln!("skipped: run under devbox"); return; } let root = tempfile::tempdir().expect("temp"); let calcalist = Calcalist::new(root.path(), "version = 1\n"); // Port 9 is discard: it refuses or blackholes, and nothing listens for DAV. let run = calcalist.run(&[ "discover", "http://127.0.0.1:9/calendars/me/", "--username", "me", ]); assert!( !run.succeeded(), "it should not claim success\n{}", run.stdout ); assert!( !run.stderr.is_empty(), "the failure should be explained: {run:?}", run = run.stderr ); } /// The lock is what stops a timer firing into a hand-run cycle. /// /// The lock is taken here rather than by racing two real syncs, which would be /// timing-dependent and would pass by accident most of the time. #[test] fn a_second_cycle_is_refused_while_one_is_running() { let Some(fixture) = fixture() else { return }; assert!(fixture.calcalist.run(&["sync"]).succeeded()); let lock_path = fixture.calcalist.state.join("calcalist/lock"); let held = std::fs::OpenOptions::new() .read(true) .write(true) .open(&lock_path) .expect("the lock file should exist after a cycle"); rustix::fs::flock(&held, rustix::fs::FlockOperation::NonBlockingLockExclusive) .expect("the test should be able to take the lock"); let refused = fixture.calcalist.run(&["sync"]); assert!(!refused.succeeded(), "{}", refused.stdout); assert!( refused.stderr.contains("already running"), "the refusal should say why: {}", refused.stderr ); drop(held); assert!( fixture.calcalist.run(&["sync"]).succeeded(), "the lock should be free again" ); }