Files
wanderbooks/crates/ledger-mcp/tests/startup.rs
T
swaitsandClaude Opus 5.5 a833229d87 tests: every test dir is removed when its test ends, pass or panic
Many test helpers removed their temp dir only before the test, or only
on pass, so runs left them behind: 117,000 filled /tmp (RAM here) and
stopped a mutation run. Each now holds a guard that removes the dir on
drop, unwind included; servers started on a test dir hold its guard
until they stop. A full cargo test --workspace leaves 0 dirs (859
before). just sweep-test-tmp stays for runs killed mid-test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 20:47:44 -06:00

427 lines
15 KiB
Rust

//! Starting the server: arguments, where the books and settings are found,
//! and what happens when another writer has the books.
use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const BIN: &str = env!("CARGO_BIN_EXE_ledger-mcp");
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(std::path::PathBuf);
impl std::ops::Deref for TestDir {
type Target = std::path::Path;
fn deref(&self) -> &std::path::Path {
&self.0
}
}
impl AsRef<std::path::Path> for TestDir {
fn as_ref(&self) -> &std::path::Path {
&self.0
}
}
impl AsRef<std::ffi::OsStr> for TestDir {
fn as_ref(&self) -> &std::ffi::OsStr {
self.0.as_os_str()
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn temp(tag: &str) -> TestDir {
let d = std::env::temp_dir().join(format!("ledger-mcp-start-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
TestDir(d)
}
/// Start the server, initialize, call one tool, and return its text
/// result (or the error text), then end the session.
fn one_call(cmd: &mut Command, tool: &str, args: serde_json::Value) -> String {
let mut child = cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut stdin = child.stdin.take().unwrap();
let mut out = BufReader::new(child.stdout.take().unwrap());
let mut send = |v: serde_json::Value| writeln!(stdin, "{v}").unwrap();
send(
serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}),
);
let mut line = String::new();
out.read_line(&mut line).unwrap();
send(serde_json::json!({"jsonrpc":"2.0","method":"notifications/initialized","params":{}}));
send(
serde_json::json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":tool,"arguments":args}}),
);
line.clear();
out.read_line(&mut line).unwrap();
drop(stdin);
let _ = child.wait();
line
}
#[test]
fn help_names_the_options() {
let out = Command::new(BIN).arg("--help").output().unwrap();
assert!(out.status.success());
let text = String::from_utf8_lossy(&out.stdout) + String::from_utf8_lossy(&out.stderr);
for opt in ["--data-dir", "--books-toml", "--config", "--http"] {
assert!(text.contains(opt), "--help lacks {opt}: {text}");
}
}
/// With no --data-dir the books live in ~/.local/share/wanderbooks.
#[test]
fn the_default_data_dir_is_under_home() {
let home = temp("home");
one_call(
Command::new(BIN).env("HOME", &home),
"books.chart",
serde_json::json!({}),
);
assert!(
home.join(".local/share/wanderbooks/journal.jsonl").exists(),
"no books under {}",
home.display()
);
}
/// wanderbooks.toml beside the data folder is read without --books-toml:
/// its receipt threshold (0.00) refuses a 1.00 entry with no receipt,
/// which the default ($25.00) would post.
#[test]
fn the_settings_beside_the_books_are_read() {
let dir = temp("beside");
std::fs::write(
dir.join("wanderbooks.toml"),
"[books]\nreceipt_threshold = \"0.00\"\n",
)
.unwrap();
let data = dir.join("books-data");
let mut store = ledger_store::Store::create(&data, ledger_core::Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", ledger_core::AccountType::Asset, None, false)
.unwrap();
let rent = store
.add_account(
"6000",
"Rent",
ledger_core::AccountType::Expense,
None,
false,
)
.unwrap();
let e = store
.create_entry(chrono::NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), "x")
.unwrap();
let m = ledger_core::Money::new(rust_decimal::Decimal::ONE, ledger_core::Currency::USD);
store
.add_line(e, rent, ledger_core::Side::Debit, m)
.unwrap();
store
.add_line(e, cash, ledger_core::Side::Credit, m)
.unwrap();
drop(store);
let reply = one_call(
Command::new(BIN).arg("--data-dir").arg(&data),
"books.post_entry",
serde_json::json!({"entry_id": e, "confirmed": true}),
);
assert!(
reply.contains("receipt_required"),
"posted without a receipt: {reply}"
);
}
/// The cash flow roles in wanderbooks.toml beside the data folder are
/// read without --books-toml: its cash account lets cash_flow run with no
/// cash_accounts of its own.
#[test]
fn the_cash_roles_beside_the_books_are_read() {
let dir = temp("roles");
std::fs::write(
dir.join("wanderbooks.toml"),
"[books]\ncash_accounts = [\"1000\"]\n",
)
.unwrap();
let data = dir.join("books-data");
let mut store = ledger_store::Store::create(&data, ledger_core::Currency::USD).unwrap();
store
.add_account("1000", "Cash", ledger_core::AccountType::Asset, None, false)
.unwrap();
drop(store);
let reply = one_call(
Command::new(BIN).arg("--data-dir").arg(&data),
"books.cash_flow",
serde_json::json!({"start": "2026-01-01", "end": "2026-03-31"}),
);
assert!(reply.contains("\"isError\":false"), "{reply}");
}
/// A writer that keeps the books is a conflict: the server gives up with
/// an error (after its ten-second wait), never hangs.
#[test]
fn a_held_lock_is_refused_not_waited_on_forever() {
let dir = temp("locked");
let _holder = ledger_store::Store::create(&dir, ledger_core::Currency::USD).unwrap();
let start = Instant::now();
let mut child = Command::new(BIN)
.arg("--data-dir")
.arg(&dir)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
let status = loop {
if let Some(s) = child.try_wait().unwrap() {
break s;
}
if start.elapsed() > Duration::from_secs(60) {
let _ = child.kill();
panic!("the server waited on a held lock for a minute");
}
std::thread::sleep(Duration::from_millis(100));
};
assert!(!status.success());
assert!(
start.elapsed() >= Duration::from_secs(9),
"gave up before waiting"
);
}
/// Books written by a newer wanderbooks: the server won't start on them,
/// and says to update it, not that the books are corrupt (rules.md B-27).
#[test]
fn newer_books_are_refused_as_newer_not_corrupt() {
let dir = temp("newer");
drop(ledger_store::Store::create(&dir, ledger_core::Currency::USD).unwrap());
let mut w = ledger_store::JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
w.append(
"journal.format_set",
serde_json::json!({"format": 3, "features": []}),
)
.unwrap();
// The next line chains onto it, so its bytes are checked (B-27).
w.append(
"fx.rate_set",
serde_json::json!({"currency": "EUR", "date": "2026-10-01", "rate": "1.10"}),
)
.unwrap();
let journal = std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap();
let out = Command::new(BIN)
.arg("--data-dir")
.arg(&dir)
.stdin(Stdio::null())
.output()
.unwrap();
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("written by a newer version of wanderbooks"),
"{err}"
);
assert!(err.contains("Update wanderbooks"), "{err}");
assert!(!err.contains("corrupt"), "{err}");
assert_eq!(
std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap(),
journal
);
}
/// Start the server and report whether it refused to start (it never got
/// as far as serving), with its standard error.
fn refuses_to_start(cmd: &mut Command) -> Option<String> {
let mut child = cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
// A server that starts waits on stdin; closing it ends the server.
drop(child.stdin.take());
let out = child.wait_with_output().unwrap();
let err = String::from_utf8_lossy(&out.stderr).into_owned();
(!err.contains("serving over stdio")).then_some(err)
}
/// local_files.rs INVARIANT 0: a named import root that is `/`, the home
/// folder, a folder holding it, a folder holding the books, the data dir
/// (a symlinked --data-dir placed where it really is), the vault or a key
/// file, or a folder inside the books, the data dir or the vault, stops
/// the server at startup, saying why; a folder holding none of them is
/// used.
#[test]
fn an_import_root_holding_the_books_or_the_home_folder_is_refused() {
let tmp = temp("bad-roots");
let dir = tmp.canonicalize().unwrap();
let home = dir.join("home");
let books = home.join("work/books");
let data = books.join("books-data");
let keys = dir.join("keys");
for d in [&home.join("Downloads"), &books, &keys, &dir.join("config")] {
std::fs::create_dir_all(d).unwrap();
}
std::fs::write(
books.join("wanderbooks.toml"),
format!(
"[vault]\nkey_file = \"{}\"\n",
keys.join("vault.key").display()
),
)
.unwrap();
let link = dir.join("data-link");
std::os::unix::fs::symlink(&data, &link).unwrap();
let start = |data: &std::path::Path, root: &std::path::Path| {
let mut cmd = Command::new(BIN);
cmd.arg("--data-dir")
.arg(data)
.arg("--import-root")
.arg(root)
.env("HOME", &home)
.env("XDG_CONFIG_HOME", dir.join("config"))
.env("XDG_RUNTIME_DIR", dir.join("config"));
refuses_to_start(&mut cmd)
};
for (root, why) in [
(std::path::PathBuf::from("/"), "whole filesystem"),
(home.clone(), "home folder"),
(dir.clone(), "home folder"),
(home.join("work"), "holds the books"),
(books.clone(), "holds the books"),
(data.clone(), "holds the books"),
(
keys.clone(),
"holds the books, the TIN vault, or a vault key",
),
(
dir.join("config"),
"holds the books, the TIN vault, or a vault key",
),
] {
let err = start(&data, &root).unwrap_or_else(|| panic!("{} was used", root.display()));
assert!(
err.contains("can't be used") && err.contains(why),
"{}: {err}",
root.display()
);
}
// A folder inside the books, the data dir or the vault: every file in
// it would be refused, so the root is.
for root in [
books.join("company"),
data.join("blobs"),
books.join("vault/inner"),
] {
std::fs::create_dir_all(&root).unwrap();
let err = start(&data, &root).unwrap_or_else(|| panic!("{} was used", root.display()));
assert!(
err.contains("can't be used") && err.contains("inside the books"),
"{}: {err}",
root.display()
);
}
// Through a symlinked data dir, the books are where they really are.
let err = start(&link, &home.join("work")).expect("a root holding the real books");
assert!(err.contains("holds the books"), "{err}");
// A folder holding none of them is used.
assert_eq!(start(&data, &home.join("Downloads")), None);
}
/// local_files.rs INVARIANT 0, the default: an XDG_DOWNLOAD_DIR of
/// "$HOME/" (or "/") never makes the home folder an import root. With no
/// real ~/Downloads there is no root, and an import says so; with one, it
/// is the root, and the rest of the home folder stays out of reach.
#[test]
fn a_download_dir_of_home_never_opens_the_home_folder() {
let tmp = temp("xdg-home");
let dir = tmp.canonicalize().unwrap();
let home = dir.join("home");
std::fs::create_dir_all(home.join(".ssh")).unwrap();
std::fs::write(home.join(".ssh/id_ed25519"), "PRIVATE KEY").unwrap();
let key = home.join(".ssh/id_ed25519");
let import = |xdg: &str, path: &std::path::Path| {
one_call(
Command::new(BIN)
.arg("--data-dir")
.arg(dir.join("books/books-data"))
.env("HOME", &home)
.env("XDG_DOWNLOAD_DIR", xdg)
.env("XDG_CONFIG_HOME", dir.join("config"))
.env("XDG_RUNTIME_DIR", dir.join("config")),
"books.import_local_file",
serde_json::json!({"path": path.to_str().unwrap(), "category": "document"}),
)
};
for xdg in ["$HOME/", "$HOME", "/"] {
let reply = import(xdg, &key);
assert!(reply.contains("has no import folder"), "{xdg}: {reply}");
}
std::fs::create_dir_all(home.join("Downloads")).unwrap();
std::fs::write(home.join("Downloads/receipt.pdf"), "%PDF a receipt").unwrap();
for xdg in ["$HOME/", "/"] {
let reply = import(xdg, &key);
assert!(
reply.contains("no such file in the server's import folders"),
"{xdg}: {reply}"
);
assert!(!reply.contains("PRIVATE"), "{reply}");
}
let reply = import("$HOME/", &home.join("Downloads/receipt.pdf"));
assert!(reply.contains("attachment_id"), "{reply}");
}
/// local_files.rs INVARIANT 0, a refused default: a downloads folder
/// that holds the books, or one inside them, is dropped, never fatal
/// (only a named root stops the server): it serves, with no import
/// folder, and an import says so.
#[test]
fn a_default_download_dir_holding_the_books_is_dropped_not_fatal() {
let tmp = temp("xdg-books");
let dir = tmp.canonicalize().unwrap();
let home = dir.join("home");
let downloads = dir.join("downloads");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(downloads.join("books")).unwrap();
std::fs::write(downloads.join("receipt.pdf"), "%PDF a receipt").unwrap();
let reply = one_call(
Command::new(BIN)
.arg("--data-dir")
.arg(downloads.join("books/books-data"))
.env("HOME", &home)
.env("XDG_DOWNLOAD_DIR", &downloads)
.env("XDG_CONFIG_HOME", dir.join("config"))
.env("XDG_RUNTIME_DIR", dir.join("config")),
"books.import_local_file",
serde_json::json!({"path": downloads.join("receipt.pdf").to_str().unwrap(), "category": "document"}),
);
assert!(reply.contains("has no import folder"), "{reply}");
// A downloads folder inside the books dir is dropped the same way.
let books = dir.join("books");
std::fs::create_dir_all(books.join("Downloads")).unwrap();
std::fs::write(books.join("Downloads/receipt.pdf"), "%PDF a receipt").unwrap();
let reply = one_call(
Command::new(BIN)
.arg("--data-dir")
.arg(books.join("books-data"))
.env("HOME", &home)
.env("XDG_DOWNLOAD_DIR", books.join("Downloads"))
.env("XDG_CONFIG_HOME", dir.join("config"))
.env("XDG_RUNTIME_DIR", dir.join("config")),
"books.import_local_file",
serde_json::json!({"path": books.join("Downloads/receipt.pdf").to_str().unwrap(), "category": "document"}),
);
assert!(reply.contains("has no import folder"), "{reply}");
}