181aad91e6
Sync and the server - crates/sync: revision ledger (stale writers conflict, edit beats delete), client planner, three-way merge, conflict names, path and hash gates - apps/notescraft-sync-server: pairing, device tokens, change log with long poll, blobs, accounts with many vaults, roles, invites, audit log, quota, GC, readiness, request ids - server history (revisions, restore, retention), chunked resumable uploads, live editing endpoints (SSE rooms, presence, flush through the ledger) - engine: HttpRemote, sync_once, SyncWorker, sync.* commands, share.* and server.* commands, live session client, note.capture, vault catalog and vaults.* commands Editing together - crates/collab: operational transformation core (TextOp, transform, client state machine, room, presence) - ui-egui: Settings > Sync, sync pill, conflicts, Share dialog, live pill and remote carets, launcher Connect and Join flows App shell - vault launcher (desktop and phone layouts) wired into the app host (--launcher, File > Vaults...) - compact (phone) layout with a bottom tab bar Verified with cargo xtask ci (fmt, clippy -D warnings, workspace tests, layers, tree, i18n). Android and iOS shells are not built: the build machine has no NDK or Xcode. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
255 lines
14 KiB
Rust
255 lines
14 KiB
Rust
//! Integration tests of the vault catalog: the repair of `vault_catalog`,
|
|
//! the launcher rows with their local sync pill, and the `vaults.*`
|
|
//! commands over a `Session` with its own settings folder. Nothing here
|
|
//! touches a real config file, a real vault's data or the network.
|
|
|
|
use std::time::{Duration, SystemTime};
|
|
|
|
use notescraft_engine::{
|
|
AppSettings, MAX_CATALOG, MAX_NAME_CHARS, Registry, Session, SyncBadge, VaultMeta, list_rows, load_at, repair_catalog, save_at, touch,
|
|
};
|
|
use serde_json::{Value, json};
|
|
|
|
mod common;
|
|
|
|
use common::{temp_config, temp_vault};
|
|
|
|
/// The launch budget most callers pass; big enough to never trip a local test.
|
|
fn generous() -> std::time::Duration {
|
|
std::time::Duration::from_millis(500)
|
|
}
|
|
|
|
/// The string form of a temp vault, a real folder on a real path.
|
|
fn path_of(vault: &common::TempVault) -> String {
|
|
vault.path().to_string_lossy().into_owned()
|
|
}
|
|
|
|
/// A catalog entry for a test; every field explicit.
|
|
fn vault(path: &str, name: Option<&str>, pinned: bool, last_opened_ms: u64) -> VaultMeta {
|
|
VaultMeta { path: path.to_owned(), name: name.map(str::to_owned), pinned, last_opened_ms }
|
|
}
|
|
|
|
/// Repair drops the empty paths and the duplicates (the newest copy wins)
|
|
/// and clears over-long names without dropping the vault.
|
|
#[test]
|
|
fn repair_drops_empty_duplicates_and_long_names() {
|
|
let long = vault("/long-name", Some("n".repeat(MAX_NAME_CHARS + 1).as_str()), false, 30);
|
|
let mut list = vec![
|
|
vault("", None, false, 9),
|
|
vault("/a", None, false, 1),
|
|
vault("/a", None, false, 9),
|
|
vault("/a", Some("newest name"), true, 5),
|
|
long,
|
|
vault("/c", Some(""), false, 2),
|
|
];
|
|
let notes = repair_catalog(&mut list);
|
|
let joined = notes.join("\n");
|
|
assert!(joined.contains("kept 3 of 6 entries"), "{joined}");
|
|
assert!(joined.contains("cleared the names of 2 entries"), "{joined}");
|
|
assert_eq!(list, vec![vault("/long-name", None, false, 30), vault("/a", None, false, 9), vault("/c", None, false, 2)], "{list:?}");
|
|
}
|
|
|
|
/// Past the cap, the pinned vaults and then the newest survive.
|
|
#[test]
|
|
fn repair_caps_keeping_pinned_then_newest() {
|
|
let mut list: Vec<VaultMeta> =
|
|
(0..=MAX_CATALOG).map(|index| vault(&format!("/v-{index:03}"), None, false, u64::try_from(index).unwrap_or_default())).collect();
|
|
list.push(vault("/pinned-old", None, true, 0));
|
|
let notes = repair_catalog(&mut list);
|
|
assert!(notes.iter().any(|note| note.contains("kept 200 of 202 entries")), "{notes:?}");
|
|
assert_eq!(list.len(), MAX_CATALOG);
|
|
assert_eq!(list.first().map(|meta| meta.path.as_str()), Some("/pinned-old"), "the pinned vault survives the cap first");
|
|
assert_eq!(list.get(1).map(|meta| meta.last_opened_ms), Some(u64::try_from(MAX_CATALOG).unwrap_or_default()), "then the newest");
|
|
assert!(!list.iter().any(|meta| meta.path == "/v-000"), "the oldest entry is gone");
|
|
}
|
|
|
|
/// Pinned vaults first, by name, then the rest, newest first; recents the
|
|
/// catalog does not know yet come last with no last-open time.
|
|
#[test]
|
|
fn rows_order_pinned_then_newest() {
|
|
let settings = AppSettings {
|
|
vault_catalog: vec![vault("/a", Some("Zz"), false, 10), vault("/b", Some("B"), true, 1), vault("/c", Some("C"), true, 5)],
|
|
recent_vaults: vec![String::from("/r")],
|
|
..AppSettings::default()
|
|
};
|
|
let rows = list_rows(&settings, generous());
|
|
let want = ["/b", "/c", "/a", "/r"];
|
|
let got: Vec<&str> = rows.iter().map(|row| row.path.as_str()).collect();
|
|
assert_eq!(got, want, "pinned by name, then newest, then the recents-only path");
|
|
assert_eq!(rows.last().map(|row| row.last_opened_ms), Some(0), "the recents-only path has no last-open time");
|
|
}
|
|
|
|
/// The row name: the user rename, else the folder's file name, else the path.
|
|
#[test]
|
|
fn row_name_prefers_rename_then_folder_name() {
|
|
let folder = temp_vault("my-vault");
|
|
let path = folder.path().to_string_lossy().into_owned();
|
|
let settings = AppSettings {
|
|
vault_catalog: vec![vault(&path, Some("My Library"), false, 7), vault("/", None, false, 6), vault("/gone/folder/", None, false, 5)],
|
|
..AppSettings::default()
|
|
};
|
|
let rows = list_rows(&settings, generous());
|
|
let by_path = |rows: &[notescraft_engine::VaultRow], path: &str| {
|
|
rows.iter().find(|row| row.path == path).map_or_else(|| panic!("no row for {path}"), |row| row.name.clone())
|
|
};
|
|
assert_eq!(by_path(&rows, &path), "My Library", "the rename wins");
|
|
assert_eq!(by_path(&rows, "/"), "/", "a path without a folder name is the name itself");
|
|
assert_eq!(by_path(&rows, "/gone/folder/"), "folder", "the folder's file name, even for a missing folder");
|
|
}
|
|
|
|
/// A folder that is not there is marked, not assumed.
|
|
#[test]
|
|
fn missing_folder_is_marked_not_existing() {
|
|
let folder = temp_vault("exists");
|
|
let path = folder.path().to_string_lossy().into_owned();
|
|
let settings = AppSettings { vault_catalog: vec![vault("/gone", None, false, 3), vault(&path, None, false, 2)], ..AppSettings::default() };
|
|
let rows = list_rows(&settings, generous());
|
|
let by_path = |path: &str| rows.iter().find(|row| row.path == path).map(|row| (row.exists, row.sync.clone()));
|
|
assert_eq!(by_path("/gone"), Some((false, SyncBadge::NotSynced)), "a missing folder is not there and cannot have a pill");
|
|
assert_eq!(by_path(&path), Some((true, SyncBadge::NotSynced)), "the folder exists, so it was checked");
|
|
}
|
|
|
|
/// A used-up budget stops the I/O but every row still comes back; with a
|
|
/// budget of zero at most the first row is checked.
|
|
#[test]
|
|
fn budget_stops_checking_but_returns_every_row() {
|
|
let folder = temp_vault("real");
|
|
let path = folder.path().to_string_lossy().into_owned();
|
|
let settings = AppSettings {
|
|
vault_catalog: vec![vault(&path, None, false, 2), vault("/missing-a", None, false, 1), vault("/missing-b", None, false, 0)],
|
|
..AppSettings::default()
|
|
};
|
|
let rows = list_rows(&settings, Duration::ZERO);
|
|
assert_eq!(rows.len(), 3, "every row comes back");
|
|
let lives: Vec<(&str, bool)> = rows.iter().map(|row| (row.path.as_str(), row.exists)).collect();
|
|
assert_eq!(lives, vec![(path.as_str(), true), ("/missing-a", true), ("/missing-b", true)], "the first is checked, the rest is assumed");
|
|
}
|
|
|
|
/// The badge comes from the local sync files only: no binding, a binding, a
|
|
/// malformed binding, a pending count and an oversized junk binding.
|
|
#[test]
|
|
fn sync_badge_from_local_files() {
|
|
let plain = temp_vault("pill-plain");
|
|
let bound = temp_vault("pill-bound");
|
|
let broken = temp_vault("pill-broken");
|
|
let waiting = temp_vault("pill-waiting");
|
|
let big = temp_vault("pill-big");
|
|
let sync_dir = |vault: &common::TempVault| vault.path().join(".notescraft").join("sync");
|
|
std::fs::create_dir_all(sync_dir(&bound)).expect("sync dir");
|
|
std::fs::create_dir_all(sync_dir(&broken)).expect("sync dir");
|
|
std::fs::create_dir_all(sync_dir(&waiting)).expect("sync dir");
|
|
std::fs::create_dir_all(sync_dir(&big)).expect("sync dir");
|
|
let binding = r#"{"server":"https://sync.example.net","vault_id":"v-one","role":"owner"}"#;
|
|
std::fs::write(sync_dir(&bound).join("binding.json"), binding).expect("binding");
|
|
std::fs::write(sync_dir(&broken).join("binding.json"), r#"{"server": 3}"#).expect("malformed binding");
|
|
std::fs::write(sync_dir(&waiting).join("binding.json"), binding).expect("binding");
|
|
std::fs::write(sync_dir(&waiting).join("pending.json"), r#"{"count":3}"#).expect("pending");
|
|
std::fs::write(sync_dir(&big).join("binding.json"), "x".repeat(1024 * 1024)).expect("1 MiB of junk");
|
|
|
|
let settings = AppSettings {
|
|
vault_catalog: vec![
|
|
vault(&path_of(&plain), None, false, 5),
|
|
vault(&path_of(&bound), None, false, 4),
|
|
vault(&path_of(&broken), None, false, 3),
|
|
vault(&path_of(&waiting), None, false, 2),
|
|
vault(&path_of(&big), None, false, 1),
|
|
],
|
|
..AppSettings::default()
|
|
};
|
|
let rows = list_rows(&settings, generous());
|
|
let pill = |vault: &common::TempVault| {
|
|
let row = rows.iter().find(|row| row.path == path_of(vault)).unwrap_or_else(|| panic!("no row for {}", path_of(vault)));
|
|
(row.sync.clone(), row.server.clone(), row.role.clone())
|
|
};
|
|
assert_eq!(pill(&plain), (SyncBadge::NotSynced, None, None), "no binding, no pill");
|
|
assert_eq!(pill(&bound), (SyncBadge::Synced, Some(String::from("sync.example.net")), Some(String::from("owner"))));
|
|
assert_eq!(pill(&broken), (SyncBadge::NotSynced, None, None), "a malformed binding is not usable");
|
|
assert_eq!(pill(&waiting), (SyncBadge::Waiting(3), Some(String::from("sync.example.net")), Some(String::from("owner"))));
|
|
assert_eq!(pill(&big), (SyncBadge::NotSynced, None, None), "a 1 MiB binding is ignored, not read in full");
|
|
}
|
|
/// `vaults.pin`, `vaults.rename` and `vaults.remove` store their changes, and
|
|
/// `vaults.list` shows them; `remove` forgets in the catalog and the recents.
|
|
#[test]
|
|
fn commands_pin_rename_remove_roundtrip() {
|
|
let vault = temp_vault("commands-vault");
|
|
let path = path_of(&vault);
|
|
let config = temp_config("commands-cfg");
|
|
let mut session = Session::open(vault.path()).expect("open the session");
|
|
session.use_settings_dir(config.path());
|
|
let mut registry = Registry::new();
|
|
notescraft_engine::register_core(&mut registry).expect("register the core commands");
|
|
let run = |session: &mut Session, id: &str, params: Value| registry.run(id, session, ¶ms).unwrap_or_else(|error| panic!("{id}: {error}"));
|
|
|
|
// Unknown vault: pinning remembers it, first on the list.
|
|
run(&mut session, "vaults.pin", json!({ "path": path, "pinned": true }));
|
|
let list = run(&mut session, "vaults.list", json!({}));
|
|
let rows = list.get("vaults").and_then(Value::as_array).unwrap_or_else(|| panic!("no vaults in {list}"));
|
|
assert_eq!(rows.len(), 1, "{list}");
|
|
assert_eq!(rows.first().and_then(|row| row.get("pinned")).and_then(Value::as_bool), Some(true), "{list}");
|
|
let folder_name = vault.path().file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default();
|
|
assert_eq!(rows.first().and_then(|row| row.get("name")).cloned(), Some(json!(folder_name)), "the folder's file name");
|
|
|
|
// Rename, clear with an empty name, refuse an over-long one.
|
|
run(&mut session, "vaults.rename", json!({ "path": path, "name": "Big Work" }));
|
|
let (settings, notes) = load_at(config.path());
|
|
assert_eq!(notes, &[] as &[String], "{notes:?}");
|
|
assert_eq!(settings.vault_catalog.first().map(|meta| meta.name.clone()), Some(Some(String::from("Big Work"))), "{settings:?}");
|
|
assert_eq!(settings.recent_vaults, Vec::<String>::new(), "pinning did not invent a recents entry");
|
|
let refused = registry.run("vaults.rename", &mut session, &json!({ "path": path, "name": "x".repeat(MAX_NAME_CHARS + 1) }));
|
|
let error = refused.err().unwrap_or_else(|| panic!("the over-long name was accepted"));
|
|
assert!(error.to_string().contains("longer than"), "{error}");
|
|
run(&mut session, "vaults.rename", json!({ "path": path, "name": "" }));
|
|
let (cleared, _) = load_at(config.path());
|
|
assert_eq!(cleared.vault_catalog.first().map(|meta| meta.name.clone()), Some(None), "the empty name cleared the rename");
|
|
|
|
// Remove forgets in both lists and never touches the folder.
|
|
let settings = AppSettings { recent_vaults: vec![path.clone()], ..settings };
|
|
save_at(config.path(), &settings).expect("seed the recents");
|
|
run(&mut session, "vaults.remove", json!({ "path": path }));
|
|
let (after, _) = load_at(config.path());
|
|
assert_eq!(after.vault_catalog, Vec::<VaultMeta>::new(), "{after:?}");
|
|
assert_eq!(after.recent_vaults, Vec::<String>::new(), "{after:?}");
|
|
assert!(vault.path().is_dir(), "the folder is untouched");
|
|
}
|
|
|
|
/// The `vault_catalog` list survives the settings store round trip.
|
|
#[test]
|
|
fn settings_survive_save_and_load() {
|
|
let config = temp_config("catalog-roundtrip");
|
|
let now = u64::try_from(SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).ok().map(|time| time.as_millis()).unwrap_or_default()).unwrap_or_default();
|
|
let settings = AppSettings {
|
|
recent_vaults: vec![String::from("/vault-one")],
|
|
vault_catalog: vec![vault("/vault-one", Some("One"), true, now), vault("/vault-two", None, false, now.saturating_sub(1))],
|
|
..AppSettings::default()
|
|
};
|
|
save_at(config.path(), &settings).expect("save the catalog");
|
|
let (loaded, notes) = load_at(config.path());
|
|
assert_eq!(notes, &[] as &[String], "{notes:?}");
|
|
assert_eq!(loaded, settings, "the whole document, catalog included");
|
|
|
|
// `touch` adds the second vault to the catalog and moves it to the recents front.
|
|
let mut settings =
|
|
AppSettings { vault_catalog: vec![vault("/vault-one", None, false, now)], recent_vaults: vec![String::from("/vault-one")], ..AppSettings::default() };
|
|
touch(&mut settings, "/vault-two", now.checked_add(5).unwrap_or_default());
|
|
assert_eq!(settings.recent_vaults.first().map(String::as_str), Some("/vault-two"), "the new vault leads the recents");
|
|
assert_eq!(settings.recent_vaults.len(), 2, "the old recents stay");
|
|
assert!(settings.vault_catalog.iter().any(|meta| meta.path == "/vault-two" && meta.last_opened_ms == now.checked_add(5).unwrap_or_default()));
|
|
}
|
|
|
|
/// A `config.toml` from before the field existed loads with an empty catalog.
|
|
#[test]
|
|
fn old_settings_file_without_the_field_still_loads() {
|
|
let config = temp_config("catalog-old-file");
|
|
std::fs::write(config.path().join("config.toml"), b"schema_version = 1\n\nrecent_vaults = [\"/old-vault\"]\n\n[session]\ndaily_folder = \"Notes\"\n")
|
|
.expect("write the old file");
|
|
let (settings, notes) = load_at(config.path());
|
|
assert_eq!(settings.vault_catalog, Vec::<VaultMeta>::new(), "no catalog, no problem");
|
|
assert_eq!(settings.recent_vaults, [String::from("/old-vault")], "the old keys still load");
|
|
assert_eq!(notes, &[] as &[String], "{notes:?}");
|
|
// And the next save adds the field harmlessly.
|
|
save_at(config.path(), &settings).expect("save");
|
|
let (again, notes) = load_at(config.path());
|
|
assert_eq!(again, settings);
|
|
assert_eq!(notes, &[] as &[String], "{notes:?}");
|
|
}
|