use std::{fs, path::Path, process::Command};
const DAV: &str = "http://127.0.0.1:5232";
const MERGE_BOOK: &str = "merge";
const BIND_BOOK: &str = "bind";
const SEED_BOOK: &str = "seed";
const AUTHORITY_BOOK: &str = "authority";
const SOURCE: &str = "test";
const TARGET: &str = "test2";
fn card(uid: &str, tel: &str) -> String {
format!(
"BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
UID:{uid}\r\n\
FN:Jane Doe\r\n\
TEL:{tel}\r\n\
END:VCARD\r\n",
)
}
fn noted_card(uid: &str, tel: &str, note: &str) -> String {
format!(
"BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
UID:{uid}\r\n\
FN:Jane Doe\r\n\
TEL:{tel}\r\n\
NOTE:{note}\r\n\
END:VCARD\r\n",
)
}
#[test]
#[ignore = "requires a Radicale instance (./tests/radicale.sh) on :5232 and --ignored"]
fn a_card_changed_on_both_endpoints_merges_or_parks_and_never_overwrites() {
let tmp = tempfile::tempdir().expect("temp dir");
let root = tmp.path();
let state = root.join("state");
let config = root.join("config.toml");
fs::create_dir_all(&state).unwrap();
fs::write(&config, account()).unwrap();
create_book(root, SOURCE, MERGE_BOOK);
create_book(root, TARGET, MERGE_BOOK);
put(
root,
SOURCE,
MERGE_BOOK,
"card-1",
¬ed_card("card-1", "+1", "old"),
);
put(root, SOURCE, MERGE_BOOK, "card-2", &card("card-2", "+1"));
neverest(&["init", "-a", "div"], &config, &state, 0);
sync(&config, &state, MERGE_BOOK, 0);
assert_eq!(
get(SOURCE, MERGE_BOOK, "card-2"),
get(TARGET, MERGE_BOOK, "card-2"),
"the seed crossed",
);
put(
root,
SOURCE,
MERGE_BOOK,
"card-1",
¬ed_card("card-1", "+2", "old"),
);
put(
root,
TARGET,
MERGE_BOOK,
"card-1",
¬ed_card("card-1", "+1", "new"),
);
put(root, SOURCE, MERGE_BOOK, "card-2", &card("card-2", "+2"));
put(root, TARGET, MERGE_BOOK, "card-2", &card("card-2", "+3"));
let report = sync(&config, &state, MERGE_BOOK, 2);
assert!(
report.contains(r#""outstandingConflicts":1"#),
"the divergence is parked and counted, not resolved; report was:\n{report}",
);
assert!(
report.contains(r#""id":"card-2.vcf""#),
"the parked item is named; report was:\n{report}",
);
for side in [SOURCE, TARGET] {
let merged = get(side, MERGE_BOOK, "card-1");
assert!(
merged.contains("TEL:+2") && merged.contains("NOTE:new"),
"{side} holds the merged card-1; it held:\n{merged}",
);
}
assert!(
get(SOURCE, MERGE_BOOK, "card-2").contains("TEL:+2"),
"the source keeps its own card-2 edit",
);
assert!(
get(TARGET, MERGE_BOOK, "card-2").contains("TEL:+3"),
"the target keeps its own card-2 edit",
);
let report = sync(&config, &state, MERGE_BOOK, 2);
assert!(
!report.contains(r#""kind":"update""#),
"a rerun pushes neither body over the other; report was:\n{report}",
);
assert!(
get(SOURCE, MERGE_BOOK, "card-2").contains("TEL:+2")
&& get(TARGET, MERGE_BOOK, "card-2").contains("TEL:+3"),
"both edits survive a rerun",
);
let listed = neverest(
&["conflict", "list", "-a", "div", "--json"],
&config,
&state,
0,
);
assert!(
listed.contains("card-2.vcf"),
"the divergence is listable; listing was:\n{listed}",
);
}
#[test]
#[ignore = "requires a Radicale instance (./tests/radicale.sh) on :5232 and --ignored"]
fn a_one_way_account_overwrites_the_target_instead_of_parking_the_divergence() {
let tmp = tempfile::tempdir().expect("temp dir");
let root = tmp.path();
let state = root.join("state");
let config = root.join("config.toml");
fs::create_dir_all(&state).unwrap();
fs::write(&config, format!("{}one-way = true\n", account())).unwrap();
create_book(root, SOURCE, AUTHORITY_BOOK);
create_book(root, TARGET, AUTHORITY_BOOK);
put(
root,
SOURCE,
AUTHORITY_BOOK,
"card-1",
&card("card-1", "+1"),
);
neverest(&["init", "-a", "div"], &config, &state, 0);
sync(&config, &state, AUTHORITY_BOOK, 0);
assert_eq!(
get(SOURCE, AUTHORITY_BOOK, "card-1"),
get(TARGET, AUTHORITY_BOOK, "card-1"),
"the seed crossed",
);
put(
root,
SOURCE,
AUTHORITY_BOOK,
"card-1",
&card("card-1", "+2"),
);
put(
root,
TARGET,
AUTHORITY_BOOK,
"card-1",
&card("card-1", "+3"),
);
let report = sync(&config, &state, AUTHORITY_BOOK, 0);
assert!(
report.contains(r#""outstandingConflicts":0"#),
"an authoritative source leaves nothing waiting; report was:\n{report}",
);
assert!(
!report.contains(r#""conflicts""#),
"and names nothing parked; report was:\n{report}",
);
assert!(
get(TARGET, AUTHORITY_BOOK, "card-1").contains("TEL:+2"),
"the target takes the source's body, its own edit discarded",
);
assert!(
get(SOURCE, AUTHORITY_BOOK, "card-1").contains("TEL:+2"),
"and the source is never written back to, which is what makes it \
authoritative",
);
let report = sync(&config, &state, AUTHORITY_BOOK, 0);
assert!(
!report.contains(r#""kind":"update""#),
"a rerun pushes nothing; report was:\n{report}",
);
assert!(
get(SOURCE, AUTHORITY_BOOK, "card-1").contains("TEL:+2")
&& get(TARGET, AUTHORITY_BOOK, "card-1").contains("TEL:+2"),
"and both endpoints stay on the body the source decided",
);
let listed = neverest(
&["conflict", "list", "-a", "div", "--json"],
&config,
&state,
0,
);
assert!(
!listed.contains("card-1.vcf"),
"nothing was recorded for a person to settle; listing was:\n{listed}",
);
}
#[test]
#[ignore = "requires a Radicale instance (./tests/radicale.sh) on :5232 and --ignored"]
fn two_endpoints_already_holding_one_card_bind_it_to_a_single_item() {
let tmp = tempfile::tempdir().expect("temp dir");
let root = tmp.path();
let state = root.join("state");
let config = root.join("config.toml");
fs::create_dir_all(&state).unwrap();
fs::write(&config, account()).unwrap();
create_book(root, SOURCE, BIND_BOOK);
create_book(root, TARGET, BIND_BOOK);
put(root, SOURCE, BIND_BOOK, "card-1", &card("card-1", "+1"));
put(root, TARGET, BIND_BOOK, "card-1", &card("card-1", "+1"));
neverest(&["init", "-a", "div"], &config, &state, 0);
let report = sync(&config, &state, BIND_BOOK, 0);
assert!(
!report.contains("dup:"),
"the second holder is bound, not minted a key of its own; report was:\n{report}",
);
assert!(
!report.contains(r#""refused""#) && !report.contains(r#""rejected""#),
"neither endpoint is asked to take a card it already holds; report was:\n{report}",
);
let items = store_items(&state, BIND_BOOK);
assert!(
items.contains(r#""link_id":"card-1""#) && !items.contains("dup:"),
"one identity is one item; store held:\n{items}",
);
assert_eq!(
items.matches(r#""link_id""#).count(),
1,
"and only one; store held:\n{items}",
);
let report = sync(&config, &state, BIND_BOOK, 0);
assert!(
report.contains(r#""patch":[]"#),
"a bound pair is quiescent; report was:\n{report}",
);
}
#[test]
#[ignore = "requires a Radicale instance (./tests/radicale.sh) on :5232 and --ignored"]
fn two_endpoints_holding_one_card_under_two_bodies_park_it_and_never_overwrite() {
let tmp = tempfile::tempdir().expect("temp dir");
let root = tmp.path();
let state = root.join("state");
let config = root.join("config.toml");
fs::create_dir_all(&state).unwrap();
fs::write(&config, account()).unwrap();
create_book(root, SOURCE, SEED_BOOK);
create_book(root, TARGET, SEED_BOOK);
for uid in ["card-1", "card-2"] {
put(root, SOURCE, SEED_BOOK, uid, &card(uid, "+1"));
put(root, TARGET, SEED_BOOK, uid, &card(uid, "+9"));
}
neverest(&["init", "-a", "div"], &config, &state, 0);
let report = sync(&config, &state, SEED_BOOK, 2);
assert!(
report.contains(r#""outstandingConflicts":2"#),
"both divergences are parked and counted; report was:\n{report}",
);
assert!(
report.contains(r#""id":"card-1.vcf""#) && report.contains(r#""id":"card-2.vcf""#),
"both parked items are named; report was:\n{report}",
);
assert!(
!report.contains(r#""kind":"update""#),
"no body is pushed over the other; report was:\n{report}",
);
for uid in ["card-1", "card-2"] {
assert!(
get(SOURCE, SEED_BOOK, uid).contains("TEL:+1"),
"the source keeps its own {uid}",
);
assert!(
get(TARGET, SEED_BOOK, uid).contains("TEL:+9"),
"the target keeps its own {uid}, which is the body the run would \
have replaced",
);
}
let report = sync(&config, &state, SEED_BOOK, 2);
assert!(
!report.contains(r#""kind":"update""#) && !report.contains(r#""conflicts""#),
"a rerun pushes nothing and re-announces nothing; report was:\n{report}",
);
assert!(
get(TARGET, SEED_BOOK, "card-1").contains("TEL:+9"),
"the target's body survives a rerun",
);
let listed = neverest(
&["conflict", "list", "-a", "div", "--json"],
&config,
&state,
0,
);
let local = conflict_id(&listed, "card-1.vcf").to_string();
let remote = conflict_id(&listed, "card-2.vcf").to_string();
neverest(
&["conflict", "resolve", "-a", "div", &local, "--prefer-local"],
&config,
&state,
0,
);
neverest(
&[
"conflict",
"resolve",
"-a",
"div",
&remote,
"--prefer-remote",
],
&config,
&state,
0,
);
sync(&config, &state, SEED_BOOK, 0);
for side in [SOURCE, TARGET] {
assert!(
get(side, SEED_BOOK, "card-1").contains("TEL:+1"),
"{side} converged on the body --prefer-local settled on",
);
assert!(
get(side, SEED_BOOK, "card-2").contains("TEL:+9"),
"{side} converged on the body --prefer-remote settled on",
);
}
}
fn conflict_id(listing: &str, handle: &str) -> i64 {
let listed: serde_json::Value = serde_json::from_str(listing).expect("conflict listing");
listed["conflicts"]
.as_array()
.expect("a conflict array")
.iter()
.find(|conflict| conflict["handle"] == handle)
.unwrap_or_else(|| panic!("no conflict for {handle} in:\n{listing}"))["id"]
.as_i64()
.expect("a conflict id")
}
fn account() -> String {
format!(
"[accounts.div]\n\
retain = true\n\
sources.a.carddav.server = \"{DAV}/\"\n\
sources.a.carddav.auth.basic.username = \"{SOURCE}\"\n\
sources.a.carddav.auth.basic.password.raw = \"{SOURCE}\"\n\
targets.b.carddav.server = \"{DAV}/\"\n\
targets.b.carddav.auth.basic.username = \"{TARGET}\"\n\
targets.b.carddav.auth.basic.password.raw = \"{TARGET}\"\n",
)
}
fn create_book(root: &Path, user: &str, book: &str) {
let output = Command::new("curl")
.args(["-sS", "-o", "/dev/null", "-X", "DELETE"])
.args(["-u", &format!("{user}:{user}")])
.arg(format!("{DAV}/{user}/{book}/"))
.output()
.expect("spawn curl delete book");
assert!(
output.status.success(),
"DELETE of {user}'s address book failed",
);
let body = root.join(format!("mkcol-{user}.xml"));
fs::write(
&body,
format!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\
<D:mkcol xmlns:D=\"DAV:\" xmlns:C=\"urn:ietf:params:xml:ns:carddav\">\
<D:set><D:prop>\
<D:resourcetype><D:collection/><C:addressbook/></D:resourcetype>\
<D:displayname>{book}</D:displayname>\
</D:prop></D:set></D:mkcol>",
),
)
.unwrap();
let output = Command::new("curl")
.args(["-fsS", "-X", "MKCOL", "-u", &format!("{user}:{user}")])
.args(["-H", "Content-Type: application/xml; charset=utf-8"])
.arg("--data-binary")
.arg(format!("@{}", body.display()))
.arg(format!("{DAV}/{user}/{book}/"))
.output()
.expect("spawn curl mkcol");
assert!(
output.status.success(),
"MKCOL {user}/{book} failed: {}",
String::from_utf8_lossy(&output.stderr),
);
}
fn put(root: &Path, user: &str, book: &str, id: &str, body: &str) {
let file = root.join(format!("{user}-{id}.vcf"));
fs::write(&file, body).unwrap();
let output = Command::new("curl")
.args(["-fsS", "-X", "PUT", "-u", &format!("{user}:{user}")])
.args(["-H", "Content-Type: text/vcard; charset=utf-8"])
.arg("--data-binary")
.arg(format!("@{}", file.display()))
.arg(format!("{DAV}/{user}/{book}/{id}.vcf"))
.output()
.expect("spawn curl put");
assert!(
output.status.success(),
"PUT {user}/{book}/{id} failed: {}",
String::from_utf8_lossy(&output.stderr),
);
}
fn get(user: &str, book: &str, id: &str) -> String {
let output = Command::new("curl")
.args(["-fsS", "-u", &format!("{user}:{user}")])
.arg(format!("{DAV}/{user}/{book}/{id}.vcf"))
.output()
.expect("spawn curl get");
assert!(
output.status.success(),
"GET {user}/{book}/{id} failed: {}",
String::from_utf8_lossy(&output.stderr),
);
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn sync(config: &Path, state: &Path, book: &str, code: i32) -> String {
neverest(
&["sync", "-a", "div", "-m", book, "--json"],
config,
state,
code,
)
}
fn store_items(state: &Path, book: &str) -> String {
let store = state.join("neverest").join("div");
let output = Command::new("pimdir")
.args(["--store", &store.to_string_lossy()])
.args(["item", "list", &format!("a/{book}"), "--json"])
.output()
.expect("spawn pimdir (cargo install --path ../io-pimdir --features cli)");
assert!(
output.status.success(),
"`pimdir item list a/{book}` failed:\n{}",
String::from_utf8_lossy(&output.stderr),
);
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn neverest(args: &[&str], config: &Path, state: &Path, code: i32) -> String {
let output = Command::new(env!("CARGO_BIN_EXE_neverest"))
.args(["-c", &config.to_string_lossy()])
.args(args)
.env("XDG_STATE_HOME", state)
.output()
.expect("spawn neverest");
assert_eq!(
output.status.code(),
Some(code),
"`neverest {}` ended on the wrong code:\n--- stdout ---\n{}\n--- stderr ---\n{}",
args.join(" "),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
String::from_utf8_lossy(&output.stdout).into_owned()
}