//! The real engine sync client against the real server over HTTP: two vault folders converge. use std::path::{Path, PathBuf}; use notescraft_engine::{HttpRemote, SyncState, pair, sync_once}; use notescraft_sync_server::{AppState, Store, router}; use notescraft_vault::Vault; const ADMIN: &str = "test-admin-token-0123456789abcdef"; fn temp(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("nc-e2e-{name}-{}", std::process::id())); std::fs::remove_dir_all(&dir).ok(); std::fs::create_dir_all(&dir).expect("dir"); dir } /// Starts the server on an ephemeral port and returns its base url. fn start(data: &Path) -> String { std::fs::create_dir_all(data.join("blobs")).expect("blobs"); let state = AppState::new(Store::open(&data.join("sync.db")).expect("db"), data.join("blobs"), ADMIN); let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); listener.set_nonblocking(true).expect("nonblocking"); let port = listener.local_addr().expect("addr").port(); std::thread::spawn(move || { let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build().expect("runtime"); runtime.block_on(async { let listener = tokio::net::TcpListener::from_std(listener).expect("listener"); axum::serve(listener, router(state)).await.ok(); }); }); format!("http://127.0.0.1:{port}") } fn admin(base: &str, path: &str) -> serde_json::Value { let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into(); let mut response = agent.post(format!("{base}{path}")).header("Authorization", format!("Bearer {ADMIN}")).send_empty().expect("admin call"); response.body_mut().read_json().expect("json") } struct Device { vault: Vault, remote: HttpRemote, state: SyncState, name: &'static str, } impl Device { /// A local vault over an already-paired device token. `name` keeps temp folders apart. fn new(base: &str, vault_id: &str, token: &str, name: &'static str) -> Self { let root = temp(name); let vault = Vault::open(&root).expect("vault"); Self { vault, remote: HttpRemote::new(base, vault_id, token), state: SyncState::default(), name } } fn write(&self, path: &str, text: &str) { self.vault.write(path, text.as_bytes()).expect("write"); } fn read(&self, path: &str) -> String { String::from_utf8(self.vault.read(path).expect("read")).expect("utf8") } fn sync(&mut self) -> notescraft_engine::SyncReport { let report = sync_once(&self.vault, &self.remote, &mut self.state, self.name, "2026-10-20").expect("sync"); self.state.save(&self.vault).expect("save state"); report } } /// Issues a pairing code (admin) for the vault, bound to `user` when given; issuing /// for an unknown vault id creates the vault (accounts have no other bootstrap). fn issue(base: &str, vault_id: &str, user: Option<&str>) -> String { let path = user.map_or_else(|| format!("/v1/vaults/{vault_id}/pairing"), |user| format!("/v1/vaults/{vault_id}/pairing?user={user}")); admin(base, &path)["code"].as_str().expect("code").to_owned() } /// Redeems a code with raw HTTP to also learn the account the device joined. fn pair_raw(base: &str, code: &str, name: &str) -> (String, String) { let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into(); let mut response = agent.post(format!("{base}/v1/devices")).send_json(serde_json::json!({"code": code, "name": name})).expect("pair"); let device: serde_json::Value = response.body_mut().read_json().expect("json"); (device["token"].as_str().expect("token").to_owned(), device["user_id"].as_str().expect("user id").to_owned()) } #[test] fn two_devices_converge_through_the_real_server() { let base = start(&temp("server-data")); // Accounts: the first pairing creates the vault and its owner account; the second // device joins the same account through a `?user=`-bound pairing code for the // same vault, so one token works for the account, not just one vault. let vault_id = format!("v-e2e-{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos())); let (phone_token, user_id) = pair_raw(&base, &issue(&base, &vault_id, None), "phone"); let laptop_pairing = pair(&base, &issue(&base, &vault_id, Some(&user_id)), "laptop").expect("pair"); assert_eq!(laptop_pairing.vault_id, vault_id); let mut phone = Device::new(&base, &vault_id, &phone_token, "phone"); let mut laptop = Device::new(&base, &laptop_pairing.vault_id, &laptop_pairing.token, "laptop"); phone.write("notes/a.md", "one\ntwo\nthree\nfour\nfive\n"); phone.write("b.md", "bee\n"); assert_eq!(phone.sync().uploaded.len(), 2); assert_eq!(laptop.sync().downloaded.len(), 2); assert_eq!(laptop.read("notes/a.md"), "one\ntwo\nthree\nfour\nfive\n"); // Offline edits on different lines merge without a conflict copy. phone.write("notes/a.md", "ONE\ntwo\nthree\nfour\nfive\n"); laptop.write("notes/a.md", "one\ntwo\nthree\nfour\nFIVE\n"); assert_eq!(phone.sync().uploaded, vec!["notes/a.md".to_owned()]); let report = laptop.sync(); assert_eq!(report.merged, vec!["notes/a.md".to_owned()]); assert_eq!(report.conflicts, Vec::::new()); phone.sync(); assert_eq!(phone.read("notes/a.md"), "ONE\ntwo\nthree\nfour\nFIVE\n"); assert_eq!(laptop.read("notes/a.md"), "ONE\ntwo\nthree\nfour\nFIVE\n"); // The same line edited twice keeps both versions. phone.write("b.md", "phone\n"); laptop.write("b.md", "laptop\n"); phone.sync(); let report = laptop.sync(); assert_eq!(report.conflicts.len(), 1); phone.sync(); assert_eq!(phone.read("b.md"), "phone\n"); assert_eq!(phone.read(&report.conflicts[0]), "laptop\n"); // Delete on one device reaches the other; a second run is quiet. phone.vault.delete_to_trash("b.md").expect("delete"); phone.sync(); laptop.sync(); assert!(laptop.vault.read("b.md").is_err()); assert_eq!(laptop.sync(), notescraft_engine::SyncReport::default()); }