444 lines
16 KiB
Rust
444 lines
16 KiB
Rust
//! End-to-end smoke test: drive the real `ledger-mcp` binary over stdio
|
||
//! with raw JSON-RPC, exercising draft → preview → receipt policy → post.
|
||
|
||
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,
|
||
#[allow(dead_code)]
|
||
child: Child,
|
||
}
|
||
|
||
impl Mcp {
|
||
fn spawn(data_dir: &std::path::Path) -> Self {
|
||
let bin = env!("CARGO_BIN_EXE_ledger-mcp");
|
||
let mut child = Command::new(bin)
|
||
.arg("--data-dir")
|
||
.arg(data_dir)
|
||
.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()
|
||
}
|
||
}
|
||
|
||
fn test_dir(name: &str) -> std::path::PathBuf {
|
||
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();
|
||
dir
|
||
}
|
||
|
||
#[test]
|
||
fn mcp_end_to_end_over_stdio() {
|
||
let dir = test_dir("main");
|
||
let mut mcp = Mcp::spawn(&dir);
|
||
|
||
// Initialize: the server identifies itself and delivers the contract.
|
||
let init = mcp.request(
|
||
"initialize",
|
||
json!({
|
||
"protocolVersion": "2025-06-18",
|
||
"capabilities": {},
|
||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||
}),
|
||
);
|
||
assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks");
|
||
let instructions = init["result"]["instructions"].as_str().unwrap();
|
||
assert!(instructions.contains("bookkeeping agent"));
|
||
assert!(instructions.contains("decimal strings"));
|
||
mcp.notify("notifications/initialized");
|
||
|
||
// Tool listing: 23 narrow tools, chart is read-only.
|
||
let list = mcp.request("tools/list", json!({}));
|
||
let tools = list["result"]["tools"].as_array().unwrap();
|
||
assert_eq!(tools.len(), 23, "expected 23 tools, got {}", tools.len());
|
||
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
||
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
||
assert!(
|
||
chart["description"]
|
||
.as_str()
|
||
.unwrap()
|
||
.contains("chart of accounts")
|
||
);
|
||
|
||
// Fresh books: empty chart.
|
||
let chart_out = mcp.call("books.chart", json!({}));
|
||
assert_eq!(chart_out["accounts"].as_array().unwrap().len(), 0);
|
||
assert_eq!(chart_out["base_currency"], "USD");
|
||
|
||
// Build a chart.
|
||
for (code, name, typ) in [
|
||
("1000", "Cash", "asset"),
|
||
("3000", "Owner's Equity", "equity"),
|
||
("4000", "Service Revenue", "revenue"),
|
||
("6000", "Rent Expense", "expense"),
|
||
] {
|
||
let out = mcp.call(
|
||
"books.add_account",
|
||
json!({"code": code, "name": name, "account_type": typ}),
|
||
);
|
||
assert!(out["account_id"].as_u64().unwrap() > 0);
|
||
}
|
||
// Duplicate code is rejected.
|
||
let err = mcp.call_err(
|
||
"books.add_account",
|
||
json!({"code": "1000", "name": "Cash 2", "account_type": "asset"}),
|
||
);
|
||
assert!(err.contains("already in use"), "got: {err}");
|
||
|
||
// Draft → lines → preview.
|
||
let draft = mcp.call(
|
||
"books.draft_entry",
|
||
json!({"date": "2026-01-15", "description": "January rent"}),
|
||
);
|
||
let entry_id = draft["entry_id"].as_u64().unwrap();
|
||
mcp.call(
|
||
"books.add_line",
|
||
json!({"entry_id": entry_id, "account": "6000", "side": "debit", "amount": "100.00"}),
|
||
);
|
||
mcp.call(
|
||
"books.add_line",
|
||
json!({"entry_id": entry_id, "account": "1000", "side": "credit", "amount": "100.00"}),
|
||
);
|
||
let preview = mcp.call("books.post_entry", json!({"entry_id": entry_id}));
|
||
assert_eq!(preview["status"], "preview");
|
||
assert_eq!(preview["total"], "100.00");
|
||
assert_eq!(preview["receipt_required"], true);
|
||
assert_eq!(preview["receipt_missing"], true);
|
||
assert_eq!(preview["lines"].as_array().unwrap().len(), 2);
|
||
// Resulting balances: rent expense goes 0 → 100.
|
||
let rent_line = preview["lines"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.find(|l| l["code"] == "6000")
|
||
.unwrap();
|
||
assert_eq!(rent_line["balance_before"], "0");
|
||
assert_eq!(rent_line["balance_after"], "100.00");
|
||
|
||
// Confirmed post without a receipt is rejected: >$25, no receipt.
|
||
let err = mcp.call_err(
|
||
"books.post_entry",
|
||
json!({"entry_id": entry_id, "confirmed": true}),
|
||
);
|
||
assert!(err.contains("receipt_required"), "got: {err}");
|
||
|
||
// Attach the receipt, then the confirmed post succeeds.
|
||
let png = base64_like(b"fake-png-bytes");
|
||
let attached = mcp.call(
|
||
"books.attach_receipt",
|
||
json!({
|
||
"entry_id": entry_id,
|
||
"file_name": "rent.png",
|
||
"mime_type": "image/png",
|
||
"content_base64": png,
|
||
}),
|
||
);
|
||
assert_eq!(attached["deduplicated"], false);
|
||
assert_eq!(attached["sha256"].as_str().unwrap().len(), 64);
|
||
let posted = mcp.call(
|
||
"books.post_entry",
|
||
json!({"entry_id": entry_id, "confirmed": true}),
|
||
);
|
||
assert_eq!(posted["status"], "posted");
|
||
assert_eq!(posted["receipt_missing"], false);
|
||
|
||
// Reports agree.
|
||
let tb = mcp.call("books.trial_balance", json!({}));
|
||
assert_eq!(tb["balanced"], true);
|
||
assert_eq!(tb["total_debits"], "100.00");
|
||
assert_eq!(tb["total_credits"], "100.00");
|
||
let pnl = mcp.call(
|
||
"books.profit_and_loss",
|
||
json!({"start": "2026-01-01", "end": "2026-01-31"}),
|
||
);
|
||
assert_eq!(pnl["expenses"], "100.00");
|
||
assert_eq!(pnl["net_income"], "-100.00");
|
||
let bs = mcp.call("books.balance_sheet", json!({"as_of": "2026-01-31"}));
|
||
assert_eq!(bs["balanced"], true);
|
||
|
||
// Digest and search see the entry.
|
||
let digest = mcp.call("books.digest", json!({"since": "2026-01-01"}));
|
||
assert_eq!(digest["entries"].as_array().unwrap().len(), 1);
|
||
assert_eq!(digest["entries"][0]["receipt_missing"], false);
|
||
let search = mcp.call("books.search_entries", json!({"text": "rent"}));
|
||
assert_eq!(search["entries"].as_array().unwrap().len(), 1);
|
||
assert_eq!(search["entries"][0]["receipt_count"], 1);
|
||
|
||
// Export works.
|
||
let export = mcp.call(
|
||
"books.export",
|
||
json!({"format": "csv", "report": "journal"}),
|
||
);
|
||
assert!(export["content"].as_str().unwrap().contains("January rent"));
|
||
|
||
// Reversal: preview then post; the book still balances.
|
||
let rev_preview = mcp.call(
|
||
"books.reverse_entry",
|
||
json!({"entry_id": entry_id, "date": "2026-01-16", "description": "Reverse rent"}),
|
||
);
|
||
assert_eq!(rev_preview["status"], "preview");
|
||
let rev = mcp.call(
|
||
"books.reverse_entry",
|
||
json!({"entry_id": entry_id, "date": "2026-01-16", "description": "Reverse rent", "confirmed": true}),
|
||
);
|
||
assert_eq!(rev["status"], "posted");
|
||
let tb2 = mcp.call("books.trial_balance", json!({}));
|
||
assert_eq!(tb2["balanced"], true);
|
||
assert_eq!(tb2["total_debits"], "0");
|
||
}
|
||
|
||
#[test]
|
||
fn mcp_assets_end_to_end_over_stdio() {
|
||
let dir = test_dir("assets");
|
||
let mut mcp = Mcp::spawn(&dir);
|
||
let init = mcp.request(
|
||
"initialize",
|
||
json!({
|
||
"protocolVersion": "2025-06-18",
|
||
"capabilities": {},
|
||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||
}),
|
||
);
|
||
assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks");
|
||
mcp.notify("notifications/initialized");
|
||
|
||
// Chart for the asset: cost, contra, expense, bank, gain.
|
||
for (code, name, typ) in [
|
||
("1500", "Equipment", "asset"),
|
||
("1501", "Accumulated Depreciation", "asset"),
|
||
("6100", "Depreciation Expense", "expense"),
|
||
("1000", "Bank", "asset"),
|
||
("4900", "Gain on Disposal", "revenue"),
|
||
] {
|
||
mcp.call(
|
||
"books.add_account",
|
||
json!({"code": code, "name": name, "account_type": typ}),
|
||
);
|
||
}
|
||
|
||
// Register the van: 60,000, 60-month life, bought 2026-01-15.
|
||
let reg = mcp.call(
|
||
"books.register_asset",
|
||
json!({
|
||
"description": "Sprinter van",
|
||
"purchase_date": "2026-01-15",
|
||
"cost": "60000.00",
|
||
"useful_life_months": 60,
|
||
"cost_account": "1500",
|
||
"accum_depr_account": "1501",
|
||
"expense_account": "6100",
|
||
}),
|
||
);
|
||
let asset_id = reg["asset_id"].as_u64().unwrap();
|
||
|
||
// Schedule: 61 calendar months, lifetime total exactly 60,000.
|
||
let sched = mcp.call("books.asset_schedule", json!({"asset_id": asset_id}));
|
||
let lines = sched["schedule"].as_array().unwrap();
|
||
assert_eq!(lines.len(), 61);
|
||
assert_eq!(lines[0]["period"], "2026-01");
|
||
assert_eq!(lines[0]["amount"], "548.39");
|
||
assert_eq!(lines[60]["period"], "2031-01");
|
||
let total: f64 = lines
|
||
.iter()
|
||
.map(|l| l["amount"].as_str().unwrap().parse::<f64>().unwrap())
|
||
.sum();
|
||
assert!((total - 60000.0).abs() < 0.005, "total was {total}");
|
||
assert_eq!(sched["status"], "active");
|
||
|
||
// List: one active asset.
|
||
let list = mcp.call("books.list_assets", json!({}));
|
||
assert_eq!(list["assets"].as_array().unwrap().len(), 1);
|
||
assert_eq!(list["assets"][0]["status"], "active");
|
||
|
||
// Run January: posts 548.39.
|
||
let run1 = mcp.call(
|
||
"books.run_depreciation",
|
||
json!({"period": "2026-01", "asset_id": asset_id}),
|
||
);
|
||
assert_eq!(run1["runs"][0]["amount"], "548.39");
|
||
assert_eq!(run1["runs"][0]["already_posted"], false);
|
||
let entry_id = run1["runs"][0]["entry_id"].as_u64().unwrap();
|
||
|
||
// Idempotent: second run returns the same entry.
|
||
let run2 = mcp.call(
|
||
"books.run_depreciation",
|
||
json!({"period": "2026-01", "asset_id": asset_id}),
|
||
);
|
||
assert_eq!(run2["runs"][0]["already_posted"], true);
|
||
assert_eq!(run2["runs"][0]["entry_id"], entry_id);
|
||
|
||
// Run-all for February: picks up the van, posts a full 1,000.
|
||
let run_all = mcp.call("books.run_depreciation", json!({"period": "2026-02"}));
|
||
assert_eq!(run_all["runs"][0]["amount"], "1000.00");
|
||
assert_eq!(run_all["skipped"].as_array().unwrap().len(), 0);
|
||
// And again: already posted.
|
||
let run_all2 = mcp.call("books.run_depreciation", json!({"period": "2026-02"}));
|
||
assert_eq!(run_all2["runs"][0]["already_posted"], true);
|
||
|
||
// Dispose preview: book value 60000 − 1548.39 = 58451.61;
|
||
// proceeds 60000 → gain 1548.39. Receipt required.
|
||
let preview = mcp.call(
|
||
"books.dispose_asset",
|
||
json!({
|
||
"asset_id": asset_id,
|
||
"date": "2026-02-28",
|
||
"proceeds": "60000",
|
||
"proceeds_account": "1000",
|
||
"gain_loss_account": "4900",
|
||
}),
|
||
);
|
||
assert_eq!(preview["status"], "preview");
|
||
assert_eq!(preview["book_value"], "58451.61");
|
||
assert_eq!(preview["gain_loss"], "1548.39");
|
||
assert_eq!(preview["final_depreciation"], "0");
|
||
assert_eq!(preview["receipt_required"], true);
|
||
|
||
// Confirmed without the bill of sale: rejected.
|
||
let err = mcp.call_err(
|
||
"books.dispose_asset",
|
||
json!({
|
||
"asset_id": asset_id,
|
||
"date": "2026-02-28",
|
||
"proceeds": "60000",
|
||
"proceeds_account": "1000",
|
||
"gain_loss_account": "4900",
|
||
"confirmed": true,
|
||
}),
|
||
);
|
||
assert!(err.contains("receipt_required"), "got: {err}");
|
||
|
||
// File the bill of sale, then confirm with its attachment id.
|
||
let bill = mcp.call(
|
||
"books.file_document",
|
||
json!({
|
||
"category": "legal",
|
||
"file_name": "bill-of-sale.pdf",
|
||
"mime_type": "application/pdf",
|
||
"content_base64": base64_like(b"fake bill of sale"),
|
||
}),
|
||
);
|
||
let aid = bill["attachment_id"].as_u64().unwrap();
|
||
let posted = mcp.call(
|
||
"books.dispose_asset",
|
||
json!({
|
||
"asset_id": asset_id,
|
||
"date": "2026-02-28",
|
||
"proceeds": "60000",
|
||
"proceeds_account": "1000",
|
||
"gain_loss_account": "4900",
|
||
"confirmed": true,
|
||
"receipt_attachment_ids": [aid],
|
||
}),
|
||
);
|
||
assert_eq!(posted["status"], "posted");
|
||
assert_eq!(posted["gain_loss"], "1548.39");
|
||
|
||
// The bill of sale is linked to the disposal entry.
|
||
let receipts = mcp.call(
|
||
"books.list_receipts",
|
||
json!({"entry_id": posted["entry_id"]}),
|
||
);
|
||
assert_eq!(receipts["receipts"].as_array().unwrap().len(), 1);
|
||
|
||
// Asset is disposed; further depreciation is refused.
|
||
let list2 = mcp.call("books.list_assets", json!({}));
|
||
assert_eq!(list2["assets"][0]["status"], "disposed");
|
||
let err2 = mcp.call_err(
|
||
"books.run_depreciation",
|
||
json!({"period": "2026-03", "asset_id": asset_id}),
|
||
);
|
||
assert!(err2.contains("disposed"), "got: {err2}");
|
||
|
||
// The books still balance.
|
||
let tb = mcp.call("books.trial_balance", json!({}));
|
||
assert_eq!(tb["balanced"], true);
|
||
}
|
||
|
||
fn base64_like(bytes: &[u8]) -> String {
|
||
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||
let mut out = String::new();
|
||
for chunk in bytes.chunks(3) {
|
||
let mut n: u32 = 0;
|
||
for (i, &b) in chunk.iter().enumerate() {
|
||
n |= (b as u32) << (16 - 8 * i);
|
||
}
|
||
let pad = 3 - chunk.len();
|
||
for i in 0..4 - pad {
|
||
out.push(ALPHABET[((n >> (18 - 6 * i)) & 63) as usize] as char);
|
||
}
|
||
for _ in 0..pad {
|
||
out.push('=');
|
||
}
|
||
}
|
||
out
|
||
}
|