Files
wanderbooks/crates/ledger-mcp/tests/spec_kept.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

693 lines
25 KiB
Rust

//! P1 through the real server: settings moving into the books, the books'
//! own settings, conventions, approvals, books.start (plan/served.md P1).
//! Drives the real `ledger-mcp` binary over stdio with raw JSON-RPC.
use serde_json::{Value, json};
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
struct Mcp {
stdin: ChildStdin,
stdout: BufReader<std::process::ChildStdout>,
next_id: u64,
child: Child,
}
/// Closing stdin ends the server; wait for it to exit, so the next server
/// on the same books never races it for the writer lock.
impl Drop for Mcp {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Mcp {
fn spawn(data_dir: &std::path::Path) -> Self {
Self::spawn_with(data_dir, &[])
}
fn spawn_with(data_dir: &std::path::Path, extra: &[&std::ffi::OsStr]) -> Self {
Self::spawn_env(data_dir, extra, &[])
}
fn spawn_env(
data_dir: &std::path::Path,
extra: &[&std::ffi::OsStr],
env: &[(&str, &std::path::Path)],
) -> Self {
let bin = env!("CARGO_BIN_EXE_ledger-mcp");
let mut child = Command::new(bin)
.arg("--data-dir")
.arg(data_dir)
.args(extra)
.envs(env.iter().map(|(k, v)| (*k, v.as_os_str())))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn ledger-mcp");
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
Self {
stdin,
stdout,
next_id: 1,
child,
}
}
fn request(&mut self, method: &str, params: Value) -> Value {
let id = self.next_id;
self.next_id += 1;
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
writeln!(self.stdin, "{msg}").unwrap();
self.stdin.flush().unwrap();
let mut line = String::new();
self.stdout
.read_line(&mut line)
.expect("read response line");
let resp: Value = serde_json::from_str(&line).expect("parse response");
assert_eq!(resp["id"], id, "id mismatch in {resp}");
assert!(
resp.get("error").is_none(),
"JSON-RPC error for {method}: {}",
resp["error"]
);
resp
}
fn notify(&mut self, method: &str) {
let msg = json!({"jsonrpc": "2.0", "method": method, "params": {}});
writeln!(self.stdin, "{msg}").unwrap();
self.stdin.flush().unwrap();
}
/// Call a tool; panic on tool error. Returns structuredContent.
fn call(&mut self, tool: &str, args: Value) -> Value {
let resp = self.request("tools/call", json!({"name": tool, "arguments": args}));
let result = &resp["result"];
assert_ne!(
result.get("isError"),
Some(&json!(true)),
"tool {tool} returned an error: {}",
result["content"]
);
result.get("structuredContent").cloned().unwrap_or_else(|| {
let text = result["content"][0]["text"].as_str().unwrap();
serde_json::from_str(text).unwrap()
})
}
/// Call a tool expecting an error; returns the error text.
fn call_err(&mut self, tool: &str, args: Value) -> String {
let resp = self.request("tools/call", json!({"name": tool, "arguments": args}));
let result = &resp["result"];
assert_eq!(
result.get("isError"),
Some(&json!(true)),
"expected {tool} to fail, got {result}"
);
result["content"][0]["text"].as_str().unwrap().to_string()
}
}
/// 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 Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn test_dir(name: &str) -> TestDir {
let dir = std::env::temp_dir().join(format!("ledger-mcp-test-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
fn init(m: &mut Mcp) -> String {
let r = m.request(
"initialize",
json!({"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "spec-kept", "version": "0"}}),
);
m.notify("notifications/initialized");
r["result"]["instructions"].as_str().unwrap().to_string()
}
const TOML: &str = r#"[company]
name = "Kept Co LLC"
tax_classification = "partnership" # Form 1065; a Schedule K-1 per member
fiscal_year_start = "01-01"
ein = "43-2215893"
[[company.members]]
name = "A"
share = "50%"
capital_account = "3000"
[[company.members]]
name = "B"
share = "50%"
capital_account = "3001"
[books]
data_dir = "books-data"
receipt_threshold = "0.00"
cash_accounts = ["1000"]
parties = "require"
[viewer]
open_at_start = "ask"
"#;
fn chart(m: &mut Mcp) {
for (code, name, t) in [
("1000", "Bank", "asset"),
("3000", "Capital A", "equity"),
("3001", "Capital B", "equity"),
("6000", "Software", "expense"),
] {
m.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": t}),
);
}
}
/// A settings file moves into the books, once, with the human's go: set
/// is refused before (it would drop the rest of the file); the preview
/// changes nothing; after, the file is never read, even to start, and the
/// company's EIN only ever leaves the server masked.
#[test]
fn a_settings_file_moves_into_the_books() {
let dir = test_dir("kept-move");
let data = dir.join("books-data");
std::fs::write(dir.join("wanderbooks.toml"), TOML).unwrap();
let mut m = Mcp::spawn(&data);
let told = init(&mut m);
assert!(told.contains("books.start"), "{told}");
assert!(
told.contains("the settings file books.settings names"),
"{told}"
);
chart(&mut m);
assert_eq!(m.call("books.settings", json!({}))["source"], "file");
let start = m.call("books.start", json!({}));
assert_eq!(start["settings_source"], "file");
assert!(
start["next_steps"]
.to_string()
.contains("books.import_settings")
);
let refused = m.call_err(
"books.set_setting",
json!({"changes": [{"key": "company.cpa", "value": "Krista"}], "decided_by": "A"}),
);
assert!(refused.contains("books.import_settings"), "{refused}");
// The preview records nothing.
let head = |m: &mut Mcp| m.call("books.start", json!({}))["head"]["seq"].clone();
let before = head(&mut m);
let preview = m.call(
"books.import_settings",
json!({"toml_text": TOML, "decided_by": "A", "note": "Moving in."}),
);
assert_eq!(preview["status"], "preview");
assert_eq!(head(&mut m), before);
assert!(
preview["notes"].to_string().contains("data_dir"),
"{preview}"
);
assert!(
preview["unknown"].as_array().unwrap().is_empty(),
"{preview}"
);
// The file's comments are listed, never dropped unseen.
assert_eq!(
preview["comments"],
json!(["Form 1065; a Schedule K-1 per member"]),
"{preview}"
);
let shown = preview.to_string();
assert!(!shown.contains("2215893"), "the EIN is masked: {shown}");
assert!(shown.contains("**-***5893"), "{shown}");
let done = m.call(
"books.import_settings",
json!({"toml_text": TOML, "decided_by": "A", "confirmed": true}),
);
assert_eq!(done["status"], "imported");
let settings = m.call("books.settings", json!({}));
assert_eq!(settings["source"], "books");
assert_eq!(settings["books_toml"], Value::Null);
assert_eq!(settings["parties"], "require");
let ein = settings["keys"]
.as_array()
.unwrap()
.iter()
.find(|k| k["key"] == "company.ein")
.unwrap()
.clone();
assert_eq!(ein["value"], "**-***5893");
assert_eq!(ein["set_by"]["decided_by"], "A");
// The file is no longer read: an edit changes nothing.
std::fs::write(dir.join("wanderbooks.toml"), TOML.replace("0.00", "99.00")).unwrap();
let rule = m.call("books.settings", json!({}))["receipt_rule"].clone();
assert!(rule.as_str().unwrap().contains("every entry"), "{rule}");
// Settings change through the tools now.
let set = m.call(
"books.set_setting",
json!({"changes": [{"key": "company.timezone", "value": "America/Denver"},
{"key": "company.ein", "value": "12-3456789"}],
"decided_by": "B", "decided_on": "2026-10-17"}),
);
assert!(!set.to_string().contains("3456789"), "{set}");
let export = m.call("books.export_settings", json!({}));
let text = export["toml_text"].as_str().unwrap();
assert!(text.contains("timezone = \"America/Denver\""), "{text}");
assert!(!text.contains("3456789"), "{text}");
let start = m.call("books.start", json!({}));
assert_eq!(start["settings_source"], "books");
assert_eq!(start["book"]["timezone"], "America/Denver");
assert!(!start.to_string().contains("3456789"));
drop(m);
// A broken settings file beside books that keep their own can't stop
// the server: it is never read.
std::fs::write(dir.join("wanderbooks.toml"), "[books\nthis is not toml").unwrap();
let mut m = Mcp::spawn(&data);
let told = init(&mut m);
assert!(
told.contains("Settings and conventions live in the books"),
"{told}"
);
assert!(!told.contains("local file you may edit"), "{told}");
assert!(told.contains("Kept Co LLC"), "{told}");
assert_eq!(m.call("books.settings", json!({}))["source"], "books");
assert_eq!(m.call("books.verify", json!({}))["status"], "ok");
}
/// INVARIANT 2 (tools/kept.rs): an import that would read any setting
/// differently than the server does now is refused. Here the server reads
/// its receipt policy from config.json, which the import must carry.
#[test]
fn an_import_that_reads_differently_is_refused() {
let dir = test_dir("kept-differs");
let data = dir.join("books-data");
std::fs::create_dir_all(&data).unwrap();
std::fs::write(dir.join("wanderbooks.toml"), TOML).unwrap();
let policy = r#"{"receipt_policy": {"require_receipts_above": "10.00", "receipt_exempt_accounts": ["6999"]}}"#;
std::fs::write(data.join("config.json"), policy).unwrap();
let mut m = Mcp::spawn(&data);
init(&mut m);
let e = m.call_err(
"books.import_settings",
json!({"toml_text": TOML, "decided_by": "A", "confirmed": true}),
);
assert!(e.contains("policy.evidence.threshold"), "{e}");
assert!(e.contains("config_json_text"), "{e}");
assert_eq!(m.call("books.settings", json!({}))["source"], "file");
m.call(
"books.import_settings",
json!({"toml_text": TOML, "decided_by": "A", "confirmed": true,
"config_json_text": policy}),
);
let s = m.call("books.settings", json!({}));
assert_eq!(s["source"], "books");
assert_eq!(s["receipt_exempt_accounts"], json!(["6999"]));
}
/// The vault key a settings file names must come from the command line
/// before the settings move in, or the vault would stop opening.
#[test]
fn an_import_needs_the_vault_key_on_the_command_line() {
let dir = test_dir("kept-vault");
let data = dir.join("books-data");
let key = dir.join("elsewhere").join("vault.key");
let toml = format!("{TOML}\n[vault]\nkey_file = \"{}\"\n", key.display());
std::fs::write(dir.join("wanderbooks.toml"), &toml).unwrap();
let mut m = Mcp::spawn(&data);
init(&mut m);
let e = m.call_err(
"books.import_settings",
json!({"toml_text": toml, "decided_by": "A", "confirmed": true}),
);
assert!(e.contains("--vault-key-file"), "{e}");
drop(m);
let mut m = Mcp::spawn_with(&data, &["--vault-key-file".as_ref(), key.as_os_str()]);
init(&mut m);
m.call(
"books.import_settings",
json!({"toml_text": toml, "decided_by": "A", "confirmed": true}),
);
assert_eq!(m.call("books.settings", json!({}))["source"], "books");
}
/// Approval rules through the server: enforced, a covered post is refused
/// until its approver approves, then posts; off, they block nothing.
#[test]
fn approval_rules_through_the_server() {
let dir = test_dir("kept-approvals");
let data = dir.join("books-data");
let mut m = Mcp::spawn(&data);
init(&mut m);
chart(&mut m);
m.call(
"books.set_setting",
json!({"changes": [{"key": "policy.evidence.threshold", "value": "1000000"},
{"key": "policy.approval_rules", "value": "enforce"}],
"decided_by": "A"}),
);
m.call(
"books.set_convention",
json!({"id": "big-software", "kind": "approval_rule", "title": "Big software",
"body_md": "Software over 500 needs B.",
"structured": {"approver": "B", "accounts": ["6000"], "amount_over": "500"},
"decided_by": "A"}),
);
let e = m.call(
"books.draft_entry",
json!({"date": "2026-10-01", "description": "Laptop", "no_party_reason": "test"}),
)["entry_id"]
.clone();
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
m.call(
"books.add_line",
json!({"entry_id": e, "account": account, "side": side, "amount": "900.00"}),
);
}
let preview = m.call("books.post_entry", json!({"entry_id": e}));
assert_eq!(
preview["approvals_missing"],
json!([{"rule": "big-software", "approver": "B"}])
);
let refused = m.call_err(
"books.post_entry",
json!({"entry_id": e, "confirmed": true}),
);
assert!(refused.contains("approval_required"), "{refused}");
assert!(refused.contains("books.approve_entry"), "{refused}");
m.call(
"books.approve_entry",
json!({"entry_id": e, "approver": "B"}),
);
let preview = m.call("books.post_entry", json!({"entry_id": e}));
assert!(preview.get("approvals_missing").is_none(), "{preview}");
m.call(
"books.post_entry",
json!({"entry_id": e, "confirmed": true}),
);
m.call(
"books.set_setting",
json!({"changes": [{"key": "policy.approval_rules", "value": null}], "decided_by": "A"}),
);
assert_eq!(
m.call("books.start", json!({}))["rules"]["approval_rules"],
"off"
);
}
/// A posting template drafts through the server, to the cent.
#[test]
fn templates_draft_through_the_server() {
let dir = test_dir("kept-template");
let data = dir.join("books-data");
let mut m = Mcp::spawn(&data);
init(&mut m);
chart(&mut m);
m.call(
"books.set_convention",
json!({"id": "contribution", "kind": "posting_template",
"title": "Joint contribution", "body_md": "Half each.",
"structured": {"description": "Contribution", "lines": [
{"account": "1000", "side": "debit", "share": "1"},
{"account": "3000", "side": "credit", "share": "0.5"},
{"account": "3001", "side": "credit", "share": "0.5"}]},
"decided_by": "A"}),
);
let d = m.call(
"books.draft_from_template",
json!({"template_id": "contribution", "date": "2026-10-02", "amount": "0.03",
"no_party_reason": "between the members"}),
);
assert_eq!(d["description"], "Contribution");
let amounts: Vec<&str> = d["lines"]
.as_array()
.unwrap()
.iter()
.map(|l| l["amount"].as_str().unwrap())
.collect();
assert_eq!(amounts, ["0.03", "0.02", "0.01"]);
let e = m.call_err(
"books.draft_from_template",
json!({"template_id": "contribution", "date": "2026-10-02", "amount": "0.01"}),
);
assert!(e.contains("too small"), "{e}");
// A slot takes the account each draft names.
m.call(
"books.set_convention",
json!({"id": "bank-expense", "kind": "posting_template",
"title": "Expense paid from the bank", "body_md": "Dr the expense / Cr 1000.",
"structured": {"lines": [
{"account": "{expense}", "side": "debit", "share": "1"},
{"account": "1000", "side": "credit", "share": "1"}]},
"decided_by": "A"}),
);
let e = m.call_err(
"books.draft_from_template",
json!({"template_id": "bank-expense", "date": "2026-10-02", "amount": "12.00"}),
);
assert!(e.contains("expense"), "{e}");
let d = m.call(
"books.draft_from_template",
json!({"template_id": "bank-expense", "date": "2026-10-02", "amount": "12.00",
"accounts": {"expense": "6000"}, "no_party_reason": "test"}),
);
assert_eq!(d["lines"][0]["account"], "6000");
assert_eq!(d["lines"][1]["account"], "1000");
}
/// books.start stays at most 8 KB whatever the books hold, and says what
/// it left out.
#[test]
fn start_fits_in_8_kb() {
let dir = test_dir("kept-start");
let data = dir.join("books-data");
let mut m = Mcp::spawn(&data);
init(&mut m);
let body =
"A first line long enough to fill its summary to the brim, and then some more words. "
.repeat(3);
for i in 0..60 {
m.call(
"books.set_convention",
json!({"id": format!("note-{i}"), "kind": "note",
"title": format!("A rather long convention title number {i}"),
"body_md": body, "decided_by": "A"}),
);
}
for i in 0..40 {
m.call(
"books.raise_item",
json!({"title": format!("Open item number {i}, with a title of some length"),
"due": "2026-12-31", "decided_by": "A"}),
);
}
let resp = m.request(
"tools/call",
json!({"name": "books.start", "arguments": {}}),
);
let start = &resp["result"]["structuredContent"];
let bytes = serde_json::to_string(start).unwrap().len();
assert!(bytes <= 8 * 1024, "{bytes} bytes");
let cut = start["truncated"].to_string();
assert!(cut.contains("books.conventions"), "{cut}");
assert!(start["conventions"].as_array().unwrap().len() >= 10);
}
/// Review of P1 (2026-10-17): the text imported is the file the server
/// reads, whole; books that keep their own settings take no import that
/// changes them; a settings file never turns approvals on; after the
/// move, a broken config.json can't stop the server; and books.settings
/// shows a file's values before the move.
#[test]
fn an_import_is_the_file_the_server_reads() {
let dir = test_dir("kept-whole");
let data = dir.join("books-data");
std::fs::write(dir.join("wanderbooks.toml"), TOML).unwrap();
let mut m = Mcp::spawn(&data);
init(&mut m);
let keys = m.call("books.settings", json!({}))["keys"].clone();
let ein = keys
.as_array()
.unwrap()
.iter()
.find(|k| k["key"] == "company.ein")
.unwrap()
.clone();
assert_eq!(ein["value"], "**-***5893", "the file's value, masked");
// A text that leaves the EIN out is not the file.
let short = TOML.replace("ein = \"43-2215893\"\n", "");
let e = m.call_err(
"books.import_settings",
json!({"toml_text": short, "decided_by": "A", "confirmed": true}),
);
assert!(e.contains("company.ein"), "{e}");
// Approval rules never come in from a file.
std::fs::write(
dir.join("wanderbooks.toml"),
format!("{TOML}approval_rules = \"enforce\"\n")
.replace("[viewer]\nopen_at_start = \"ask\"\n", ""),
)
.unwrap();
let with_rules = std::fs::read_to_string(dir.join("wanderbooks.toml")).unwrap();
let e = m.call_err(
"books.import_settings",
json!({"toml_text": with_rules, "decided_by": "A", "confirmed": true}),
);
assert!(e.contains("approval"), "{e}");
std::fs::write(dir.join("wanderbooks.toml"), TOML).unwrap();
m.call(
"books.import_settings",
json!({"toml_text": TOML, "decided_by": "A", "confirmed": true}),
);
// Imported books: the same text again is no change; another is refused.
let again = m.call(
"books.import_settings",
json!({"toml_text": TOML, "decided_by": "A", "confirmed": true}),
);
assert!(again["changes"].as_array().unwrap().is_empty(), "{again}");
let e = m.call_err(
"books.import_settings",
json!({"toml_text": short, "decided_by": "A", "confirmed": true}),
);
assert!(e.contains("books.set_setting"), "{e}");
drop(m);
std::fs::write(data.join("config.json"), "{ not json").unwrap();
let mut m = Mcp::spawn(&data);
init(&mut m);
assert_eq!(m.call("books.settings", json!({}))["source"], "books");
}
/// A convention that names an account is listed when that account is
/// renumbered, and as a problem while it names a code no longer in the
/// chart.
#[test]
fn renumbering_names_the_conventions_to_revise() {
let dir = test_dir("kept-renumber");
let data = dir.join("books-data");
let mut m = Mcp::spawn(&data);
init(&mut m);
chart(&mut m);
m.call(
"books.set_convention",
json!({"id": "software", "kind": "posting_template", "title": "Software",
"body_md": "Dr 6000 / Cr 1000.",
"structured": {"lines": [
{"account": "6000", "side": "debit", "share": "1"},
{"account": "1000", "side": "credit", "share": "1"}]},
"decided_by": "A"}),
);
let out = m.call(
"books.renumber_account",
json!({"account": "6000", "code": "6010"}),
);
assert!(
out["config_mentions"]
.to_string()
.contains("convention software"),
"{out}"
);
let problems = m.call("books.settings", json!({}))["problems"].to_string();
assert!(
problems.contains("convention software names account 6000"),
"{problems}"
);
}
/// books://start, books://settings and books://conventions read as their
/// tools do.
#[test]
fn the_kept_resources_read() {
let dir = test_dir("kept-resources");
let data = dir.join("books-data");
let mut m = Mcp::spawn(&data);
init(&mut m);
chart(&mut m);
m.call(
"books.set_convention",
json!({"id": "software", "kind": "note", "title": "Software",
"body_md": "Software is 6000.", "decided_by": "A"}),
);
let read = |m: &mut Mcp, uri: &str| -> Value {
let r = m.request("resources/read", json!({"uri": uri}));
let text = r["result"]["contents"][0]["text"].as_str().unwrap();
serde_json::from_str(text).unwrap()
};
let start = read(&mut m, "books://start");
assert_eq!(start["state"], "active", "{start}");
assert_eq!(start["conventions"][0]["id"], "software", "{start}");
let settings = read(&mut m, "books://settings");
assert_eq!(settings["source"], "file", "{settings}");
let conventions = read(&mut m, "books://conventions");
assert_eq!(
conventions["conventions"][0]["id"], "software",
"{conventions}"
);
drop(m);
let _ = std::fs::remove_dir_all(&dir);
}
/// A renumber lists exactly the conventions naming the old code.
#[test]
fn renumbering_lists_only_the_conventions_naming_it() {
let dir = test_dir("kept-renumber-only");
let data = dir.join("books-data");
let mut m = Mcp::spawn(&data);
init(&mut m);
chart(&mut m);
for (id, dr, cr) in [("software", "6000", "1000"), ("deposit", "1000", "3000")] {
m.call(
"books.set_convention",
json!({"id": id, "kind": "posting_template", "title": id,
"body_md": format!("Dr {dr} / Cr {cr}."),
"structured": {"lines": [
{"account": dr, "side": "debit", "share": "1"},
{"account": cr, "side": "credit", "share": "1"}]},
"decided_by": "A"}),
);
}
let out = m.call(
"books.renumber_account",
json!({"account": "6000", "code": "6010"}),
);
let mentions: Vec<String> = out["config_mentions"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.filter(|s| s.starts_with("convention "))
.collect();
assert_eq!(
mentions,
["convention software (revise it with books.set_convention)"],
"{out}"
);
drop(m);
let _ = std::fs::remove_dir_all(&dir);
}